This commit is contained in:
Your Name
2026-08-27 18:32:03 +08:00
parent 4ac6990efe
commit 1f3addcf79
50 changed files with 9145 additions and 1760 deletions
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -52,7 +52,19 @@ def build_im_session_from_storage(
session.keys_str = saved.keys_str
if saved.web_protect_str and not session.web_protect_str:
session.web_protect_str = saved.web_protect_str
if saved.my_uid and not session.my_uid:
# A UID verified from the account profile must win over collector
# guesses such as web_runtime_security_uid. Persisting this flag
# keeps API/manual-send builders on the same identity as hosting.
if saved.uid_verified and saved.my_uid:
session.my_uid = saved.my_uid
# device_id 必须与 my_uid 指向同一账号:protobuf/frontier 的
# device_id 优先取 session.device_id,凭证里残留的旧设备号
# (如 www 域 web_runtime_security_uid)会导致 device_id != my_uid
# -> 安全网关 decision=KICK。用已核验 UID 同步 device_id。
if str(session.device_id or "") != str(saved.my_uid):
session.device_id = str(saved.my_uid)
session.uid_verified = True
elif saved.my_uid and not session.my_uid:
session.my_uid = saved.my_uid
if saved.device_id and not session.device_id:
session.device_id = saved.device_id
+19 -4
View File
@@ -64,8 +64,15 @@ class DouyinAuth:
self.msToken = None
self.web_id = None
self.source_ip = ""
self.user_agent = None
def perepare_auth(self, cookieStr: str, web_protect_: str = "", keys_: str = ""):
def perepare_auth(
self,
cookieStr: str,
web_protect_: str = "",
keys_: str = "",
user_agent: str = "",
):
self.cookie = trans_cookies(cookieStr)
self.cookie_str = cookieStr
self.msToken = self.cookie["msToken"] if "msToken" in self.cookie else generate_msToken()
@@ -89,6 +96,11 @@ class DouyinAuth:
except Exception as e:
logger.debug(f"keys parse failed: {e}")
if user_agent:
# 让签名上下文记住调用方 UAquery_my_uid / generate_webid 等后续
# 请求会复用它,避免退回硬编码 DEFAULT_USER_AGENT 造成 UA 不一致。
self.user_agent = user_agent
def is_sign_ready(self) -> bool:
return bool(
self.private_key
@@ -109,9 +121,11 @@ class DouyinAuth:
session.cookie_header(),
session.web_protect_str,
session.keys_str,
user_agent=session.user_agent or DEFAULT_USER_AGENT,
)
auth.web_id = session.web_id or session.device_id or None
auth.user_agent = session.user_agent or DEFAULT_USER_AGENT
# device_id 是设备注册号(query/user 的 id),不是账号 UID
# my_uid 只作为最后兜底,由 resolve_proto_device_id 内部处理。
auth.device_id = resolve_proto_device_id(
session.device_id, session.web_id, session.my_uid
)
@@ -139,9 +153,10 @@ class DouyinAuth:
return self.uid
def query_my_uid(self) -> int:
ua = self.user_agent or DEFAULT_USER_AGENT
url = 'https://www.douyin.com/aweme/v1/web/query/user/'
headers = {
"User-Agent": DEFAULT_USER_AGENT,
"User-Agent": ua,
"Referer": "https://www.douyin.com/",
"Accept": "application/json, text/plain, */*",
}
@@ -156,7 +171,7 @@ class DouyinAuth:
"msToken": self.msToken
}
query = splice_url(params)
abogus = generate_a_bogus(query, user_agent=DEFAULT_USER_AGENT)
abogus = generate_a_bogus(query, user_agent=ua)
params['a_bogus'] = abogus
with source_bound_requests_session(self.source_ip) as client:
+9 -2
View File
@@ -123,17 +123,24 @@ def generate_fake_webid(random_length=19):
return random_str
def generate_webid(auth=None, url=""):
def generate_webid(auth=None, url="", user_agent=""):
# 优先用已采集到的 web_id(避免每次发送都发起一次阻塞的 HTTP 请求,导致事件循环卡顿)
cached = getattr(auth, "web_id", None) if auth is not None else None
if cached:
return str(cached)
if url == "":
url = "https://www.douyin.com/discover?modal_id=7376449060384935209"
# UA 优先级:显式参数 > auth.user_agentfrom_im_session / perepare_auth 已带)> 全局默认。
# 必须与 a_bogus 签名、其余请求头使用同一个 UA,否则服务端重算失配 -> 7911。
ua = (
user_agent
or (getattr(auth, "user_agent", "") if auth is not None else "")
or DEFAULT_USER_AGENT
)
try:
from .auth import DouyinAuth
headers = {
"User-Agent": DEFAULT_USER_AGENT,
"User-Agent": ua,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"upgrade-insecure-requests": "1"
+188 -183
View File
@@ -1,183 +1,188 @@
"""抖音网页版「粉丝列表」拉取,用于检测新粉丝(关注欢迎语功能)。
复用与 peer_profile / account_profile 相同的 a_bogus + msToken + cookie 签名方式,
调用 https://www.douyin.com/aweme/v1/web/user/follower/list/ 拉取本账号最近的粉丝。
返回的每个粉丝含:uid / sec_uid / nickname / follow_status / follower_status。
其中 follow_status 表示「我」与对方的关系:0=未关注 1=我已关注 2=互相关注(互关)。
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from .dy_util import (
DEFAULT_USER_AGENT,
generate_a_bogus,
generate_msToken,
generate_webid,
splice_url,
)
from .auth import DouyinAuth
logger = logging.getLogger("douyin_im.follower_poll")
FOLLOWER_LIST_URL = "https://www.douyin.com/aweme/v1/web/user/follower/list/"
def _requests_proxies() -> dict | None:
try:
from rpa_engine.runtime_config import requests_proxies
return requests_proxies()
except Exception:
return None
def _to_int(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def _extract_followers(data: dict[str, Any]) -> list[dict[str, Any]]:
raw = data.get("followers")
if not isinstance(raw, list):
return []
out: list[dict[str, Any]] = []
for item in raw:
if not isinstance(item, dict):
continue
uid = str(item.get("uid") or item.get("user_id") or "").strip()
if not uid:
continue
out.append(
{
"uid": uid,
"sec_uid": str(item.get("sec_uid") or item.get("sec_user_id") or "").strip(),
"nickname": str(item.get("nickname") or item.get("nick_name") or "").strip(),
# follow_status:我对对方的关系(2=互关);follower_status:对方对我的关系
"follow_status": _to_int(item.get("follow_status")),
"follower_status": _to_int(item.get("follower_status")),
}
)
return out
def fetch_recent_followers_sync(
session,
sec_user_id: str,
count: int = 20,
max_time: int = 0,
) -> list[dict[str, Any]]:
"""同步拉取最近粉丝(第一页)。失败返回 [],并在日志里写明原因。"""
import requests
sec_user_id = (sec_user_id or "").strip()
if not sec_user_id:
logger.warning("fetch followers skipped: 缺少本账号 sec_user_id")
return []
try:
auth = DouyinAuth()
auth.perepare_auth(session.cookie_header(), session.web_protect_str, session.keys_str)
except Exception as exc:
logger.warning("fetch followers: build auth failed: %s", exc)
return []
ua = session.user_agent or DEFAULT_USER_AGENT
params = {
"device_platform": "webapp",
"aid": "6383",
"channel": "channel_pc_web",
"sec_user_id": sec_user_id,
"count": str(count),
"max_time": str(max_time),
"min_time": "0",
"offset": "0",
"source_type": "1",
"gps_access": "0",
"address_book_access": "0",
"is_top": "1",
"update_version_code": "170400",
"pc_client_type": "1",
"version_code": "170400",
"version_name": "17.4.0",
"cookie_enabled": "true",
"screen_width": "1536",
"screen_height": "960",
"browser_language": "zh-CN",
"browser_platform": "Win32",
"browser_name": "Chrome",
"browser_version": "120.0.0.0",
"browser_online": "true",
"os_name": "Windows",
"os_version": "10",
"platform": "PC",
"webid": generate_webid(auth, "https://www.douyin.com/"),
"verifyFp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "",
"fp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "",
"msToken": auth.msToken or generate_msToken(),
}
query = splice_url(params)
params["a_bogus"] = generate_a_bogus(query, user_agent=ua)
headers = {
"User-Agent": ua,
"Referer": "https://www.douyin.com/",
"Accept": "application/json, text/plain, */*",
}
try:
resp = requests.get(
FOLLOWER_LIST_URL,
params=params,
headers=headers,
cookies=auth.cookie,
timeout=15,
verify=False,
proxies=_requests_proxies(),
)
try:
data = resp.json()
except Exception:
snippet = (resp.text or "")[:200].replace("\n", " ")
logger.warning(
"fetch followers: 非 JSON 响应 (HTTP %s): %s", resp.status_code, snippet
)
return []
if not isinstance(data, dict):
logger.warning("fetch followers: 响应不是 JSON 对象")
return []
status_code = data.get("status_code")
if status_code not in (None, 0):
logger.warning(
"fetch followers: status_code=%s msg=%s",
status_code,
data.get("status_msg") or data.get("message") or "",
)
return []
followers = _extract_followers(data)
logger.info(
"fetch followers ok: 拿到 %s 个粉丝 (has_more=%s total=%s)",
len(followers),
data.get("has_more"),
data.get("total"),
)
return followers
except Exception as exc:
logger.warning("fetch followers failed: %s", exc)
return []
async def fetch_recent_followers(
session,
sec_user_id: str,
count: int = 20,
max_time: int = 0,
) -> list[dict[str, Any]]:
return await asyncio.to_thread(
fetch_recent_followers_sync, session, sec_user_id, count, max_time
)
"""抖音网页版「粉丝列表」拉取,用于检测新粉丝(关注欢迎语功能)。
复用与 peer_profile / account_profile 相同的 a_bogus + msToken + cookie 签名方式,
调用 https://www.douyin.com/aweme/v1/web/user/follower/list/ 拉取本账号最近的粉丝。
返回的每个粉丝含:uid / sec_uid / nickname / follow_status / follower_status。
其中 follow_status 表示「我」与对方的关系:0=未关注 1=我已关注 2=互相关注(互关)。
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from .dy_util import (
DEFAULT_USER_AGENT,
generate_a_bogus,
generate_msToken,
generate_webid,
splice_url,
)
from .auth import DouyinAuth
logger = logging.getLogger("douyin_im.follower_poll")
FOLLOWER_LIST_URL = "https://www.douyin.com/aweme/v1/web/user/follower/list/"
def _requests_proxies() -> dict | None:
try:
from rpa_engine.runtime_config import requests_proxies
return requests_proxies()
except Exception:
return None
def _to_int(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def _extract_followers(data: dict[str, Any]) -> list[dict[str, Any]]:
raw = data.get("followers")
if not isinstance(raw, list):
return []
out: list[dict[str, Any]] = []
for item in raw:
if not isinstance(item, dict):
continue
uid = str(item.get("uid") or item.get("user_id") or "").strip()
if not uid:
continue
out.append(
{
"uid": uid,
"sec_uid": str(item.get("sec_uid") or item.get("sec_user_id") or "").strip(),
"nickname": str(item.get("nickname") or item.get("nick_name") or "").strip(),
# follow_status:我对对方的关系(2=互关);follower_status:对方对我的关系
"follow_status": _to_int(item.get("follow_status")),
"follower_status": _to_int(item.get("follower_status")),
}
)
return out
def fetch_recent_followers_sync(
session,
sec_user_id: str,
count: int = 20,
max_time: int = 0,
) -> list[dict[str, Any]]:
"""同步拉取最近粉丝(第一页)。失败返回 [],并在日志里写明原因。"""
import requests
sec_user_id = (sec_user_id or "").strip()
if not sec_user_id:
logger.warning("fetch followers skipped: 缺少本账号 sec_user_id")
return []
try:
auth = DouyinAuth()
auth.perepare_auth(
session.cookie_header(),
session.web_protect_str,
session.keys_str,
user_agent=session.user_agent or DEFAULT_USER_AGENT,
)
except Exception as exc:
logger.warning("fetch followers: build auth failed: %s", exc)
return []
ua = session.user_agent or DEFAULT_USER_AGENT
params = {
"device_platform": "webapp",
"aid": "6383",
"channel": "channel_pc_web",
"sec_user_id": sec_user_id,
"count": str(count),
"max_time": str(max_time),
"min_time": "0",
"offset": "0",
"source_type": "1",
"gps_access": "0",
"address_book_access": "0",
"is_top": "1",
"update_version_code": "170400",
"pc_client_type": "1",
"version_code": "170400",
"version_name": "17.4.0",
"cookie_enabled": "true",
"screen_width": "1536",
"screen_height": "960",
"browser_language": "zh-CN",
"browser_platform": "Win32",
"browser_name": "Chrome",
"browser_version": "120.0.0.0",
"browser_online": "true",
"os_name": "Windows",
"os_version": "10",
"platform": "PC",
"webid": generate_webid(auth, "https://www.douyin.com/"),
"verifyFp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "",
"fp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "",
"msToken": auth.msToken or generate_msToken(),
}
query = splice_url(params)
params["a_bogus"] = generate_a_bogus(query, user_agent=ua)
headers = {
"User-Agent": ua,
"Referer": "https://www.douyin.com/",
"Accept": "application/json, text/plain, */*",
}
try:
resp = requests.get(
FOLLOWER_LIST_URL,
params=params,
headers=headers,
cookies=auth.cookie,
timeout=15,
verify=False,
proxies=_requests_proxies(),
)
try:
data = resp.json()
except Exception:
snippet = (resp.text or "")[:200].replace("\n", " ")
logger.warning(
"fetch followers: 非 JSON 响应 (HTTP %s): %s", resp.status_code, snippet
)
return []
if not isinstance(data, dict):
logger.warning("fetch followers: 响应不是 JSON 对象")
return []
status_code = data.get("status_code")
if status_code not in (None, 0):
logger.warning(
"fetch followers: status_code=%s msg=%s",
status_code,
data.get("status_msg") or data.get("message") or "",
)
return []
followers = _extract_followers(data)
logger.info(
"fetch followers ok: 拿到 %s 个粉丝 (has_more=%s total=%s)",
len(followers),
data.get("has_more"),
data.get("total"),
)
return followers
except Exception as exc:
logger.warning("fetch followers failed: %s", exc)
return []
async def fetch_recent_followers(
session,
sec_user_id: str,
count: int = 20,
max_time: int = 0,
) -> list[dict[str, Any]]:
return await asyncio.to_thread(
fetch_recent_followers_sync, session, sec_user_id, count, max_time
)
+21 -1
View File
@@ -8,7 +8,7 @@ from urllib.parse import unquote
import requests
from .auth import DouyinAuth
from .dy_util import generate_a_bogus, generate_msToken, generate_webid, splice_url
from .dy_util import DEFAULT_USER_AGENT, generate_a_bogus, generate_msToken, generate_webid, splice_url
from .session import DouyinImSession, is_frontier_ws_url
logger = logging.getLogger("douyin_im.frontier")
@@ -42,6 +42,7 @@ def fetch_device_id(session: DouyinImSession) -> str:
session.cookie_header(),
session.web_protect_str,
session.keys_str,
user_agent=session.user_agent or DEFAULT_USER_AGENT,
)
url = "https://www.douyin.com/aweme/v1/web/query/user"
headers = {
@@ -134,12 +135,31 @@ def ensure_frontier_ws(session: DouyinImSession) -> Optional[str]:
logger.info("Using captured real frontier WS URL (with sdk_cert)")
return url
# Only fpid=9 is Douyin private messaging. Other Frontier products (for
# example fpid=971 opened by the generic /chat page shell) also handshake
# successfully but never carry this account's IM push stream.
for url in session.ws_urls:
if (
is_frontier_ws_url(url)
and "zijieapi.com" in url
and "access_key=" in url
and re.search(r"[?&]fpid=9(?:&|$)", url)
and _ws_device_matches_session(session, url)
):
session.ws_urls = [url]
logger.info("Using captured browser frontier WebSocket URL")
return url
for url in session.ws_urls:
if is_frontier_ws_url(url) and _ws_token_looks_encoded(url):
session.ws_urls = [url]
logger.info("Using captured frontier WS URL")
return url
# frontier 按 device_id 寻址推送,它不是账号 UID:抖音 query/user 返回的
# id 才是本浏览器的设备注册号(my_uid 走 DouyinAuth,两者不能互换)。
# 用 my_uid 拼出来的地址握手同样成功,但订阅的是另一个地址,
# 于是长连接一直是「连上但收不到任何私信」。
device_id = resolve_frontier_device_id(session)
if not device_id:
session.ws_urls = []
+432 -132
View File
@@ -1,6 +1,8 @@
import asyncio
import json
import logging
import re
import time
from typing import Any, Optional
from urllib.parse import urlparse
@@ -13,8 +15,9 @@ from rpa_engine.egress_channels import (
resolve_send_channels,
)
from .conv_util import build_conversation_id, normalize_conversation_id, resolve_peer_uid
from .message_content import format_im_message, serialize_message_content
from .peer_profile import enrich_conversation_item, fetch_peer_profile, is_generic_peer_name
from .protocol import normalize_im_payload, normalize_im_payload_from_bytes, _pick_avatar_url
from .protocol import normalize_im_payload_from_bytes, _pick_avatar_url
from .session import DouyinImSession
logger = logging.getLogger("douyin_im.http")
@@ -23,6 +26,27 @@ IMAPI_BASE = "https://imapi.douyin.com"
# 抖音 IM「按会话拉取消息」cmd(与电商/web 一致);body 字段号 == cmd。
CMD_GET_MESSAGES_BY_CONVERSATION = 301
# 「按用户拉取收件箱」cmd:抖音网页版打开私信时用它一次性同步各会话最新消息。
CMD_GET_MESSAGES_BY_USER_INIT = 200
# MessageBody 的字段号(与 Response.proto 的 MessageBody 一致)。
_MSG_FIELD_CONVERSATION_ID = 1
_MSG_FIELD_SERVER_MESSAGE_ID = 3
_MSG_FIELD_CONVERSATION_SHORT_ID = 5
_MSG_FIELD_MESSAGE_TYPE = 6
_MSG_FIELD_SENDER = 7
_MSG_FIELD_CONTENT = 8
_CONVERSATION_ID_RE = re.compile(r"^0:\d+:\d+:\d+$")
# 托管轮询只需覆盖长连接断开的时间窗;游标是微秒时间戳,窗口越小响应越小
# (实测同一账号:游标 0 -> 113KB,回看 30 分钟 -> 约 2KB)。
INBOX_POLL_LOOKBACK_SECONDS = 1800.0
# 会话列表被抖音拒绝时的系统日志节流:托管期间每个账号最多每 30 分钟记一次,
# 既保证「收不到私信」有据可查,又不会被每轮轮询刷屏。
_CONVERSATION_REJECT_LOG_INTERVAL = 1800.0
_conversation_reject_logged_at: dict[tuple[int, str], float] = {}
def _pb_varint(n: int) -> bytes:
@@ -94,6 +118,173 @@ def _pb_parse_fields(buf: bytes) -> list[tuple[int, int, Any]]:
return out
def _as_int(value: Any) -> int:
try:
return int(str(value or 0))
except (TypeError, ValueError):
return 0
def _pb_message_body(buf: bytes) -> Optional[dict]:
"""把一段字节按 MessageBody 解析;形状不像就返回 None。
判据是「有合法的 conversation_id + server_message_id」,而不是它出现在
哪个字段号上——收件箱响应里 messages 挂在哪一层随接口而变。
"""
msg: dict = {}
try:
fields = _pb_parse_fields(buf)
except Exception:
return None
for fn, wt, val in fields:
if fn == _MSG_FIELD_CONVERSATION_ID and wt == 2:
try:
conv_id = val.decode("utf-8")
except Exception:
return None
if not _CONVERSATION_ID_RE.match(conv_id):
return None
msg["conversation_id"] = conv_id
elif fn == _MSG_FIELD_SERVER_MESSAGE_ID and wt == 0:
msg["server_message_id"] = str(val)
elif fn == _MSG_FIELD_CONVERSATION_SHORT_ID and wt == 0:
msg["conversation_short_id"] = str(val)
elif fn == _MSG_FIELD_MESSAGE_TYPE and wt == 0:
msg["message_type"] = val
elif fn == _MSG_FIELD_SENDER and wt == 0:
msg["sender"] = str(val)
elif fn == _MSG_FIELD_CONTENT and wt == 2:
msg["content"] = val.decode("utf-8", errors="replace")
if msg.get("conversation_id") and msg.get("server_message_id"):
return msg
return None
def _pb_collect_message_bodies(
buf: bytes,
out: list[dict],
depth: int = 0,
) -> None:
"""递归找出响应体里所有 MessageBody。"""
if depth > 6:
return
parsed = _pb_message_body(buf)
if parsed is not None:
out.append(parsed)
return
try:
fields = _pb_parse_fields(buf)
except Exception:
return
for _fn, wt, val in fields:
if wt == 2 and isinstance(val, bytes) and val:
_pb_collect_message_bodies(val, out, depth + 1)
def _pb_parse_inbox_messages(content: bytes, cmd: int) -> list[dict]:
"""从 get_by_user_init 响应里取出各会话的最新消息。"""
out: list[dict] = []
for fn, wt, val in _pb_parse_fields(content):
if fn != 6 or wt != 2: # Response.body
continue
for bfn, bwt, bval in _pb_parse_fields(val):
if bfn != cmd or bwt != 2: # ResponseBody.<cmd>
continue
_pb_collect_message_bodies(bval, out)
return out
def _is_inbox_control_message(msg: dict) -> bool:
"""收件箱里的会话控制/状态帧(不是用户发的消息)。"""
from .protocol import _is_control_payload
try:
message_type = int(msg.get("message_type") or 0)
except (TypeError, ValueError):
message_type = 0
content_json: Any = None
raw = msg.get("content")
if raw:
try:
content_json = json.loads(raw)
except Exception:
content_json = None
return _is_control_payload(content_json, message_type)
def _pb_parse_inbox_conversations(content: bytes, cmd: int) -> list[dict]:
"""取出 cmd 200 响应里的会话条目。
响应体除了 messages(字段 1) 还带一组会话条目(字段 6):
f1=conversation_short_id(varint) f4=conversation_id(string)
游标为 0 时这组条目就是账号的完整会话列表,所以「列全部会话」不必再去拉
cmd 203 的 1.5MB 全量快照。
"""
out: list[dict] = []
for fn, wt, val in _pb_parse_fields(content):
if fn != 6 or wt != 2: # Response.body
continue
for bfn, bwt, bval in _pb_parse_fields(val):
if bfn != cmd or bwt != 2:
continue
for cfn, cwt, cval in _pb_parse_fields(bval):
if cfn != 6 or cwt != 2: # repeated conversation entry
continue
short_id = ""
conv_id = ""
for efn, ewt, eval_ in _pb_parse_fields(cval):
if efn == 1 and ewt == 0:
short_id = str(eval_)
elif efn == 4 and ewt == 2:
try:
candidate = eval_.decode("utf-8")
except Exception:
continue
if _CONVERSATION_ID_RE.match(candidate):
conv_id = candidate
if conv_id:
out.append(
{
"conversation_id": conv_id,
"conversation_short_id": short_id,
}
)
return out
def _pb_parse_inbox_page(content: bytes, cmd: int) -> tuple[int, bool]:
"""返回收件箱这一页的 (next_cursor, has_more)。"""
next_cursor = 0
has_more = False
for fn, wt, val in _pb_parse_fields(content):
if fn != 6 or wt != 2:
continue
for bfn, bwt, bval in _pb_parse_fields(val):
if bfn != cmd or bwt != 2:
continue
for cfn, cwt, cval in _pb_parse_fields(bval):
if cfn == 2 and cwt == 0:
next_cursor = int(cval)
elif cfn == 3 and cwt == 0:
has_more = bool(cval)
return next_cursor, has_more
def _pb_response_status(content: bytes) -> tuple[Optional[int], str]:
"""返回 IM protobuf 响应的 (status_code, message)。"""
status: Optional[int] = None
message = ""
try:
for fn, wt, val in _pb_parse_fields(content):
if fn == 3 and wt == 0:
status = int(val)
elif fn == 4 and wt == 2:
message = val.decode("utf-8", errors="replace")
except Exception:
return None, ""
return status, message
def _pb_parse_conversation_messages(content: bytes, cmd: int) -> list[dict]:
"""解析 get_by_conversation 的 protobuf 响应,返回消息列表。"""
out: list[dict] = []
@@ -350,6 +541,13 @@ class DouyinImHttpClient:
# another channel. Ambiguous read timeouts stay false to avoid duplicates.
self.last_send_channel_retryable: bool = False
self.last_request_debug: str = ""
# 会话列表被抖音判定为「请求本身不合法」时置位。换 payload、换 cookie
# 都修不好,上层据此停掉这轮轮询,别每 120 秒白打一次请求。
self.conversation_list_unsupported: bool = False
# 最近一次收件箱响应里的会话条目(只覆盖翻到的那些页)
self._last_inbox_conversations: list[dict] = []
# 翻页预算用尽但抖音还说 has_more:这次拿到的会话列表不完整
self.inbox_truncated: bool = False
self._proxy_url: str = ""
self._source_ip_override = str(source_ip or "").strip()
self._egress_public_ip_override = str(egress_public_ip or "").strip()
@@ -448,16 +646,43 @@ class DouyinImHttpClient:
def _set_error(self, msg: str) -> None:
self.last_error = msg or ""
def _resolve_authoritative_uid(self, auth) -> int:
"""用 query/user 接口核验当前账号真实 UID,并回写 session.my_uid。
def _report_conversation_list_rejected(self, reason: str) -> None:
"""把「会话列表被抖音拒绝」变成可见故障,而不是静默的空收件箱。"""
self._set_error(f"会话列表接口被抖音拒绝:{reason}")
self.conversation_list_unsupported = True
logger.warning(
"Conversation list rejected by IM API (account=%s): %s",
self.account_id,
reason,
)
key = (int(self.account_id or 0), reason)
now = time.monotonic()
last_at = _conversation_reject_logged_at.get(key)
if last_at is not None and now - last_at < _CONVERSATION_REJECT_LOG_INTERVAL:
return
_conversation_reject_logged_at[key] = now
system_logger.record(
"会话列表接口被抖音拒绝,已停用轮询兜底",
detail=(
f"抖音返回:{reason}。该请求本身被判定为不合法,重试也修不好,"
"本轮托管不再重复调用。私信改为完全依赖实时长连接接收;"
"长连接断开期间漏收的消息无法再通过轮询补齐。"
),
level="error",
category="poll",
account_id=self.account_id,
)
采集端从 tea_cache 推断的 my_uid 可能取到访客/对方 id(导致 cmd=609
INVALID_REQUEST、会话列表为 0)。query/user 返回的 user_uid 才是权威值。
核验成功后写回 session 并打标,避免每次发送都请求接口。
def _resolve_authoritative_uid(self, auth) -> int:
"""Resolve a usable UID without overwriting a known IM identity.
Douyin's query/user ``user_uid`` can differ from the UID used by IM.
It is therefore only a last-resort value when the session has no UID;
a profile-verified or collected numeric UID always wins.
"""
sess = self.session
auth.source_ip = self._source_ip
if getattr(sess, "uid_verified", False) and sess.my_uid:
if sess.my_uid:
return int(sess.my_uid)
resolved = None
try:
@@ -466,16 +691,10 @@ class DouyinImHttpClient:
logger.warning(f"query/user 解析 my_uid 失败: {e}")
if resolved and str(resolved).isdigit():
resolved = int(resolved)
old = int(sess.my_uid or 0)
if old and old != resolved:
logger.warning("my_uid 修正(query/user):采集值 %s -> 权威值 %s", old, resolved)
sess.my_uid = resolved
# 本系统里 device_id 等同账号 uid(采集端常与 my_uid 一起取错,导致会话列表为 0)。
# device_id 为空 / 非数字 / 等于旧的错误 my_uid 时,一并修正为权威 uid。
dev = str(sess.device_id or "")
if (not dev.isdigit()) or (old and dev == str(old)):
if not dev.isdigit():
sess.device_id = str(resolved)
sess.uid_verified = True
return resolved
return int(sess.my_uid or 0)
@@ -734,7 +953,9 @@ class DouyinImHttpClient:
cmd = CMD_GET_MESSAGES_BY_CONVERSATION
try:
request = await asyncio.to_thread(ProtoBuilder.build_normal_request, auth, cmd)
# 同样要用 x_tt_token:带 auth.ticket 时抖音回 OK 但正文恒为空,
# 于是 WS 瘦推送的图片/语音一直补不全真实 content。
request = await asyncio.to_thread(ProtoBuilder.build_read_request, auth, cmd)
conv_req = (
_pb_str(1, conversation_id)
+ _pb_int(2, 1)
@@ -815,83 +1036,207 @@ class DouyinImHttpClient:
pass
return total
async def get_conversations(self, *, enrich_profiles: bool = True) -> list[dict]:
"""拉取会话列表,返回标准化会话"""
payloads = [
{"cursor": 0, "count": 50, "inbox_type": 0},
{"cursor": 0, "limit": 50},
{},
]
conversations = []
seen = set()
async def fetch_inbox_messages(
self,
limit: int = 50,
lookback_seconds: float = INBOX_POLL_LOOKBACK_SECONDS,
max_pages: int = 1,
) -> list[dict]:
"""用 protobuf 拉取收件箱消息,按游标翻页。
for body in payloads:
data = await self._request("POST", "/v1/conversation/list", body)
# Only a transport/parse failure warrants trying GET. An empty
# JSON object/list can be a perfectly valid empty inbox response.
if data is None:
data = await self._request("GET", "/v1/conversation/list", body)
if data is None:
# Payload variants only help with schema compatibility. They
# cannot repair a network outage, so stop after POST + GET
# both fail instead of occupying a scarce global slot for up
# to four more full request timeouts.
logger.warning("Conversation poll transport failed; skipping payload fallbacks")
break
imapi.douyin.com 只接受 protobuf:发 JSON body 会被当成 protobuf 解析,
固定返回 status_code=1 "unexepcted session length"(与 Cookie 无关,
实测不带任何 Cookie 也是同一条错误)。这里用与发送/拉消息同一套 Request
信封,抖音网页版打开私信时用的也是这个 cmd。
status_code = data.get("status_code") if isinstance(data, dict) else None
error_text = ""
if isinstance(data, dict):
error_text = str(
data.get("error_desc")
or data.get("message")
or data.get("error")
or ""
).strip().lower()
explicit_success = status_code in (0, "0")
structured_without_status = (
isinstance(data, (dict, list)) and status_code is None
响应是**分页**的:body 的 f2=next_cursor、f3=has_more,随附的会话条目
只覆盖这一页里出现过的会话。实测 cursor=0 返回 9 个会话且 has_more=1
翻 6 页后累计 35 个且仍未翻完——所以「一次请求 = 完整会话列表」是错的,
翻不完时必须把 inbox_truncated 置位,别把一页伪装成全部。
"""
from .auth import DouyinAuth
from .proto_builder import ProtoBuilder
cmd = CMD_GET_MESSAGES_BY_USER_INIT
auth = DouyinAuth.from_im_session(self.session)
auth.source_ip = self._source_ip
# 字段 1 是游标(微秒时间戳)。托管轮询只回看一个窗口:这条链路只用来
# 补齐长连接断开期间漏收的消息。lookback_seconds<=0 表示不设游标
# (游标 0 = 从头翻),别算成「now」,那等于只要比此刻更新的消息。
if lookback_seconds <= 0:
cursor = 0
else:
cursor = max(0, int((time.time() - lookback_seconds) * 1_000_000))
messages: list[dict] = []
conversations: list[dict] = []
seen_conversations: set[str] = set()
self.inbox_truncated = False
pages = max(1, int(max_pages))
for page in range(pages):
request = await asyncio.to_thread(
ProtoBuilder.build_read_request, auth, cmd
)
terminal_credential_error = (
status_code not in (None, 0, "0")
and any(
marker in error_text
for marker in (
"empty token",
"invalid token",
"token expired",
"credential expired",
"authentication",
"unauthorized",
"not login",
"not logged",
)
body = _pb_int(1, cursor) + _pb_int(2, int(limit))
payload = request.SerializeToString() + _pb_msg(8, _pb_msg(cmd, body))
resp = await self._post_protobuf(
f"{IMAPI_BASE}/v1/message/get_by_user_init",
auth,
payload,
signed=False,
log_label="inbox",
)
resp.raise_for_status()
status_code, message = _pb_response_status(resp.content)
if status_code is not None and status_code != 0:
self._report_conversation_list_rejected(
message or f"status_code={status_code}"
)
)
return []
if page == 0:
self._adopt_authoritative_uid(resp.content)
messages.extend(_pb_parse_inbox_messages(resp.content, cmd))
for entry in _pb_parse_inbox_conversations(resp.content, cmd):
conv_id = str(entry.get("conversation_id") or "")
if conv_id and conv_id not in seen_conversations:
seen_conversations.add(conv_id)
conversations.append(entry)
normalized = normalize_im_payload(data)
for item in normalized:
name = item.get("sender_name") or ""
key = name or item.get("conversation_id") or ""
if key and key not in seen:
seen.add(key)
conversations.append(item)
# 也从原始结构提取会话级 unread
self._extract_conversation_rows(data, conversations, seen)
# Compatibility payloads are alternatives, not pagination. Stop
# after a successful empty response as well as a non-empty one;
# otherwise every idle account issues three identical endpoint
# calls on every poll. Credential errors cannot be repaired by
# changing only the JSON shape, so do not amplify those either.
if (
conversations
or explicit_success
or structured_without_status
or terminal_credential_error
):
next_cursor, has_more = _pb_parse_inbox_page(resp.content, cmd)
if not has_more:
break
# 游标不前进就停:否则同一页会被无限翻下去。
if not next_cursor or next_cursor == cursor:
break
cursor = next_cursor
if page == pages - 1:
self.inbox_truncated = True
logger.info(
"Inbox paging stopped at the %d-page budget for account %s; "
"%d conversations so far, more remain",
pages,
self.account_id,
len(conversations),
)
self._last_inbox_conversations = conversations
return messages
def _adopt_authoritative_uid(self, content: bytes) -> None:
"""响应字段 13 是抖音认定的本账号 IM uid,用它纠正 session.my_uid。
my_uid 取错时 _is_self_message 拦不住自己发的消息(机器人会自问自答),
resolve_peer_uid 也会把会话对端认成自己。
"""
uid = 0
try:
for fn, wt, val in _pb_parse_fields(content):
if fn == 13 and wt == 0:
uid = int(val)
break
except Exception:
return
if not uid or uid == int(self.session.my_uid or 0):
return
logger.warning(
"Account %s IM uid corrected from %s to %s (imapi response field 13)",
self.account_id,
self.session.my_uid,
uid,
)
self.session.my_uid = uid
self.session.uid_verified = True
async def get_conversations(
self,
*,
enrich_profiles: bool = True,
lookback_seconds: float = INBOX_POLL_LOOKBACK_SECONDS,
max_pages: int = 1,
) -> list[dict]:
"""拉取会话列表,返回标准化会话。
lookback_seconds=0 表示从头翻;max_pages 是翻页预算。抖音不提供「一次
取回全部会话」的接口,翻页预算用完时 inbox_truncated 会被置位——调用方
必须知道自己拿到的可能只是一部分,不能把一页当成完整会话列表。
托管轮询用默认的小窗口 + 单页,只为补齐长连接断开期间漏收的消息。
"""
conversations: list[dict] = []
try:
messages = await self.fetch_inbox_messages(
lookback_seconds=lookback_seconds,
max_pages=max_pages,
)
except Exception as exc:
self._set_error(str(exc))
logger.warning("Conversation poll transport failed: %s", exc)
return []
# 一个会话只保留最新的一条:server_message_id 单调递增。
latest: dict[str, dict] = {}
for msg in messages:
conv_id = str(msg.get("conversation_id") or "")
if not conv_id:
continue
# 会话状态/已读位等控制帧不是用户消息:既不该当成会话预览,
# 更不该被 _handle_incoming 拿去匹配自动回复(WS 侧同样过滤)。
if _is_inbox_control_message(msg):
continue
current = latest.get(conv_id)
if current is None or _as_int(msg.get("server_message_id")) > _as_int(
current.get("server_message_id")
):
latest[conv_id] = msg
# 窗口内没有消息、但账号里确实存在的会话也要出现在列表里,
# 否则「会话列表」会退化成「最近有动静的会话」。
for entry in self._last_inbox_conversations:
conv_id = str(entry.get("conversation_id") or "")
short_id = str(entry.get("conversation_short_id") or "")
if short_id and short_id != "0":
self.session.conv_meta.setdefault(conv_id, {})
self.session.conv_meta[conv_id]["conversation_short_id"] = short_id
if conv_id and conv_id not in latest:
latest[conv_id] = {
"conversation_id": conv_id,
"conversation_short_id": short_id,
"server_message_id": "0",
"content": "",
}
for conv_id, msg in latest.items():
content = str(msg.get("content") or "")
try:
message_type = int(msg.get("message_type") or 0)
except (TypeError, ValueError):
message_type = 0
preview = content
try:
parsed = format_im_message(json.loads(content), message_type)
preview = serialize_message_content(parsed) if parsed else content
except Exception:
pass
short_id = str(msg.get("conversation_short_id") or "")
if short_id and short_id != "0":
# 发送私信需要 short_id;从收件箱顺手补上,省掉一次 create。
self.session.conv_meta.setdefault(conv_id, {})
self.session.conv_meta[conv_id]["conversation_short_id"] = short_id
conversations.append(
{
"conversation_id": conv_id,
"conversation_short_id": short_id,
"sender_name": "",
"sender_avatar": None,
"content": preview,
"raw_content": content,
"message_type": message_type,
"server_message_id": str(msg.get("server_message_id") or ""),
# sender 可能是自己(我方发出的最后一条),不能当 peer;
# 交给 enrich_conversation_item 从 conversation_id 推。
"sender_uid": str(msg.get("sender") or ""),
"unread_count": 0,
}
)
my_uid = int(self.session.my_uid or 0)
enriched: list[dict] = []
@@ -917,51 +1262,6 @@ class DouyinImHttpClient:
logger.info(f"Fetched {len(enriched)} conversations from IM API")
return enriched
def _extract_conversation_rows(self, data: Any, out: list, seen: set, depth: int = 0):
if depth > 10:
return
if isinstance(data, dict):
name = (
data.get("nick_name")
or data.get("nickname")
or (
(data.get("core_info") or {}).get("nick_name")
if isinstance(data.get("core_info"), dict)
else None
)
)
unread = data.get("unread_count") or data.get("unreadCount") or 0
conv_id = data.get("conversation_id") or data.get("conversationId") or ""
preview = ""
last = data.get("last_message") or data.get("latest_message")
if isinstance(last, dict):
preview = last.get("content") or last.get("text") or ""
elif isinstance(last, str):
preview = last
if isinstance(name, str) and name.strip():
name = name.strip()
sender_avatar = _pick_avatar_url(data)
if name not in seen:
try:
unread = int(unread or 0)
except (TypeError, ValueError):
unread = 0
out.append({
"sender_name": name,
"sender_avatar": sender_avatar or None,
"content": str(preview or ""),
"conversation_id": str(conv_id or ""),
"unread_count": unread,
})
seen.add(name)
for v in data.values():
self._extract_conversation_rows(v, out, seen, depth + 1)
elif isinstance(data, list):
for item in data:
self._extract_conversation_rows(item, out, seen, depth + 1)
async def send_text_message(
self,
conversation_id: str,
@@ -1213,7 +1513,7 @@ class DouyinImHttpClient:
self.last_send_channel_retryable = True
detail = (
"抖音安全网关返回 decision=KICK,当前登录/安全会话已被服务端踢下线;"
"请停止托管后用浏览器模式重新登录,并打开一次私信页重新采集凭证"
"系统正在自动重登录,请留意账号卡片上的二维码并扫码"
)
elif decision:
detail = f"抖音安全网关拒绝发送 decision={decision}"
+6 -1
View File
@@ -283,8 +283,13 @@ def _fetch_im_upload_sts(session, source_ip: str = "") -> tuple[str, str, str, s
)
auth = DouyinAuth()
auth.perepare_auth(session.cookie_header(), session.web_protect_str, session.keys_str)
ua = session.user_agent or DEFAULT_USER_AGENT
auth.perepare_auth(
session.cookie_header(),
session.web_protect_str,
session.keys_str,
user_agent=ua,
)
params = {
"device_platform": "webapp",
+7 -2
View File
@@ -73,9 +73,14 @@ def _requests_proxies() -> dict | None:
def _build_auth(session: DouyinImSession) -> tuple[DouyinAuth, str]:
auth = DouyinAuth()
auth.perepare_auth(session.cookie_header(), session.web_protect_str, session.keys_str)
ua = resolve_user_agent(session.user_agent or DEFAULT_USER_AGENT)
auth = DouyinAuth()
auth.perepare_auth(
session.cookie_header(),
session.web_protect_str,
session.keys_str,
user_agent=ua,
)
return auth, ua
@@ -59,6 +59,24 @@ class ProtoBuilder:
request.sdk_cert = normalize_client_cert(auth.client_cert or "")
return request
@staticmethod
def build_read_request(auth, cmd):
"""读接口(收件箱/会话消息)的 Request 信封。
Request.token 必须是 x_tt_token cookie。build_normal_request 填的是
auth.ticketbd-ticket-guard 票据):长度合法,服务端照样回
status_code=0 "OK",但把调用方当成匿名用户,body 恒为空——和「收件箱
里没有消息」完全无法区分。实测同一请求只换 token:
auth.ticket -> 73 字节 0 条;x_tt_token -> 113KB 47 条。
发送接口另有签名,沿用 build_normal_request,不在此处改动。
"""
request = ProtoBuilder.build_normal_request(auth, cmd)
cookies = getattr(auth, "cookie", None) or {}
token = str(cookies.get("x_tt_token") or "").strip()
if token:
request.token = token
return request
@staticmethod
def build_create_conversation_request(auth, toId, myId):
request = ProtoBuilder.build_normal_request(auth, 609)
+49 -2
View File
@@ -204,9 +204,46 @@ def extract_json_objects(raw: bytes | str) -> list[dict]:
return results
def _looks_like_push_frame(frame) -> bool:
"""判断这段字节确实是 frontier 的 PushFrame 信封。
真实帧一定带 service/method 和 frontier 自己的 headers/traceid;随手一段
二进制偶尔也能被 protobuf 宽松解析成 PushFrame,那种不算。
"""
return bool(
frame.service
or frame.method
or frame.payloadType
or frame.payloadEncoding
or frame.logIdNew
or len(frame.headersList)
)
def _decode_push_frame_payload(frame) -> bytes:
"""取出 PushFrame 内层负载,按 payloadEncoding 解压。
frontier 会用 gzip 压缩 payload;直接把压缩字节喂给 Response.ParseFromString
只会抛异常并被吞掉,整条私信就此丢失。
"""
body = bytes(frame.payload or b"")
if not body:
return b""
encoding = str(frame.payloadEncoding or "").lower()
if encoding in ("gzip", "gz"):
try:
return gzip.decompress(body)
except Exception as exc:
logger.warning("Failed to gunzip frontier frame payload: %s", exc)
return body
return body
def parse_ws_payload(raw: bytes | str) -> list[dict]:
"""解析 WebSocket 二进制帧,返回标准化消息 dict 列表"""
messages = []
frame_payload = b""
is_push_frame = False
# 尝试 Protobuf 解包
if isinstance(raw, bytes):
@@ -214,9 +251,14 @@ def parse_ws_payload(raw: bytes | str) -> list[dict]:
from .static import Live_pb2, Response_pb2
frame = Live_pb2.PushFrame()
frame.ParseFromString(raw)
if frame.payloadType == 'pb':
is_push_frame = _looks_like_push_frame(frame)
frame_payload = _decode_push_frame_payload(frame)
# payloadType 不再作为判据:现网 frontier 帧会带 'pb'、'text/json'
# 或空值,之前只认 'pb' 会把其余帧整帧丢弃。真正的判据是解出来
# 有没有 new_message_notify;解不出就照旧走下面的 JSON/文本兜底。
if frame_payload:
response = Response_pb2.Response()
response.ParseFromString(frame.payload)
response.ParseFromString(frame_payload)
body = response.body
if body.HasField("new_message_notify"):
notify = body.new_message_notify
@@ -306,6 +348,11 @@ def parse_ws_payload(raw: bytes | str) -> list[dict]:
if isinstance(raw, str):
payloads = [raw.encode("utf-8", errors="ignore")]
elif is_push_frame:
# 已确认是 frontier PushFrame:只解析它的内层负载。整帧字节里还有
# seqId / traceid / payloadType 等元数据,拿去做纯文本兜底会把每条
# 「连接建立」等控制帧误当成一条用户私信记录并触发一次自动回复。
payloads = [frame_payload] if frame_payload else []
else:
payloads = [raw]
# 尝试 gzip 解压(frontier 常见)
+62 -5
View File
@@ -320,6 +320,7 @@ class DouyinImService:
reply_cooldown_seconds: Optional[int] = None,
cooldown_resolver: Optional[Callable[[], Awaitable[int]]] = None,
refresh_credentials: Optional[Callable[[], Awaitable[bool]]] = None,
send_fallback: Optional[Callable[[str, str], Awaitable[tuple[bool, str]]]] = None,
follow_tick: Optional[Callable[[], Awaitable[None]]] = None,
on_session_invalid: Optional[Callable[[str], Awaitable[None]]] = None,
on_ready: Optional[ReadyFn] = None,
@@ -354,6 +355,11 @@ class DouyinImService:
self._cooldown_resolver = cooldown_resolver
# 由 worker 注入:触发后台重新采集 web_protect/keys(刷新 ts_sign),返回是否刷新成功
self.refresh_credentials = refresh_credentials
# 由 worker 注入的第二套发送方案:当 HTTP 签名发送被安全网关拒绝
# decision=KICK / 7911 / INVALID_REQUEST)时,用浏览器页面上下文
# 重新发送(真实 JS 生成 a_bogus/bd-ticket-guard,可自愈被踢的会话)。
# 签名: async (conversation_id, content) -> (ok, detail)
self.send_fallback = send_fallback
self._running = False
self._replied_keys: set[str] = set()
self._logged_keys: set[str] = set()
@@ -363,6 +369,9 @@ class DouyinImService:
self._conv_previews: dict[str, str] = {}
self._conv_names: dict[str, str] = {} # uid/conv_id -> nickname
self._conv_meta: dict[str, dict] = {} # conversation_id -> meta
# 抖音判定会话列表请求本身不合法时置位:这轮托管不再重复轮询该接口,
# 实时长连接成为唯一接收通道(已在系统日志里说明)。
self._conversation_list_unsupported = False
self._ws_client: Optional[DouyinImWsClient] = None
self.last_error: str = ""
@@ -1037,6 +1046,11 @@ class DouyinImService:
initial: bool = False,
defer_handlers: bool = False,
) -> list[dict]:
if self._conversation_list_unsupported:
# 抖音已明确拒绝过这个请求本身;重复调用只会每轮浪费一次请求,
# 并把同一条错误反复写进日志。原因已在首次拒绝时记录。
return []
controller = get_traffic_controller()
async with controller.background_slot(
self.account_id,
@@ -1053,6 +1067,14 @@ class DouyinImService:
account_id=self.account_id,
) as http:
conversations = await http.get_conversations(enrich_profiles=False)
if http.conversation_list_unsupported:
self._conversation_list_unsupported = True
logger.warning(
"Account %s disabled conversation reconciliation; "
"the realtime WebSocket is now the only receive path",
self.account_id,
)
return []
# Capture the previous preview before _index_conversations overwrites
# _conv_meta. A conversation-list preview is not inherently a new
@@ -1445,6 +1467,40 @@ class DouyinImService:
if refreshed:
continue
break
# 第二套发送方案(浏览器页面内发送):
# HTTP 签名发送被安全网关拒绝(KICK/7911/INVALID_REQUEST)时,交给 worker
# 用浏览器页面上下文重发——由抖音页面自带的 security-sdk 在真实环境生成
# a_bogus/bd-ticket-guard,绕开我们 Node execjs 的签名模拟,可自愈被踢会话。
upper_err = (self.last_error or "").upper()
if self.send_fallback and (
"DECISION=KICK" in upper_err
or "STATUS_CODE=7911" in upper_err
or "INVALID_REQUEST" in upper_err
):
try:
fb_ok, fb_detail = await self.send_fallback(conversation_id, content)
except Exception as exc:
logger.warning(f"send_fallback raised for {conversation_id}: {exc}")
fb_ok, fb_detail = False, f"浏览器兜底发送异常:{exc}"
if fb_ok:
self._session_invalid_strikes = 0
self._session_invalid_fired = False # 兜底成功说明登录仍有效,撤销自动下线
system_logger.record(
"浏览器兜底发送成功",
detail=f"会话 {conversation_id}{fb_detail}",
level="success",
category="send",
account_id=self.account_id,
)
return True, None
system_logger.record(
"浏览器兜底发送失败",
detail=f"会话 {conversation_id}{fb_detail}",
level="error",
category="send",
account_id=self.account_id,
)
await self._note_session_invalid(self.last_error)
return False, None
@@ -1479,7 +1535,7 @@ class DouyinImService:
system_logger.record(
"IM 登录失效,自动下线",
detail=f"{reason}{failure_detail})。"
"请停止托管后用浏览器模式重新登录并打开私信页,再重新启动托管",
"系统正在自动重登录,请留意账号卡片上的登录二维码并扫码",
level="error",
category="auth",
account_id=self.account_id,
@@ -1495,17 +1551,18 @@ class DouyinImService:
"""手动发送私信"""
from .conv_util import normalize_conversation_id
from .auth import DouyinAuth
from .dy_util import DEFAULT_USER_AGENT
auth = DouyinAuth()
auth.perepare_auth(
self.session.cookie_header(),
self.session.web_protect_str,
self.session.keys_str,
user_agent=self.session.user_agent or DEFAULT_USER_AGENT,
)
if getattr(self.session, "uid_verified", False) and self.session.my_uid:
my_uid = self.session.my_uid
else:
my_uid = await asyncio.to_thread(lambda: auth.get_uid()) or self.session.my_uid
my_uid = self.session.my_uid
if not my_uid:
my_uid = await asyncio.to_thread(lambda: auth.get_uid()) or 0
if my_uid:
conversation_id = normalize_conversation_id(conversation_id, my_uid)
+66 -20
View File
@@ -14,9 +14,21 @@ def is_frontier_ws_url(url: str) -> bool:
真实抓包里 host 可能是 frontier-im.douyin.com,也可能是
frontierNN-normal.zijieapi.com 这类内部别名,二者都要认。
"""
if not url or "token=" not in url:
if not url:
return False
return "frontier-im.douyin.com" in url or ("frontier" in url and "zijieapi.com" in url)
parsed = urlparse(url)
host = (parsed.hostname or "").lower()
query = parse_qs(parsed.query)
fpid = (query.get("fpid") or [""])[0]
legacy = host == "frontier-im.douyin.com" and "token" in query and fpid == "9"
browser_frontier = (
"frontier" in host
and host.endswith("zijieapi.com")
and "access_key" in query
and "device_id" in query
and fpid == "9"
)
return legacy or browser_frontier
@dataclass
@@ -34,7 +46,8 @@ class DouyinImSession:
keys_str: str = ""
web_protect_str: str = ""
my_uid: int = 0
# my_uid 是否已用 query/user 接口核验过(采集端推断的 my_uid 可能取错 tea_cache id
# my_uid 是否已由账号资料 UID 等可靠来源核验。query/user 的 user_uid
# 不是所有账号的 IM UID,不能据此覆盖已采集/已同步的 my_uid。
uid_verified: bool = False
conv_meta: dict = field(default_factory=dict)
# 方案 A:直接复用浏览器抓到的真实 frontier 连接凭证(绕开我们自己推导 token/access_key 不准的问题)
@@ -96,6 +109,14 @@ class DouyinImSession:
my_uid = _as_uid(extra.get("my_uid")) or _as_uid(data.get("my_uid"))
user_agent = str(extra.get("user_agent") or data.get("user_agent") or "").strip()
# 先整段扫描 localStorage,收集字段(避免遍历顺序导致取值不确定)。
# 关键背景:新版抖音 web 端 __tea_cache_tokens_6383 的 user_unique_id 实际存的是
# web_id(如 7678646545793812008),并非账号 UID;而 web_runtime_security_uid
# 才是账号真实 UID(如 2609567359568155)。混合登录态下若把 tea 的 user_unique_id
# 当 my_uid,会导致 device_id != my_uidIM 发送被安全网关 KICK。
ls_sec_uid = "" # web_runtime_security_uid(最可靠的账号 UID 来源)
ls_web_id = "" # 第一个 tea 条目的 web_id/user_unique_id
ls_tea_pairs = [] # [(user_unique_id, web_id), ...] 按出现顺序
if not device_id or not web_id or not keys_str or not web_protect_str or not my_uid:
for origin in data.get("origins", []):
for entry in origin.get("localStorage", []):
@@ -107,27 +128,42 @@ class DouyinImSession:
keys_str = value
if name == "security-sdk/s_sdk_sign_data_key/web_protect" and not web_protect_str:
web_protect_str = value
if "tea_cache_tokens" in name and not web_id:
if "tea_cache_tokens" in name:
try:
parsed = json.loads(value)
web_id = str(
parsed.get("web_id")
or parsed.get("user_unique_id")
or ""
)
except Exception:
pass
if name == "web_runtime_security_uid" and not device_id:
if str(value or "").isdigit():
device_id = value
if "tea_cache_tokens" in name and not my_uid:
try:
parsed = json.loads(value)
uid = parsed.get("user_unique_id")
if uid and str(uid).isdigit():
my_uid = int(uid)
if isinstance(parsed, dict):
wid = str(parsed.get("web_id") or "")
uid = str(parsed.get("user_unique_id") or "")
if not ls_web_id:
ls_web_id = wid or uid
ls_tea_pairs.append((uid, wid))
except Exception:
pass
if name == "web_runtime_security_uid":
v = str(value or "")
if v.isdigit() and not ls_sec_uid:
ls_sec_uid = v
# web_idextra 显式值 > localStorage tea
if not web_id:
web_id = ls_web_id
# my_uid 优先级:extra/顶层 > web_runtime_security_uid(真实账号 UID>
# tea 的 user_unique_id(仅当与自身 web_id 不同才可信,避免误取 web_id)
if not my_uid and ls_sec_uid:
my_uid = int(ls_sec_uid)
if not my_uid:
for uid, wid in ls_tea_pairs:
if uid.isdigit() and not (wid and uid == wid):
my_uid = int(uid)
break
# device_id 优先级:extra/cookies > web_runtime_security_uid(与账号 UID 绑定)
if not device_id:
if ls_sec_uid:
device_id = ls_sec_uid
elif my_uid:
device_id = str(my_uid)
if not my_uid:
for item in data.get("cookies", []):
@@ -144,6 +180,14 @@ class DouyinImSession:
elif not device_id and web_id:
device_id = web_id
# 最终一致性收敛:protobuf/frontier 的 device_id 优先取 session.device_id
# resolve_proto_device_id),若凭证里残留旧设备号(如 www 域
# web_runtime_security_uid),发送时 device_id != my_uid 会被安全网关
# 判为设备指纹异常 -> decision=KICK。my_uid 此时已是权威账号 UID,
# 不一致时以 my_uid 收敛 device_id。
if my_uid and device_id and str(device_id) != str(my_uid):
device_id = str(my_uid)
ws_urls = list(extra.get("ws_urls") or [])
# 方案 A:凭证采集工具可携带浏览器抓到的真实 frontier 连接(含 token/sdk_cert/ts_sign)。
@@ -193,6 +237,7 @@ class DouyinImSession:
"keys_str": self.keys_str,
"web_protect_str": self.web_protect_str,
"my_uid": self.my_uid,
"uid_verified": self.uid_verified,
"conv_meta": self.conv_meta,
"sdk_cert": self.sdk_cert,
"frontier_ts_sign": self.frontier_ts_sign,
@@ -212,6 +257,7 @@ class DouyinImSession:
keys_str=str(data.get("keys_str") or ""),
web_protect_str=str(data.get("web_protect_str") or ""),
my_uid=int(data.get("my_uid") or 0),
uid_verified=bool(data.get("uid_verified", False)),
conv_meta=dict(data.get("conv_meta") or {}),
sdk_cert=str(data.get("sdk_cert") or ""),
frontier_ts_sign=str(data.get("frontier_ts_sign") or ""),
+80 -6
View File
@@ -1,4 +1,5 @@
import asyncio
import gzip
import logging
import os
import weakref
@@ -14,6 +15,31 @@ logger = logging.getLogger("douyin_im.ws")
MessageHandler = Callable[[dict], Awaitable[None]]
def _safe_frame_metadata(payload: bytes) -> str:
"""Return non-content protobuf metadata for early connection diagnostics."""
try:
from .static import Live_pb2, Response_pb2
frame = Live_pb2.PushFrame()
frame.ParseFromString(payload)
body = bytes(frame.payload)
if str(frame.payloadEncoding or "").lower() == "gzip":
body = gzip.decompress(body)
response = Response_pb2.Response()
response.ParseFromString(body)
fields = [field.name for field, _ in response.body.ListFields()]
message = str(response.message or response.error_desc or "")[:80]
return (
f"service={frame.service} method={frame.method} "
f"encoding={frame.payloadEncoding or 'none'} "
f"type={frame.payloadType or 'none'} payload_bytes={len(body)} "
f"cmd={response.cmd} body={','.join(fields) or 'none'} "
f"status={message or 'ok'}"
)
except Exception as exc:
return f"metadata_unavailable={type(exc).__name__}"
# Both stages are finite. The transport queue gives the receive coroutine a
# small amount of breathing room, while the application queue decouples Pong /
# frame reads from potentially slow database and reply work. Once both fill,
@@ -127,6 +153,8 @@ class DouyinImWsClient:
self._last_connection_lifetime = 0.0
self._message_queue: Optional[asyncio.Queue[dict]] = None
self._dispatcher_task: Optional[asyncio.Task] = None
self._received_frame_count = 0
self._heartbeat_ack_logged = False
async def start(self):
if self._task and not self._task.done():
@@ -331,6 +359,16 @@ class DouyinImWsClient:
headers.append(("Cookie", cookie))
return headers
@staticmethod
def _uses_browser_frontier(url: str) -> bool:
return "zijieapi.com" in url and "access_key=" in url
async def _run_browser_heartbeat(self, websocket) -> None:
"""Mirror Frontier's browser SDK application-level ``hi`` heartbeat."""
while self._running:
await websocket.send("hi")
await asyncio.sleep(30)
async def _run_connection(self, url: str) -> None:
"""Open one connection and dispatch messages sequentially.
@@ -343,6 +381,8 @@ class DouyinImWsClient:
loop = asyncio.get_running_loop()
connected_at: float | None = None
connection: Optional[WebSocketClientProtocol] = None
heartbeat_task: Optional[asyncio.Task] = None
browser_frontier = self._uses_browser_frontier(url)
source_ip = str(getattr(self.session, "egress_source_ip", "") or "").strip()
connect_kwargs = {"local_addr": (source_ip, 0)} if source_ip else {}
try:
@@ -354,7 +394,9 @@ class DouyinImWsClient:
user_agent_header=self.session.user_agent,
compression="deflate",
open_timeout=10,
ping_interval=20,
# The current Douyin browser Frontier SDK uses a text ``hi``
# heartbeat instead of RFC WebSocket ping frames.
ping_interval=None if browser_frontier else 20,
# A handler may legitimately wait up to SQLite's 30s busy
# timeout. Leave enough headroom for queued work so a healthy
# socket isn't mistaken for a dead peer during that stall.
@@ -371,7 +413,10 @@ class DouyinImWsClient:
self._connection = websocket
connected_at = loop.time()
self.connected = True
logger.info("IM WebSocket connected")
logger.info(
"IM WebSocket connected: subprotocol=%s",
getattr(websocket, "subprotocol", None) or "none",
)
self._record_connection_system_event(
"connected",
"实时接收通道已连接",
@@ -379,10 +424,23 @@ class DouyinImWsClient:
level="success",
)
async for raw in websocket:
if not self._running:
break
await self._dispatch(raw)
if browser_frontier:
heartbeat_task = asyncio.create_task(
self._run_browser_heartbeat(websocket),
name=f"im-ws-heartbeat-{self.account_id or 'na'}",
)
try:
async for raw in websocket:
if not self._running:
break
await self._dispatch(raw)
finally:
if heartbeat_task and not heartbeat_task.done():
heartbeat_task.cancel()
try:
await heartbeat_task
except asyncio.CancelledError:
pass
finally:
if connected_at is not None:
self._last_connection_lifetime = max(0.0, loop.time() - connected_at)
@@ -408,6 +466,11 @@ class DouyinImWsClient:
)
async def _dispatch(self, raw):
if raw == "hi":
if not self._heartbeat_ack_logged:
logger.info("IM WebSocket application heartbeat acknowledged")
self._heartbeat_ack_logged = True
return
self._ensure_dispatcher()
queue = self._message_queue
if queue is None:
@@ -417,6 +480,17 @@ class DouyinImWsClient:
else:
payload = raw
items = parse_ws_payload(payload)
self._received_frame_count += 1
if self._received_frame_count <= 3:
metadata = _safe_frame_metadata(payload) if not items else "parsed-message"
logger.info(
"IM WebSocket frame received: seq=%d kind=%s bytes=%d parsed=%d %s",
self._received_frame_count,
"text" if isinstance(raw, str) else "binary",
len(payload),
len(items),
metadata,
)
for item in items:
if not self._running:
return
File diff suppressed because it is too large Load Diff
+145 -129
View File
@@ -1,129 +1,145 @@
"""运行时环境配置:住宅代理 + 浏览器显示。
用于解决部署到云服务器后两类常见问题
1. 机房 IP 触发抖音风控7911 通过 KEFU_DOUYIN_PROXY 让抖音请求走住宅代理
2. 无图形界面的 Linux 起不来有头浏览器 自动拉起 Xvfb 虚拟显示
全部通过环境变量控制无需改代码
KEFU_DOUYIN_PROXY 抖音 IM HTTP 请求与浏览器登录走的代理绕开机房 IP 风控
形如 http://user:pass@host:port socks5://host:port
KEFU_BROWSER_HEADLESS 是否使用无头浏览器1/true 开启默认 false抖音安全 SDK
headless 判定严格无头易生成无效 ts_sign反而刷新无效
"""
import asyncio
import logging
import os
from typing import Optional
from urllib.parse import urlparse
logger = logging.getLogger("rpa_engine.runtime")
_TRUE = {"1", "true", "yes", "on"}
_FALSE = {"0", "false", "no", "off"}
_NO_DISPLAY_HINT = (
"当前是无图形界面的 Linux 服务器,且无法启动虚拟显示来运行有头浏览器。"
"抖音安全 SDK 对 headless 判定严格,扫码登录 / 刷新凭证需要有头 Chromium。请任选其一:\n"
" 1) 安装 Xvfb + pyvirtualdisplay,让程序自动拉起虚拟显示:\n"
" Debian/Ubuntu: apt install -y xvfb && pip install pyvirtualdisplay\n"
" CentOS/Rocky : yum install -y xorg-x11-server-Xvfb && pip install pyvirtualdisplay\n"
" 2) 或用 xvfb-run 启动后端:xvfb-run -a ./start_web.sh\n"
" 3) 或设置 KEFU_BROWSER_HEADLESS=1 强制无头(更易触发抖音风控,不推荐)。"
)
def get_douyin_proxy() -> Optional[str]:
"""读取抖音请求代理 URL(未配置返回 None)。"""
val = (os.getenv("KEFU_DOUYIN_PROXY") or "").strip()
return val or None
def httpx_proxy() -> Optional[str]:
"""供 httpx.AsyncClient(proxy=...) 使用的代理 URL。"""
return get_douyin_proxy()
def requests_proxies() -> Optional[dict]:
"""供 requests.get(proxies=...) 使用的代理字典。"""
url = get_douyin_proxy()
if not url:
return None
return {"http": url, "https": url}
def playwright_proxy() -> Optional[dict]:
"""转成 Playwright launch(proxy=...) 所需结构(未配置或无法解析返回 None)。"""
url = get_douyin_proxy()
if not url:
return None
parsed = urlparse(url)
if not parsed.hostname:
logger.warning("KEFU_DOUYIN_PROXY 格式无法解析,已忽略:%s", url)
return None
server = f"{parsed.scheme or 'http'}://{parsed.hostname}"
if parsed.port:
server += f":{parsed.port}"
proxy: dict[str, str] = {"server": server}
if parsed.username:
proxy["username"] = parsed.username
if parsed.password:
proxy["password"] = parsed.password
return proxy
def resolve_headless(default: bool = False) -> bool:
"""根据 KEFU_BROWSER_HEADLESS 决定是否无头;未设置时用 default。"""
val = (os.getenv("KEFU_BROWSER_HEADLESS") or "").strip().lower()
if val in _TRUE:
return True
if val in _FALSE:
return False
return default
# 进程内仅启动一次的虚拟显示(Xvfb)句柄
_virtual_display = None
_virtual_display_failed = False
def _start_virtual_display_sync() -> Optional[str]:
"""在无 DISPLAY 的 Linux 上启动一次 Xvfb 虚拟显示(阻塞,需放线程执行)。"""
global _virtual_display, _virtual_display_failed
# 仅 Linux 且无 DISPLAY 时才需要虚拟显示;Windows/macOS 有桌面,直接返回。
if os.name != "posix":
return os.environ.get("DISPLAY")
if os.environ.get("DISPLAY"):
return os.environ["DISPLAY"]
if _virtual_display is not None:
return os.environ.get("DISPLAY")
if _virtual_display_failed:
raise RuntimeError(_NO_DISPLAY_HINT)
try:
from pyvirtualdisplay import Display
except ImportError as e:
_virtual_display_failed = True
raise RuntimeError(_NO_DISPLAY_HINT) from e
try:
disp = Display(visible=False, size=(1280, 800))
disp.start() # 设置 os.environ['DISPLAY']
except Exception as e:
_virtual_display_failed = True
raise RuntimeError(_NO_DISPLAY_HINT) from e
_virtual_display = disp
logger.info("已启动 Xvfb 虚拟显示 DISPLAY=%s 供有头浏览器使用", os.environ.get("DISPLAY"))
return os.environ.get("DISPLAY")
async def ensure_browser_display(headless: bool) -> None:
"""有头模式在无 DISPLAY 的 Linux 上自动拉起 Xvfb 虚拟显示。
headless=True 时无需显示直接返回启动失败抛出带操作指引的 RuntimeError
"""
if headless:
return
await asyncio.to_thread(_start_virtual_display_sync)
"""运行时环境配置:住宅代理 + 浏览器显示。
用于解决部署到云服务器后两类常见问题
1. 机房 IP 触发抖音风控7911 通过 KEFU_DOUYIN_PROXY 让抖音请求走住宅代理
2. 无图形界面的 Linux 起不来有头浏览器 自动拉起 Xvfb 虚拟显示
全部通过环境变量控制无需改代码
KEFU_DOUYIN_PROXY 抖音 IM HTTP 请求与浏览器登录走的代理绕开机房 IP 风控
形如 http://user:pass@host:port socks5://host:port
KEFU_BROWSER_HEADLESS 是否使用无头浏览器1/true 开启默认 false抖音安全 SDK
headless 判定严格无头易生成无效 ts_sign反而刷新无效
"""
import asyncio
import logging
import os
from typing import Optional
from urllib.parse import urlparse
logger = logging.getLogger("rpa_engine.runtime")
_TRUE = {"1", "true", "yes", "on"}
_FALSE = {"0", "false", "no", "off"}
_NO_DISPLAY_HINT = (
"当前是无图形界面的 Linux 服务器,且无法启动虚拟显示来运行有头浏览器。"
"抖音安全 SDK 对 headless 判定严格,扫码登录 / 刷新凭证需要有头 Chromium。请任选其一:\n"
" 1) 安装 Xvfb + pyvirtualdisplay,让程序自动拉起虚拟显示:\n"
" Debian/Ubuntu: apt install -y xvfb && pip install pyvirtualdisplay\n"
" CentOS/Rocky : yum install -y xorg-x11-server-Xvfb && pip install pyvirtualdisplay\n"
" 2) 或用 xvfb-run 启动后端:xvfb-run -a ./start_web.sh\n"
" 3) 或设置 KEFU_BROWSER_HEADLESS=1 强制无头(更易触发抖音风控,不推荐)。"
)
def get_douyin_proxy() -> Optional[str]:
"""读取抖音请求代理 URL(未配置返回 None)。"""
val = (os.getenv("KEFU_DOUYIN_PROXY") or "").strip()
return val or None
def httpx_proxy() -> Optional[str]:
"""供 httpx.AsyncClient(proxy=...) 使用的代理 URL。"""
return get_douyin_proxy()
def requests_proxies() -> Optional[dict]:
"""供 requests.get(proxies=...) 使用的代理字典。"""
url = get_douyin_proxy()
if not url:
return None
return {"http": url, "https": url}
def playwright_proxy() -> Optional[dict]:
"""转成 Playwright launch(proxy=...) 所需结构(未配置或无法解析返回 None)。"""
url = get_douyin_proxy()
if not url:
return None
parsed = urlparse(url)
if not parsed.hostname:
logger.warning("KEFU_DOUYIN_PROXY 格式无法解析,已忽略:%s", url)
return None
server = f"{parsed.scheme or 'http'}://{parsed.hostname}"
if parsed.port:
server += f":{parsed.port}"
proxy: dict[str, str] = {"server": server}
if parsed.username:
proxy["username"] = parsed.username
if parsed.password:
proxy["password"] = parsed.password
return proxy
def resolve_headless(default: bool = False) -> bool:
"""根据 KEFU_BROWSER_HEADLESS 决定是否无头;未设置时用 default。"""
val = (os.getenv("KEFU_BROWSER_HEADLESS") or "").strip().lower()
if val in _TRUE:
return True
if val in _FALSE:
return False
return default
def ui_conversation_page_budget(default: int = 3) -> int:
"""用户点开会话列表时允许翻的收件箱页数(KEFU_UI_CONVERSATION_PAGES)。
抖音收件箱按游标分页一次请求只给一页实测每页约 100-500KB某账号翻
6 页拿到 35 个会话仍未翻完所以必须有预算只拿一页会把其中一页当成
完整会话列表不设上限又可能为一次点击拉下好几 MB默认 3 页只是折中
花多少流量换多完整的列表属于业务取舍用环境变量调整即可
无论调到多少翻不完时都会并入本地历史不会把残缺列表伪装成完整列表
"""
try:
value = int(os.getenv("KEFU_UI_CONVERSATION_PAGES", str(default)) or default)
except (TypeError, ValueError):
value = default
return max(1, min(20, value))
# 进程内仅启动一次的虚拟显示(Xvfb)句柄
_virtual_display = None
_virtual_display_failed = False
def _start_virtual_display_sync() -> Optional[str]:
"""在无 DISPLAY 的 Linux 上启动一次 Xvfb 虚拟显示(阻塞,需放线程执行)。"""
global _virtual_display, _virtual_display_failed
# 仅 Linux 且无 DISPLAY 时才需要虚拟显示;Windows/macOS 有桌面,直接返回。
if os.name != "posix":
return os.environ.get("DISPLAY")
if os.environ.get("DISPLAY"):
return os.environ["DISPLAY"]
if _virtual_display is not None:
return os.environ.get("DISPLAY")
if _virtual_display_failed:
raise RuntimeError(_NO_DISPLAY_HINT)
try:
from pyvirtualdisplay import Display
except ImportError as e:
_virtual_display_failed = True
raise RuntimeError(_NO_DISPLAY_HINT) from e
try:
disp = Display(visible=False, size=(1280, 800))
disp.start() # 设置 os.environ['DISPLAY']
except Exception as e:
_virtual_display_failed = True
raise RuntimeError(_NO_DISPLAY_HINT) from e
_virtual_display = disp
logger.info("已启动 Xvfb 虚拟显示 DISPLAY=%s 供有头浏览器使用", os.environ.get("DISPLAY"))
return os.environ.get("DISPLAY")
async def ensure_browser_display(headless: bool) -> None:
"""有头模式在无 DISPLAY 的 Linux 上自动拉起 Xvfb 虚拟显示。
headless=True 时无需显示直接返回启动失败抛出带操作指引的 RuntimeError
"""
if headless:
return
await asyncio.to_thread(_start_virtual_display_sync)