Files
douyin-pc/src/douyin_im.py
T
2026-09-20 16:12:32 +08:00

190 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""通过已登录抖音页的官方 IM SDK 读取私信、发送文本;不导航或点击用户主页。"""
import argparse
import json
import subprocess
import sys
from cdp_explicit import ( # pyright: ignore[reportMissingImports]
add_cdp_args,
configure,
evaluate_script,
)
from follow_user import validate_uid
EXPRESSION = r"""
(async () => {
const p = __PARAMS__;
const fail = code => { throw new Error(code); };
try {
if (location.origin !== 'https://www.douyin.com') fail('WRONG_ORIGIN');
const response = await fetch('/aweme/v1/web/user/profile/self/?device_platform=webapp&aid=6383',
{credentials: 'include', signal: AbortSignal.timeout(15000)});
if (!response.ok) fail('LOGIN_CHECK_FAILED');
const profile = await response.json();
if (profile.status_code !== 0 || !profile.user?.uid || profile.user.uid === '0')
fail('LOGIN_REQUIRED');
if (String(profile.user.uid) === p.uid) fail('SELF_TARGET');
// 按模块特征发现当前版本,不固定 chunk 名称、模块数字 ID 或账号。
let service;
for (const key of Object.keys(window).filter(k => k.startsWith('@pc-im/im:'))) {
const chunks = window[key];
if (!Array.isArray(chunks)) continue;
let req;
chunks.push([['python_im_' + crypto.randomUUID()], {}, r => { req = r; }]);
for (const [id, module] of Object.entries(req?.c || {})) {
if (!String(req.m[id]).includes('getOrCreatePrivateConversationByUid')) continue;
for (const exported of Object.values(module.exports || {})) {
if (exported?.instance?.imSdkService) service = exported.instance.imSdkService;
}
}
}
if (!service) fail('IM_SDK_NOT_READY');
const sdk = service.imSdkManager.getImSdkInstance();
const sendManager = service.sendMessageManager;
if (!sdk || !sendManager || typeof sendManager.createMessageBuilder !== 'function' ||
typeof sendManager.sendMessage !== 'function') fail('IM_SDK_NOT_READY');
const meta = c => ({id: String(c.id), short_id: String(c.shortId),
uid: String(c.toParticipantUserId), type: c.type});
const pack = m => ({server_id: String(m.serverId || ''), client_id: m.clientId || null,
sender: String(m.sender), type: m.type, content: m.content,
created_at: m.createdAt, server_status: m.serverStatus});
let conversation = sdk.getConversationList().find(c =>
c.type === 1 && String(c.toParticipantUserId) === p.uid);
if (p.action === 'send' && !p.confirm) {
return JSON.stringify({action: 'preview', sender_uid: String(profile.user.uid),
uid: p.uid, text: p.text, conversation: conversation ? meta(conversation) : null,
note: '未创建会话、未创建或发送消息;添加 --confirm 才会发送'});
}
if (!conversation && p.action === 'history') {
conversation = await service.conversationManager.getConversationByUidOrShortIdIfOnline(
{participantId: p.uid});
}
if (!conversation && p.action === 'send') {
conversation = await service.conversationManager.getOrCreatePrivateConversationByUid(p.uid);
}
if (!conversation) fail('CONVERSATION_NOT_FOUND');
if (conversation.type !== 1 || String(conversation.toParticipantUserId) !== p.uid)
fail('TARGET_MISMATCH');
if (p.action === 'history') {
const result = await sdk.getMessagesByConversation({conversation,
cursor: p.cursor, limit: p.limit});
if (!Array.isArray(result?.messages)) fail('HISTORY_FAILED');
return JSON.stringify({action: 'history', conversation: meta(conversation),
messages: result.messages.map(pack), has_more: result.hasMore,
next_cursor: result.cursor == null ? null : String(result.cursor)});
}
// 复用站内 sendMessageManager,不直接调用 sdk.sendMessage:站内流程会在发送前
// 更新 identity security headers,并处理官方一次性风控验证/重试回调。
const builder = sendManager.createMessageBuilder({
conversation, messageType: 7, enterMethod: 'douyin_accounts_worker'});
builder.toUid(p.uid).content(JSON.stringify({
aweType: 700, type: 0, richTextInfos: [], text: p.text}));
const result = await sendManager.sendMessage(builder);
const message = builder.getMessage();
if (!message) fail('MESSAGE_BUILD_FAILED');
let checkInfo = {};
if (typeof result?.checkMsg === 'string' && result.checkMsg) {
try { checkInfo = JSON.parse(result.checkMsg); } catch (_) { /* fixed fields below */ }
}
// 仅明确的 SDK 成功结果算成功;超时或未知状态不能由本程序自动重发。
// 不返回原始 checkMsg:它可能包含二次验证配置或其他敏感参数。
return JSON.stringify({action: 'send', success: result?.success === true,
status_code: result?.statusCode ?? null, check_code: String(result?.checkCode ?? ''),
check_message_present: Boolean(result?.checkMsg),
check_status_code: checkInfo?.status_code ?? null,
verification_required: Boolean(checkInfo?.decision_conf),
conversation: meta(conversation), message: pack(message)});
} catch (error) {
const known = ['WRONG_ORIGIN', 'LOGIN_CHECK_FAILED', 'LOGIN_REQUIRED', 'SELF_TARGET',
'IM_SDK_NOT_READY', 'CONVERSATION_NOT_FOUND', 'TARGET_MISMATCH', 'HISTORY_FAILED',
'MESSAGE_BUILD_FAILED'];
return JSON.stringify({error: known.includes(error.message) ? error.message : 'SDK_REQUEST_FAILED'});
}
})()
"""
def run_im(
action: str,
uid: str,
*,
text: str = "",
confirm: bool = False,
limit: int = 20,
cursor: str = "9223372036854775807",
) -> dict:
"""单次请求;历史不会标记已读,发送默认只预览。"""
validate_uid(uid)
if action not in ("history", "send"):
raise ValueError("action 必须为 history 或 send")
if not isinstance(cursor, str) or not cursor.isascii() or not cursor.isdecimal():
raise ValueError("cursor 必须是非负整数字符串")
normalized = cursor.lstrip("0") or "0"
if (
len(normalized) > 19
or (len(normalized) == 19 and normalized > "9223372036854775807")
or not 1 <= limit <= 100
):
raise ValueError("cursor 超出 int64 范围或 limit 不在 1..100")
if action == "send" and (not isinstance(text, str) or not text.strip()):
raise ValueError("消息不能为空")
params = {
"action": action,
"uid": uid,
"text": text,
"confirm": confirm,
"limit": limit,
"cursor": normalized,
}
expression = EXPRESSION.replace("__PARAMS__", json.dumps(params, ensure_ascii=True))
try:
result = evaluate_script(expression)
payload = json.loads(result.stdout.strip().splitlines()[-1])
except (subprocess.SubprocessError, OSError, ValueError, IndexError) as exc:
raise RuntimeError(
"浏览器调用失败:检查 CDP、已登录首页和 SDK;发送结果可能未知,勿自动重发"
) from exc
if not isinstance(payload, dict):
raise RuntimeError("浏览器返回格式异常;勿自动重发")
if payload.get("error"):
raise RuntimeError(
f"{payload['error']}:停止操作,请手动检查登录态/会话/SDK;勿自动重发"
)
return payload
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
sub = parser.add_subparsers(dest="action", required=True)
history = sub.add_parser("history", help="获取指定 UID 的一页历史消息")
history.add_argument("uid")
history.add_argument("--limit", type=int, default=20)
history.add_argument("--cursor", default="9223372036854775807")
send = sub.add_parser("send", help="发送文本,默认只预览")
send.add_argument("uid")
send.add_argument("--text", required=True)
send.add_argument("--confirm", action="store_true", help="确认实际发送一次")
add_cdp_args(history)
add_cdp_args(send)
args = parser.parse_args()
configure(args)
params = {
k: v
for k, v in vars(args).items()
if k not in ("cdp_url", "expected_uid", "page_url")
}
try:
result = run_im(**params)
print(json.dumps(result, ensure_ascii=False, indent=2))
return int(result.get("action") == "send" and not result.get("success"))
except (ValueError, RuntimeError) as exc:
print(f"失败:{exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())