更新
This commit is contained in:
+1137
-1081
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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:
|
||||
# 让签名上下文记住调用方 UA:query_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:
|
||||
|
||||
@@ -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_agent(from_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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.ticket(bd-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)
|
||||
|
||||
@@ -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 常见)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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_uid,IM 发送被安全网关 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_id:extra 显式值 > 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 ""),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user