更新
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,318 @@
|
||||
"""Bounded, deduplicated background queue for bulk account starts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
|
||||
logger = logging.getLogger("rpa.batch_start")
|
||||
|
||||
StartHandler = Callable[[int], Awaitable[dict[str, Any]]]
|
||||
JobToken = tuple[str, int]
|
||||
|
||||
|
||||
def _configured_concurrency() -> int:
|
||||
try:
|
||||
return max(1, min(8, int(os.getenv("KEFU_BATCH_START_CONCURRENCY", "2"))))
|
||||
except (TypeError, ValueError):
|
||||
return 2
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _BatchRecord:
|
||||
batch_id: str
|
||||
owner_id: int | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
items: dict[int, dict[str, Any]] = field(default_factory=dict)
|
||||
created_at: str = field(default_factory=_utc_now)
|
||||
updated_at: str = field(default_factory=_utc_now)
|
||||
|
||||
|
||||
class BatchStartQueue:
|
||||
"""Run account preparation with a small process-wide concurrency cap.
|
||||
|
||||
The HTTP endpoint only enqueues account ids. Long credential checks then
|
||||
run in these workers, so a batch of many accounts cannot block the request
|
||||
that submitted it. ``_pending_accounts`` makes overlapping clicks and
|
||||
overlapping batches idempotent for each account. Pending and active
|
||||
ownership is tied to a concrete job token so cleanup from a cancelled old
|
||||
job cannot release a newer submission for the same account.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
handler: StartHandler,
|
||||
concurrency: int | None = None,
|
||||
max_batches: int = 100,
|
||||
) -> None:
|
||||
self._handler = handler
|
||||
self.concurrency = max(1, int(concurrency or _configured_concurrency()))
|
||||
self.max_batches = max(10, int(max_batches or 100))
|
||||
self._queue: asyncio.Queue[JobToken] = asyncio.Queue()
|
||||
self._pending_jobs: dict[int, JobToken] = {}
|
||||
self._active_tasks: dict[int, tuple[JobToken, asyncio.Task]] = {}
|
||||
self._batches: dict[str, _BatchRecord] = {}
|
||||
self._workers: list[asyncio.Task] = []
|
||||
self._lock = asyncio.Lock()
|
||||
self._stopping = False
|
||||
|
||||
async def _ensure_workers(self) -> None:
|
||||
async with self._lock:
|
||||
self._workers = [task for task in self._workers if not task.done()]
|
||||
if self._workers or self._stopping:
|
||||
return
|
||||
for index in range(self.concurrency):
|
||||
self._workers.append(
|
||||
asyncio.create_task(
|
||||
self._worker(index + 1),
|
||||
name=f"account-batch-start-{index + 1}",
|
||||
)
|
||||
)
|
||||
|
||||
async def submit(
|
||||
self,
|
||||
account_ids: list[int],
|
||||
owner_id: int | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
await self._ensure_workers()
|
||||
unique_ids = list(dict.fromkeys(int(value) for value in account_ids if int(value) > 0))
|
||||
batch_id = uuid.uuid4().hex
|
||||
record = _BatchRecord(
|
||||
batch_id=batch_id,
|
||||
owner_id=owner_id,
|
||||
metadata=dict(metadata or {}),
|
||||
)
|
||||
|
||||
async with self._lock:
|
||||
if self._stopping:
|
||||
raise RuntimeError("账号启动队列正在停止")
|
||||
self._prune_locked()
|
||||
self._batches[batch_id] = record
|
||||
for account_id in unique_ids:
|
||||
if account_id in self._pending_jobs:
|
||||
record.items[account_id] = {
|
||||
"account_id": account_id,
|
||||
"status": "already_queued",
|
||||
"message": "账号已在启动队列中",
|
||||
}
|
||||
continue
|
||||
job_token = (batch_id, account_id)
|
||||
self._pending_jobs[account_id] = job_token
|
||||
record.items[account_id] = {
|
||||
"account_id": account_id,
|
||||
"status": "queued",
|
||||
"message": "等待启动",
|
||||
}
|
||||
self._queue.put_nowait(job_token)
|
||||
record.updated_at = _utc_now()
|
||||
return self._snapshot_locked(record)
|
||||
|
||||
async def get_batch(
|
||||
self,
|
||||
batch_id: str,
|
||||
owner_id: int | None = None,
|
||||
include_items: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._lock:
|
||||
record = self._batches.get(str(batch_id or ""))
|
||||
if record and owner_id is not None and record.owner_id != int(owner_id):
|
||||
return None
|
||||
return self._snapshot_locked(record, include_items=include_items) if record else None
|
||||
|
||||
async def _worker(self, worker_number: int) -> None:
|
||||
while True:
|
||||
batch_id, account_id = await self._queue.get()
|
||||
job_token = (batch_id, account_id)
|
||||
started_at = time.monotonic()
|
||||
handler_task: asyncio.Task | None = None
|
||||
try:
|
||||
async with self._lock:
|
||||
record = self._batches.get(batch_id)
|
||||
if not record:
|
||||
if self._pending_jobs.get(account_id) == job_token:
|
||||
self._pending_jobs.pop(account_id, None)
|
||||
continue
|
||||
item = record.items[account_id]
|
||||
if item.get("status") == "cancelled":
|
||||
if self._pending_jobs.get(account_id) == job_token:
|
||||
self._pending_jobs.pop(account_id, None)
|
||||
continue
|
||||
item.update(status="processing", message="正在校验并启动")
|
||||
record.updated_at = _utc_now()
|
||||
handler_task = asyncio.create_task(
|
||||
self._handler(account_id),
|
||||
name=f"account-start-{account_id}",
|
||||
)
|
||||
self._active_tasks[account_id] = (job_token, handler_task)
|
||||
|
||||
result = await handler_task
|
||||
async with self._lock:
|
||||
record = self._batches.get(batch_id)
|
||||
if record:
|
||||
item = record.items[account_id]
|
||||
if item.get("status") != "cancelled":
|
||||
item.update(
|
||||
status="submitted",
|
||||
message=str(result.get("message") or "已提交启动"),
|
||||
login_mode=result.get("login_mode"),
|
||||
skip_browser=bool(result.get("skip_browser", False)),
|
||||
elapsed_seconds=round(time.monotonic() - started_at, 3),
|
||||
)
|
||||
record.updated_at = _utc_now()
|
||||
except asyncio.CancelledError:
|
||||
async with self._lock:
|
||||
record = self._batches.get(batch_id)
|
||||
if record:
|
||||
record.items[account_id].update(
|
||||
status="cancelled",
|
||||
message="服务停止,启动任务已取消",
|
||||
)
|
||||
record.updated_at = _utc_now()
|
||||
# Cancelling one account must not kill a long-lived queue
|
||||
# worker. Re-raise only when stop() cancelled the worker.
|
||||
if asyncio.current_task().cancelling():
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Batch start failed account=%s worker=%s: %s",
|
||||
account_id,
|
||||
worker_number,
|
||||
exc,
|
||||
)
|
||||
async with self._lock:
|
||||
record = self._batches.get(batch_id)
|
||||
if record:
|
||||
detail = getattr(exc, "detail", None) or str(exc) or "启动失败"
|
||||
item = record.items[account_id]
|
||||
if item.get("status") != "cancelled":
|
||||
item.update(
|
||||
status="failed",
|
||||
message=str(detail),
|
||||
elapsed_seconds=round(time.monotonic() - started_at, 3),
|
||||
)
|
||||
record.updated_at = _utc_now()
|
||||
finally:
|
||||
async with self._lock:
|
||||
active_entry = self._active_tasks.get(account_id)
|
||||
if active_entry == (job_token, handler_task):
|
||||
self._active_tasks.pop(account_id, None)
|
||||
if self._pending_jobs.get(account_id) == job_token:
|
||||
self._pending_jobs.pop(account_id, None)
|
||||
self._queue.task_done()
|
||||
|
||||
async def cancel_account(self, account_id: int) -> int:
|
||||
"""Cancel queued/processing work so stop/delete cannot restart it."""
|
||||
account_key = int(account_id)
|
||||
cancelled = 0
|
||||
active_task: asyncio.Task | None = None
|
||||
async with self._lock:
|
||||
for record in self._batches.values():
|
||||
item = record.items.get(account_key)
|
||||
if not item or item.get("status") not in ("queued", "processing"):
|
||||
continue
|
||||
item.update(status="cancelled", message="启动任务已取消")
|
||||
record.updated_at = _utc_now()
|
||||
cancelled += 1
|
||||
self._pending_jobs.pop(account_key, None)
|
||||
active_entry = self._active_tasks.get(account_key)
|
||||
active_task = active_entry[1] if active_entry else None
|
||||
if active_task and not active_task.done():
|
||||
active_task.cancel()
|
||||
if active_task and not active_task.done():
|
||||
await asyncio.gather(active_task, return_exceptions=True)
|
||||
return cancelled
|
||||
|
||||
def _snapshot_locked(
|
||||
self,
|
||||
record: _BatchRecord,
|
||||
*,
|
||||
include_items: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
counts = {
|
||||
"queued": 0,
|
||||
"processing": 0,
|
||||
"submitted": 0,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"cancelled": 0,
|
||||
}
|
||||
browser_required = 0
|
||||
for item in record.items.values():
|
||||
status = item.get("status")
|
||||
if status == "submitted" and item.get("skip_browser") is False:
|
||||
browser_required += 1
|
||||
if status == "already_queued":
|
||||
counts["skipped"] += 1
|
||||
elif status in counts:
|
||||
counts[status] += 1
|
||||
active = counts["queued"] + counts["processing"]
|
||||
snapshot = {
|
||||
"batch_id": record.batch_id,
|
||||
"total_count": len(record.items),
|
||||
"accepted_count": len(record.items) - counts["skipped"],
|
||||
"queued_count": counts["queued"],
|
||||
"processing_count": counts["processing"],
|
||||
"submitted_count": counts["submitted"],
|
||||
"failed_count": counts["failed"],
|
||||
"skipped_count": counts["skipped"],
|
||||
"cancelled_count": counts["cancelled"],
|
||||
"browser_required_count": browser_required,
|
||||
"complete": active == 0,
|
||||
"created_at": record.created_at,
|
||||
"updated_at": record.updated_at,
|
||||
}
|
||||
snapshot.update(record.metadata)
|
||||
if include_items:
|
||||
snapshot["items"] = [dict(item) for item in record.items.values()]
|
||||
return snapshot
|
||||
|
||||
def _prune_locked(self) -> None:
|
||||
if len(self._batches) < self.max_batches:
|
||||
return
|
||||
removable = [
|
||||
batch_id
|
||||
for batch_id, record in self._batches.items()
|
||||
if self._snapshot_locked(record, include_items=False)["complete"]
|
||||
]
|
||||
for batch_id in removable[: max(1, len(self._batches) - self.max_batches + 1)]:
|
||||
self._batches.pop(batch_id, None)
|
||||
|
||||
async def stop(self) -> None:
|
||||
async with self._lock:
|
||||
self._stopping = True
|
||||
workers = list(self._workers)
|
||||
self._workers.clear()
|
||||
for task in workers:
|
||||
task.cancel()
|
||||
if workers:
|
||||
await asyncio.gather(*workers, return_exceptions=True)
|
||||
async with self._lock:
|
||||
self._active_tasks.clear()
|
||||
while True:
|
||||
try:
|
||||
batch_id, account_id = self._queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
record = self._batches.get(batch_id)
|
||||
if record:
|
||||
record.items[account_id].update(
|
||||
status="cancelled",
|
||||
message="服务停止,启动任务已取消",
|
||||
)
|
||||
record.updated_at = _utc_now()
|
||||
job_token = (batch_id, account_id)
|
||||
if self._pending_jobs.get(account_id) == job_token:
|
||||
self._pending_jobs.pop(account_id, None)
|
||||
self._queue.task_done()
|
||||
@@ -0,0 +1,226 @@
|
||||
"""账号凭证检测:静态 Cookie 分析 + IM 运行时校验"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from rpa_engine.douyin_im.auth import DouyinAuth
|
||||
from rpa_engine.douyin_im.frontier import ensure_frontier_ws
|
||||
from rpa_engine.douyin_im.http_client import DouyinImHttpClient
|
||||
from rpa_engine.douyin_im.session import DouyinImSession
|
||||
from utils.cookie_store import analyze_cookie
|
||||
|
||||
logger = logging.getLogger("credential")
|
||||
|
||||
|
||||
def _should_reset_credentials(assessment: dict) -> bool:
|
||||
"""凭证全面失效时需清空 Cookie/IM 数据并重新登录。"""
|
||||
if not assessment.get("has_cookie"):
|
||||
return False
|
||||
if not assessment.get("cookie_valid"):
|
||||
return True
|
||||
message = assessment.get("message") or ""
|
||||
# 仅缺 ticket/签名/浏览器采集 — 保留 Cookie,走浏览器补全即可
|
||||
if any(
|
||||
token in message
|
||||
for token in ("ticket", "签名密钥", "浏览器模式", "web_protect")
|
||||
):
|
||||
return False
|
||||
if (
|
||||
not assessment.get("im_ready")
|
||||
and not assessment.get("can_skip_browser")
|
||||
and assessment.get("has_sessionid")
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build_im_session_from_storage(
|
||||
storage: dict,
|
||||
saved_im_data: Optional[str] = None,
|
||||
) -> DouyinImSession:
|
||||
session = DouyinImSession.from_storage_state(storage or {})
|
||||
if saved_im_data:
|
||||
try:
|
||||
saved = DouyinImSession.from_dict(json.loads(saved_im_data))
|
||||
# 新粘贴的 storage_state(含真实 frontier_ws_url)优先;只有它没带时才用缓存的。
|
||||
if saved.ws_urls and not session.ws_urls:
|
||||
session.ws_urls = saved.ws_urls
|
||||
if saved.sdk_cert and not session.sdk_cert:
|
||||
session.sdk_cert = saved.sdk_cert
|
||||
if saved.frontier_ts_sign and not session.frontier_ts_sign:
|
||||
session.frontier_ts_sign = saved.frontier_ts_sign
|
||||
if saved.keys_str and not session.keys_str:
|
||||
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:
|
||||
session.my_uid = saved.my_uid
|
||||
if saved.device_id and not session.device_id:
|
||||
session.device_id = saved.device_id
|
||||
if saved.web_id and not session.web_id:
|
||||
session.web_id = saved.web_id
|
||||
if saved.conv_meta:
|
||||
session.conv_meta = {**saved.conv_meta, **session.conv_meta}
|
||||
except Exception:
|
||||
pass
|
||||
session.sanitize_ws_urls()
|
||||
# Keep this builder pure and non-blocking. Frontier discovery can perform
|
||||
# a synchronous network request (up to 15 seconds); callers that need it
|
||||
# already do so from validate_im_session() through asyncio.to_thread() and
|
||||
# the shared background-traffic limiter. Running it here made a large
|
||||
# batch freeze the FastAPI event loop before any limiter was acquired.
|
||||
return session
|
||||
|
||||
|
||||
def has_im_session_token(session: DouyinImSession) -> bool:
|
||||
return bool(session.cookies.get("sessionid") or session.cookies.get("sessionid_ss"))
|
||||
|
||||
|
||||
def extract_sessionid_info(session: DouyinImSession) -> dict:
|
||||
sessionid = session.cookies.get("sessionid") or ""
|
||||
sessionid_ss = session.cookies.get("sessionid_ss") or ""
|
||||
return {
|
||||
"has_sessionid": bool(sessionid or sessionid_ss),
|
||||
"sessionid": sessionid,
|
||||
"sessionid_ss": sessionid_ss,
|
||||
}
|
||||
|
||||
|
||||
async def build_cookie_credential_detail(
|
||||
cookie_data: Optional[str],
|
||||
im_session_data: Optional[str] = None,
|
||||
runtime_check: bool = True,
|
||||
) -> dict:
|
||||
"""供编辑账号页展示 IM 凭证与 sessionid 信息"""
|
||||
sessionid_info = {
|
||||
"has_sessionid": False,
|
||||
"sessionid": "",
|
||||
"sessionid_ss": "",
|
||||
"im_ready": False,
|
||||
"im_status": "未保存 Cookie",
|
||||
"can_skip_browser": False,
|
||||
"should_reset": False,
|
||||
}
|
||||
if not cookie_data:
|
||||
return sessionid_info
|
||||
|
||||
try:
|
||||
storage = json.loads(cookie_data)
|
||||
session = build_im_session_from_storage(storage, im_session_data)
|
||||
sessionid_info.update(extract_sessionid_info(session))
|
||||
except Exception:
|
||||
sessionid_info["im_status"] = "Cookie 格式错误"
|
||||
return sessionid_info
|
||||
|
||||
if not runtime_check:
|
||||
if sessionid_info["has_sessionid"]:
|
||||
sessionid_info["im_status"] = "已检测到 sessionid(未做运行时验证)"
|
||||
else:
|
||||
sessionid_info["im_status"] = "缺少 sessionid,无法 IM 直连"
|
||||
return sessionid_info
|
||||
|
||||
assessment = await assess_account_credential(cookie_data, im_session_data)
|
||||
sessionid_info["im_ready"] = assessment["im_ready"]
|
||||
sessionid_info["im_status"] = assessment["message"]
|
||||
sessionid_info["can_skip_browser"] = assessment["can_skip_browser"]
|
||||
sessionid_info["should_reset"] = assessment["should_reset"]
|
||||
return sessionid_info
|
||||
|
||||
|
||||
async def validate_im_session(
|
||||
session: DouyinImSession,
|
||||
_bypass_global_limit: bool = False,
|
||||
) -> tuple[bool, str]:
|
||||
if not _bypass_global_limit:
|
||||
from rpa_engine.douyin_im.traffic_control import get_traffic_controller
|
||||
|
||||
controller = get_traffic_controller()
|
||||
async with controller.background_slot(0, "credential validation"):
|
||||
return await validate_im_session(session, _bypass_global_limit=True)
|
||||
|
||||
if not session.can_direct_im():
|
||||
if not has_im_session_token(session):
|
||||
return False, "缺少 sessionid,无法直连 IM"
|
||||
return False, "Cookie 不满足 IM 直连条件"
|
||||
|
||||
await asyncio.to_thread(ensure_frontier_ws, session)
|
||||
try:
|
||||
auth = DouyinAuth.from_im_session(session)
|
||||
# 优先用已持久化的 my_uid,避免每次都发起网络 query_my_uid(uid_tt 是加密串,
|
||||
# int() 解析必然失败而回退到网络请求;该请求偶发失败会误判为“未就绪”)。
|
||||
uid = session.my_uid or auth.get_uid()
|
||||
if not uid:
|
||||
return False, "服务端未认可当前 Cookie(无法获取用户 UID)"
|
||||
if not auth.is_sign_ready():
|
||||
return False, "缺少 IM 签名密钥(web_protect/keys),请用浏览器登录补全"
|
||||
session.my_uid = int(uid)
|
||||
async with DouyinImHttpClient(session) as http:
|
||||
await http.get_unread_count()
|
||||
# 若已缓存到会话票据,优先校验其是否仍新鲜(最理想)。
|
||||
if session.conv_meta:
|
||||
ok, reason = await http.verify_messaging_capability(auth, session.my_uid)
|
||||
if ok:
|
||||
return True, reason
|
||||
# 没有缓存会话票据是首次登录的正常情况:会话 ticket 会在发送时即时
|
||||
# 创建/获取(resolve_conversation_meta),因此只要 Cookie + sessionid +
|
||||
# 签名密钥(web_protect/keys) + UID 齐全,就视为可 IM 直连托管,不必再开浏览器。
|
||||
return True, "IM 凭证就绪(Cookie 与签名密钥齐全,可直连托管)"
|
||||
except Exception as e:
|
||||
logger.warning(f"IM session validation failed: {e}")
|
||||
return False, f"IM 运行时验证失败: {e}"
|
||||
|
||||
|
||||
async def assess_account_credential(
|
||||
cookie_data: Optional[str],
|
||||
im_session_data: Optional[str] = None,
|
||||
) -> dict:
|
||||
cookie_info = analyze_cookie(cookie_data)
|
||||
result = {
|
||||
"has_cookie": cookie_info.get("has_cookie", False),
|
||||
"cookie_valid": cookie_info.get("cookie_valid", False),
|
||||
"cookie_status": cookie_info.get("reason", ""),
|
||||
"has_sessionid": False,
|
||||
"im_ready": False,
|
||||
"can_skip_browser": False,
|
||||
"login_mode": "browser",
|
||||
"message": "未保存 Cookie,需浏览器扫码登录",
|
||||
"should_reset": False,
|
||||
}
|
||||
|
||||
if not cookie_data:
|
||||
return result
|
||||
|
||||
try:
|
||||
storage = json.loads(cookie_data)
|
||||
except Exception:
|
||||
result["message"] = "Cookie 格式错误"
|
||||
result["should_reset"] = _should_reset_credentials(result)
|
||||
return result
|
||||
|
||||
session = build_im_session_from_storage(storage, im_session_data)
|
||||
result["has_sessionid"] = has_im_session_token(session)
|
||||
|
||||
if not cookie_info.get("cookie_valid"):
|
||||
result["message"] = cookie_info.get("reason") or "Cookie 无效,需重新登录"
|
||||
result["should_reset"] = _should_reset_credentials(result)
|
||||
return result
|
||||
|
||||
if not result["has_sessionid"]:
|
||||
result["login_mode"] = "browser"
|
||||
result["message"] = "Cookie 已保存但缺少 sessionid,需浏览器刷新登录态"
|
||||
result["should_reset"] = _should_reset_credentials(result)
|
||||
return result
|
||||
|
||||
im_ok, im_reason = await validate_im_session(session)
|
||||
result["im_ready"] = im_ok
|
||||
if im_ok:
|
||||
result["can_skip_browser"] = True
|
||||
result["login_mode"] = "im_direct"
|
||||
result["message"] = im_reason
|
||||
else:
|
||||
result["login_mode"] = "browser"
|
||||
result["message"] = im_reason
|
||||
|
||||
result["should_reset"] = _should_reset_credentials(result)
|
||||
return result
|
||||
@@ -0,0 +1,89 @@
|
||||
"""浏览器 / IM 伪装设备头(User-Agent)预设。
|
||||
|
||||
a_bogus 签名、Playwright 浏览器上下文、IM HTTP 请求头、Protobuf body 必须使用同一 UA,
|
||||
否则抖音会返回 7911 安全校验失败。
|
||||
"""
|
||||
|
||||
from rpa_engine.douyin_im.dy_util import DEFAULT_USER_AGENT
|
||||
|
||||
# id 用于前端下拉;user_agent 为完整字符串
|
||||
DEVICE_PROFILES: list[dict[str, str]] = [
|
||||
{
|
||||
"id": "chrome_win120",
|
||||
"label": "Chrome 120 · Windows(默认,推荐)",
|
||||
"platform": "Windows",
|
||||
"user_agent": DEFAULT_USER_AGENT,
|
||||
},
|
||||
{
|
||||
"id": "chrome_win131",
|
||||
"label": "Chrome 131 · Windows",
|
||||
"platform": "Windows",
|
||||
"user_agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "edge_win125",
|
||||
"label": "Edge 125 · Windows",
|
||||
"platform": "Windows",
|
||||
"user_agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.0"
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "firefox_win117",
|
||||
"label": "Firefox 117 · Windows",
|
||||
"platform": "Windows",
|
||||
"user_agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) "
|
||||
"Gecko/20100101 Firefox/117.0"
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "chrome_mac120",
|
||||
"label": "Chrome 120 · macOS",
|
||||
"platform": "macOS",
|
||||
"user_agent": (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "safari_mac17",
|
||||
"label": "Safari 17 · macOS",
|
||||
"platform": "macOS",
|
||||
"user_agent": (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
|
||||
"(KHTML, like Gecko) Version/17.0 Safari/605.1.15"
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
_PROFILE_BY_UA = {p["user_agent"]: p for p in DEVICE_PROFILES}
|
||||
|
||||
|
||||
def list_device_profiles() -> list[dict[str, str]]:
|
||||
return [
|
||||
{"id": p["id"], "label": p["label"], "platform": p["platform"], "user_agent": p["user_agent"]}
|
||||
for p in DEVICE_PROFILES
|
||||
]
|
||||
|
||||
|
||||
def resolve_user_agent(stored: str | None) -> str:
|
||||
"""账号保存的 UA;空则使用默认 Chrome 120。"""
|
||||
text = (stored or "").strip()
|
||||
return text or DEFAULT_USER_AGENT
|
||||
|
||||
|
||||
def profile_label_for_ua(ua: str | None) -> str:
|
||||
text = (ua or "").strip()
|
||||
if not text:
|
||||
return "Chrome 120 · Windows(默认)"
|
||||
hit = _PROFILE_BY_UA.get(text)
|
||||
if hit:
|
||||
return hit["label"]
|
||||
if len(text) > 48:
|
||||
return text[:48] + "…"
|
||||
return text
|
||||
@@ -0,0 +1,3 @@
|
||||
from .service import DouyinImService
|
||||
|
||||
__all__ = ["DouyinImService"]
|
||||
@@ -0,0 +1,163 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from .dy_util import (
|
||||
trans_cookies,
|
||||
generate_msToken,
|
||||
generate_a_bogus,
|
||||
splice_url,
|
||||
generate_webid,
|
||||
normalize_client_cert,
|
||||
resolve_proto_device_id,
|
||||
DEFAULT_USER_AGENT,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("douyin_im.auth")
|
||||
|
||||
|
||||
def _parse_storage_json(raw) -> dict | None:
|
||||
"""Parse localStorage JSON (supports nested data wrapper)."""
|
||||
if not raw:
|
||||
return None
|
||||
if isinstance(raw, dict):
|
||||
data = raw
|
||||
else:
|
||||
text = str(raw).strip()
|
||||
data = None
|
||||
for _ in range(4):
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except Exception:
|
||||
break
|
||||
if isinstance(parsed, str):
|
||||
text = parsed
|
||||
continue
|
||||
if isinstance(parsed, dict):
|
||||
data = parsed
|
||||
break
|
||||
break
|
||||
if not data:
|
||||
return None
|
||||
|
||||
inner = data.get("data")
|
||||
if isinstance(inner, str):
|
||||
try:
|
||||
inner = json.loads(inner)
|
||||
except Exception:
|
||||
inner = None
|
||||
if isinstance(inner, dict):
|
||||
return inner
|
||||
return data
|
||||
|
||||
|
||||
class DouyinAuth:
|
||||
def __init__(self):
|
||||
self.cookie = None
|
||||
self.cookie_str = None
|
||||
self.private_key = None
|
||||
self.ticket = None
|
||||
self.ts_sign = None
|
||||
self.client_cert = None
|
||||
self.ree_public_key = None
|
||||
self.uid = None
|
||||
self.msToken = None
|
||||
self.web_id = None
|
||||
|
||||
def perepare_auth(self, cookieStr: str, web_protect_: str = "", keys_: str = ""):
|
||||
self.cookie = trans_cookies(cookieStr)
|
||||
self.cookie_str = cookieStr
|
||||
self.msToken = self.cookie["msToken"] if "msToken" in self.cookie else generate_msToken()
|
||||
self.cookie["msToken"] = self.msToken
|
||||
self.cookie_str = "; ".join([f"{k}={v}" for k, v in self.cookie.items()])
|
||||
web_data = _parse_storage_json(web_protect_)
|
||||
if web_data:
|
||||
try:
|
||||
self.ticket = web_data.get("ticket") or ""
|
||||
self.ts_sign = web_data.get("ts_sign") or ""
|
||||
self.client_cert = web_data.get("client_cert") or ""
|
||||
except Exception as e:
|
||||
logger.debug(f"web_protect parse failed: {e}")
|
||||
|
||||
keys_data = _parse_storage_json(keys_)
|
||||
if keys_data:
|
||||
try:
|
||||
self.private_key = keys_data.get("ec_privateKey") or keys_data.get("privateKey") or ""
|
||||
if self.private_key:
|
||||
self.ree_public_key = base64.b64encode(self.private_key.encode()).decode()
|
||||
except Exception as e:
|
||||
logger.debug(f"keys parse failed: {e}")
|
||||
|
||||
def is_sign_ready(self) -> bool:
|
||||
return bool(
|
||||
self.private_key
|
||||
and self.ticket
|
||||
and self.ts_sign
|
||||
and self.client_cert
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_im_session(cls, session) -> "DouyinAuth":
|
||||
"""从 DouyinImSession 构建 HTTP 发送/拉会话用的签名上下文。
|
||||
|
||||
注意:frontier WS 的 sdk_cert/ts_sign 只用于长连接,不能覆盖 web_protect,
|
||||
否则 bd-ticket-guard 与 protobuf 签名会与 ticket 失配 -> 7911。
|
||||
"""
|
||||
auth = cls()
|
||||
auth.perepare_auth(
|
||||
session.cookie_header(),
|
||||
session.web_protect_str,
|
||||
session.keys_str,
|
||||
)
|
||||
auth.web_id = session.web_id or session.device_id or None
|
||||
auth.user_agent = session.user_agent or DEFAULT_USER_AGENT
|
||||
auth.device_id = resolve_proto_device_id(
|
||||
session.device_id, session.web_id, session.my_uid
|
||||
)
|
||||
# web_protect 缺 client_cert 时,才用 frontier 抓包证书兜底(不覆盖 ts_sign)
|
||||
if not auth.client_cert and getattr(session, "sdk_cert", ""):
|
||||
auth.client_cert = normalize_client_cert(session.sdk_cert)
|
||||
elif auth.client_cert:
|
||||
auth.client_cert = normalize_client_cert(auth.client_cert)
|
||||
return auth
|
||||
|
||||
def get_uid(self):
|
||||
if self.uid is None:
|
||||
# 优先从 cookie 尝试提取,否则请求接口
|
||||
for k in ("uid_tt", "uid_tt_ss"):
|
||||
if self.cookie and self.cookie.get(k):
|
||||
try:
|
||||
self.uid = int(self.cookie.get(k))
|
||||
return self.uid
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
self.uid = self.query_my_uid()
|
||||
except Exception:
|
||||
pass
|
||||
return self.uid
|
||||
|
||||
def query_my_uid(self) -> int:
|
||||
url = 'https://www.douyin.com/aweme/v1/web/query/user/'
|
||||
headers = {
|
||||
"User-Agent": DEFAULT_USER_AGENT,
|
||||
"Referer": "https://www.douyin.com/",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
}
|
||||
params = {
|
||||
"device_platform": "webapp",
|
||||
"aid": "6383",
|
||||
"channel": "channel_pc_web",
|
||||
"publish_video_strategy_type": "2",
|
||||
"verifyFp": self.cookie.get('s_v_web_id', ''),
|
||||
"fp": self.cookie.get('s_v_web_id', ''),
|
||||
"webid": generate_webid(self, "https://www.douyin.com/"),
|
||||
"msToken": self.msToken
|
||||
}
|
||||
query = splice_url(params)
|
||||
abogus = generate_a_bogus(query, user_agent=DEFAULT_USER_AGENT)
|
||||
params['a_bogus'] = abogus
|
||||
|
||||
resp = requests.get(url, params=params, headers=headers, cookies=self.cookie, verify=False, timeout=10)
|
||||
resp_json = resp.json()
|
||||
return int(resp_json['user_uid'])
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Conversation id helpers for Douyin IM."""
|
||||
|
||||
|
||||
def parse_conversation_parts(conversation_id: str) -> tuple[int, int] | None:
|
||||
parts = (conversation_id or "").split(":")
|
||||
if len(parts) >= 4 and parts[0] == "0" and parts[1] == "1":
|
||||
try:
|
||||
return int(parts[2]), int(parts[3])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def resolve_peer_uid(conversation_id: str, my_uid: int) -> int | None:
|
||||
"""Resolve peer user id from conversation id or bare numeric id."""
|
||||
raw = (conversation_id or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
parts = parse_conversation_parts(raw)
|
||||
if parts:
|
||||
uid1, uid2 = parts
|
||||
if my_uid and uid1 == my_uid:
|
||||
return uid2
|
||||
if my_uid and uid2 == my_uid:
|
||||
return uid1
|
||||
# 0:1:{my}:{peer} — 若 my_uid 与首段不一致,仍取末段为对方
|
||||
return uid2
|
||||
|
||||
if raw.isdigit():
|
||||
peer = int(raw)
|
||||
if my_uid and peer == my_uid:
|
||||
return None
|
||||
return peer
|
||||
return None
|
||||
|
||||
|
||||
def build_conversation_id(my_uid: int, peer_uid: int) -> str:
|
||||
return f"0:1:{int(my_uid)}:{int(peer_uid)}"
|
||||
|
||||
|
||||
def normalize_conversation_id(conversation_id: str, my_uid: int) -> str:
|
||||
"""Ensure conversation id uses current account uid as first participant."""
|
||||
peer_uid = resolve_peer_uid(conversation_id, my_uid)
|
||||
if peer_uid and my_uid:
|
||||
return build_conversation_id(my_uid, peer_uid)
|
||||
return (conversation_id or "").strip()
|
||||
@@ -0,0 +1,205 @@
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
import json
|
||||
import random
|
||||
import base64
|
||||
import urllib.parse
|
||||
from os import path
|
||||
import subprocess
|
||||
original_popen = subprocess.Popen
|
||||
def patched_popen(*args, **kwargs):
|
||||
if kwargs.get('universal_newlines') or kwargs.get('text'):
|
||||
if 'encoding' not in kwargs:
|
||||
kwargs['encoding'] = 'utf-8'
|
||||
return original_popen(*args, **kwargs)
|
||||
subprocess.Popen = patched_popen
|
||||
|
||||
import execjs
|
||||
import requests
|
||||
|
||||
basedir = path.dirname(__file__)
|
||||
static_dir = path.join(basedir, 'static')
|
||||
node_modules = path.join(static_dir, 'node_modules')
|
||||
|
||||
# 全局唯一 User-Agent:a_bogus 签名、HTTP 请求头、protobuf body、webid 采集等
|
||||
# 必须全部使用同一个 UA,否则抖音服务端重算 a_bogus 时与请求头 UA 不一致 -> 7911。
|
||||
# 该值需与浏览器登录上下文(playwright new_context user_agent)保持一致。
|
||||
DEFAULT_USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
# 动态编译 JS
|
||||
dy_path = path.join(static_dir, 'dy_ab.js')
|
||||
dy_js = execjs.compile(open(dy_path, 'r', encoding='utf-8').read(), cwd=node_modules)
|
||||
|
||||
sign_path = path.join(static_dir, 'dy_live_sign.js')
|
||||
sign_js = execjs.compile(open(sign_path, 'r', encoding='utf-8').read(), cwd=node_modules)
|
||||
|
||||
login_path = path.join(static_dir, 'login.js')
|
||||
login_js = execjs.compile(open(login_path, 'r', encoding='utf-8').read(), cwd=node_modules)
|
||||
|
||||
|
||||
def generateSecretPhoneNum(phone):
|
||||
return login_js.call('generateSecretPhoneNum', phone)
|
||||
|
||||
|
||||
def generateSecretCode(phone, code):
|
||||
return login_js.call('generateSecretCode', phone, code)
|
||||
|
||||
|
||||
def trans_cookies(cookies_str):
|
||||
cookies = {}
|
||||
for i in cookies_str.split("; "):
|
||||
try:
|
||||
parts = i.split('=')
|
||||
key = parts[0].strip()
|
||||
val = '='.join(parts[1:]).strip()
|
||||
# 防御性清洗:过滤掉因为误粘贴 fetch 等包含非法字符或换行的 Cookie 键
|
||||
if not key or any(c in key for c in "()[]{}'\"\n \t\\"):
|
||||
continue
|
||||
cookies[key] = val
|
||||
except:
|
||||
continue
|
||||
return cookies
|
||||
|
||||
|
||||
def generate_req_sign(e, priK):
|
||||
"""私信传 obj,其他的拼接"""
|
||||
return dy_js.call('get_req_sign', e, priK)
|
||||
|
||||
|
||||
def generate_a_bogus(query, data="", user_agent=None):
|
||||
"""query, data 都是拼接字符串。
|
||||
|
||||
user_agent 必须与实际发出请求所用的 User-Agent 完全一致(见 DEFAULT_USER_AGENT),
|
||||
否则抖音服务端用请求头 UA 重算 a_bogus 会对不上,导致 7911 安全校验失败。
|
||||
"""
|
||||
return dy_js.call('get_ab', query, data, user_agent or DEFAULT_USER_AGENT)
|
||||
|
||||
|
||||
def generate_signature(room_id, user_unique_id):
|
||||
raw_string = f"live_id=1,aid=6383,version_code=180800,webcast_sdk_version=1.0.15,room_id={room_id},sub_room_id=,sub_channel_id=,did_rule=3,user_unique_id={user_unique_id},device_platform=web,device_type=,ac=,identity=audience"
|
||||
x_ms_stub = hashlib.md5(raw_string.encode("utf-8")).hexdigest()
|
||||
result = sign_js.call("get_signature", x_ms_stub)
|
||||
return result.get("X-Bogus")
|
||||
|
||||
|
||||
def generate_ree_key(prik):
|
||||
"""传递私钥"""
|
||||
return dy_js.call('get_ree_key', prik)
|
||||
|
||||
|
||||
def generate_bd_ticket_client_data(api, ticket, ts_sign, priK):
|
||||
"""传递 query, ticket, ts_sign, priK"""
|
||||
timestamp = int(time.time())
|
||||
res_sign = f"ticket={ticket}&path={api}×tamp={timestamp}"
|
||||
p = {
|
||||
'ts_sign': ts_sign,
|
||||
'req_content': 'ticket,path,timestamp',
|
||||
'req_sign': generate_req_sign(res_sign, priK),
|
||||
'timestamp': timestamp,
|
||||
}
|
||||
p = json.dumps(p, ensure_ascii=False, separators=(',', ':'))
|
||||
return base64.urlsafe_b64encode(p.encode('utf-8')).decode('utf-8')
|
||||
|
||||
|
||||
def generate_msToken(randomlength=107):
|
||||
random_str = ''
|
||||
base_str = 'ABCDEFGHIGKLMNOPQRSTUVWXYZabcdefghigklmnopqrstuvwxyz0123456789='
|
||||
length = len(base_str) - 1
|
||||
for _ in range(randomlength):
|
||||
random_str += base_str[random.randint(0, length)]
|
||||
return random_str
|
||||
|
||||
|
||||
def generate_fake_webid(random_length=19):
|
||||
random_str = ''
|
||||
base_str = '0123456789'
|
||||
length = len(base_str) - 1
|
||||
for _ in range(random_length):
|
||||
random_str += base_str[random.randint(0, length)]
|
||||
return random_str
|
||||
|
||||
|
||||
def generate_webid(auth=None, url=""):
|
||||
# 优先用已采集到的 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"
|
||||
try:
|
||||
from .auth import DouyinAuth
|
||||
headers = {
|
||||
"User-Agent": DEFAULT_USER_AGENT,
|
||||
"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"
|
||||
}
|
||||
if auth and auth.cookie_str:
|
||||
headers['cookie'] = auth.cookie_str
|
||||
try:
|
||||
from rpa_engine.runtime_config import requests_proxies
|
||||
proxies = requests_proxies()
|
||||
except Exception:
|
||||
proxies = None
|
||||
response = requests.get(
|
||||
url, headers=headers, verify=False, timeout=10, proxies=proxies
|
||||
)
|
||||
res_text = response.text
|
||||
user_unique_id = re.findall(r'\\"user_unique_id\\":\\"(.*?)\\"', res_text)[0]
|
||||
# 把发现的 web_id 回写到 auth 上,避免同一次同步里反复发起阻塞的 HTTP 请求
|
||||
if auth is not None and user_unique_id:
|
||||
try:
|
||||
auth.web_id = user_unique_id
|
||||
except Exception:
|
||||
pass
|
||||
return user_unique_id
|
||||
except Exception:
|
||||
# 失败时同样缓存一个伪 web_id,避免后续调用重复走 10s 超时的网络请求
|
||||
fake = generate_fake_webid()
|
||||
if auth is not None:
|
||||
try:
|
||||
if not getattr(auth, "web_id", None):
|
||||
auth.web_id = fake
|
||||
except Exception:
|
||||
pass
|
||||
return fake
|
||||
|
||||
|
||||
def generate_millisecond():
|
||||
return int(round(time.time() * 1000))
|
||||
|
||||
|
||||
def normalize_client_cert(cert: str) -> str:
|
||||
"""统一 client_cert / sdk_cert 格式为「base64 证书体」。
|
||||
|
||||
web_protect.client_cert 与 frontier WS 的 sdk_cert 通常已是 base64(PEM);
|
||||
若误把 PEM 原文或 frontier 证书二次 base64,会导致 7911。
|
||||
"""
|
||||
cert = (cert or "").strip()
|
||||
if not cert:
|
||||
return ""
|
||||
if cert.startswith("-----BEGIN"):
|
||||
return base64.b64encode(cert.encode("utf-8")).decode("utf-8")
|
||||
return cert
|
||||
|
||||
|
||||
def resolve_proto_device_id(device_id: str = "", web_id: str = "", my_uid: int = 0) -> str:
|
||||
"""protobuf / frontier 更倾向使用数字 device_id(通常等于 my_uid)。"""
|
||||
for candidate in (device_id, web_id, str(my_uid or "")):
|
||||
c = str(candidate or "").strip()
|
||||
if c.isdigit():
|
||||
return c
|
||||
return str(device_id or web_id or "0")
|
||||
|
||||
|
||||
def splice_url(params):
|
||||
splice_url_str = ''
|
||||
for key, value in params.items():
|
||||
if value is None:
|
||||
value = ''
|
||||
splice_url_str += key + '=' + urllib.parse.quote(str(value)) + '&'
|
||||
return splice_url_str[:-1]
|
||||
@@ -0,0 +1,136 @@
|
||||
"""抖音标准表情(评论区 emoji)名称 -> 图片 URL 映射。
|
||||
|
||||
抖音文字表情如 [酷拽]/[微笑] 通过 WS 以 message_type=7 的纯文本下发,
|
||||
content 形如 {"text":"[酷拽]","aweType":700},不带图片地址;
|
||||
浏览器端靠本地表情表把 [name] 渲染成小图。这里拉取官方表情列表接口,
|
||||
建立 name->url 映射,收到文字表情时补成可显示的贴纸。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("douyin_im.emoji")
|
||||
|
||||
_EMOJI_MAP: dict[str, str] = {}
|
||||
_FETCHED_AT: float = 0.0
|
||||
_TTL = 6 * 3600 # 6 小时刷新一次
|
||||
_lock = threading.Lock()
|
||||
_fetch_lock = threading.Lock()
|
||||
_LAST_FETCH_ATTEMPT: float = 0.0
|
||||
_FAILURE_RETRY_SECONDS = 60.0
|
||||
|
||||
_BRACKET_RE = re.compile(r"^\[[^\[\]]{1,24}\]$")
|
||||
|
||||
|
||||
def has_emoji_map() -> bool:
|
||||
return bool(_EMOJI_MAP)
|
||||
|
||||
|
||||
def is_fresh() -> bool:
|
||||
return bool(_EMOJI_MAP) and (time.time() - _FETCHED_AT) < _TTL
|
||||
|
||||
|
||||
def set_emoji_map(mapping: dict[str, str]) -> None:
|
||||
global _EMOJI_MAP, _FETCHED_AT
|
||||
if mapping:
|
||||
with _lock:
|
||||
_EMOJI_MAP = dict(mapping)
|
||||
_FETCHED_AT = time.time()
|
||||
|
||||
|
||||
def lookup_emoji_url(name: str) -> str:
|
||||
"""name 可带或不带中括号,返回标准表情图片 URL(无则空串)。"""
|
||||
if not name:
|
||||
return ""
|
||||
key = name.strip()
|
||||
if not key:
|
||||
return ""
|
||||
if not key.startswith("["):
|
||||
key = f"[{key}]"
|
||||
return _EMOJI_MAP.get(key, "")
|
||||
|
||||
|
||||
def looks_like_emoji_token(text: str) -> bool:
|
||||
return bool(_BRACKET_RE.match((text or "").strip()))
|
||||
|
||||
|
||||
def fetch_emoji_map(session) -> dict[str, str]:
|
||||
"""用账号会话拉取官方表情列表,返回 {display_name: url}。失败返回 {}。"""
|
||||
try:
|
||||
import requests
|
||||
|
||||
from .auth import DouyinAuth
|
||||
from .dy_util import generate_a_bogus, generate_msToken, splice_url
|
||||
|
||||
auth = DouyinAuth.from_im_session(session)
|
||||
ua = session.user_agent
|
||||
s_v_web_id = session.cookies.get("s_v_web_id", "") if session.cookies else ""
|
||||
params = {
|
||||
"device_platform": "webapp",
|
||||
"aid": "6383",
|
||||
"channel": "channel_pc_web",
|
||||
"pc_client_type": "1",
|
||||
"version_code": "170400",
|
||||
"version_name": "17.4.0",
|
||||
"cookie_enabled": "true",
|
||||
"browser_language": "zh-CN",
|
||||
"browser_platform": "Win32",
|
||||
"browser_name": "Mozilla",
|
||||
"browser_online": "true",
|
||||
"verifyFp": s_v_web_id,
|
||||
"fp": s_v_web_id,
|
||||
"webid": session.web_id or session.device_id or "",
|
||||
"msToken": generate_msToken(),
|
||||
}
|
||||
params["a_bogus"] = generate_a_bogus(splice_url(params), user_agent=ua)
|
||||
headers = {
|
||||
"User-Agent": ua,
|
||||
"Referer": "https://www.douyin.com/",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Cookie": session.cookie_header(),
|
||||
}
|
||||
resp = requests.get(
|
||||
"https://www.douyin.com/aweme/v1/web/emoji/list",
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=auth.cookie if getattr(auth, "cookie", None) else None,
|
||||
verify=False,
|
||||
timeout=20,
|
||||
)
|
||||
data = resp.json()
|
||||
mapping: dict[str, str] = {}
|
||||
for item in data.get("emoji_list") or []:
|
||||
name = item.get("display_name")
|
||||
urls = (item.get("emoji_url") or {}).get("url_list") or []
|
||||
if name and urls:
|
||||
mapping[name] = urls[0]
|
||||
logger.info("Fetched %d douyin emoji", len(mapping))
|
||||
return mapping
|
||||
except Exception as e:
|
||||
logger.warning("fetch_emoji_map failed: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def ensure_emoji_map(session) -> None:
|
||||
"""若缓存为空/过期则拉取(同步阻塞,调用方建议放线程)。"""
|
||||
global _LAST_FETCH_ATTEMPT
|
||||
if is_fresh():
|
||||
return
|
||||
# Batch-started accounts used to all observe an empty cache and fetch the
|
||||
# same emoji list concurrently. Keep the network request itself inside a
|
||||
# separate single-flight lock (set_emoji_map uses _lock).
|
||||
with _fetch_lock:
|
||||
if is_fresh():
|
||||
return
|
||||
now = time.time()
|
||||
if now - _LAST_FETCH_ATTEMPT < _FAILURE_RETRY_SECONDS:
|
||||
return
|
||||
_LAST_FETCH_ATTEMPT = now
|
||||
mapping = fetch_emoji_map(session)
|
||||
if mapping:
|
||||
set_emoji_map(mapping)
|
||||
@@ -0,0 +1,183 @@
|
||||
"""抖音网页版「粉丝列表」拉取,用于检测新粉丝(关注欢迎语功能)。
|
||||
|
||||
复用与 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
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Build frontier-im WebSocket URL (DouYin_Spider douyin_recv_msg logic)."""
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
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 .session import DouyinImSession, is_frontier_ws_url
|
||||
|
||||
logger = logging.getLogger("douyin_im.frontier")
|
||||
|
||||
APP_KEY = "e1bd35ec9db7b8d846de66ed140b1ad9"
|
||||
FP_ID = "9"
|
||||
|
||||
|
||||
def build_frontier_ws_url(session: DouyinImSession, device_id: str) -> Optional[str]:
|
||||
token = session.cookies.get("sessionid") or session.cookies.get("sessionid_ss") or ""
|
||||
if not token or not device_id:
|
||||
return None
|
||||
access_key_raw = f"{FP_ID}{APP_KEY}{device_id}f8a69f1719916z"
|
||||
access_key = hashlib.md5(access_key_raw.encode("utf-8")).hexdigest()
|
||||
params = {
|
||||
"aid": "6383",
|
||||
"device_platform": "douyin_pc",
|
||||
"fpid": FP_ID,
|
||||
"device_id": device_id,
|
||||
"token": token,
|
||||
"access_key": access_key,
|
||||
}
|
||||
query = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
return f"wss://frontier-im.douyin.com/ws/v2?{query}"
|
||||
|
||||
|
||||
def fetch_device_id(session: DouyinImSession) -> str:
|
||||
"""Call Douyin user query API to obtain device/web id."""
|
||||
auth = DouyinAuth()
|
||||
auth.perepare_auth(
|
||||
session.cookie_header(),
|
||||
session.web_protect_str,
|
||||
session.keys_str,
|
||||
)
|
||||
url = "https://www.douyin.com/aweme/v1/web/query/user"
|
||||
headers = {
|
||||
"User-Agent": session.user_agent,
|
||||
"Referer": "https://www.douyin.com/discover",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Cookie": session.cookie_header(),
|
||||
}
|
||||
params = {
|
||||
"device_platform": "webapp",
|
||||
"aid": "6383",
|
||||
"channel": "channel_pc_web",
|
||||
"publish_video_strategy_type": "2",
|
||||
"verifyFp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "",
|
||||
"fp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "",
|
||||
"webid": generate_webid(auth, "https://www.douyin.com/discover"),
|
||||
"msToken": generate_msToken(),
|
||||
}
|
||||
query = splice_url(params)
|
||||
params["a_bogus"] = generate_a_bogus(query, user_agent=session.user_agent)
|
||||
try:
|
||||
resp = requests.get(
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=auth.cookie,
|
||||
verify=False,
|
||||
timeout=15,
|
||||
)
|
||||
data = resp.json()
|
||||
device_id = str(data.get("id") or data.get("device_id") or "")
|
||||
if device_id.isdigit():
|
||||
logger.info(f"Fetched device_id: {device_id[:20]}...")
|
||||
return device_id
|
||||
logger.warning(f"query/user returned non-numeric id: {device_id[:32]!r}")
|
||||
except Exception as e:
|
||||
logger.warning(f"fetch_device_id failed: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_frontier_device_id(session: DouyinImSession) -> str:
|
||||
"""Frontier WS requires numeric device_id from Douyin query/user API."""
|
||||
current = str(session.device_id or session.web_id or "")
|
||||
if current.isdigit():
|
||||
return current
|
||||
|
||||
fetched = fetch_device_id(session)
|
||||
if fetched and str(fetched).isdigit():
|
||||
session.device_id = str(fetched)
|
||||
logger.info(f"Using numeric device_id for frontier WS: {fetched[:16]}...")
|
||||
return str(fetched)
|
||||
|
||||
logger.warning(
|
||||
f"Invalid frontier device_id={current[:24]!r}; "
|
||||
"expected numeric id from query/user API"
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def _ws_device_id(url: str) -> str:
|
||||
m = re.search(r"[?&]device_id=([^&\s]+)", url or "")
|
||||
return unquote(m.group(1)) if m else ""
|
||||
|
||||
|
||||
def _ws_device_matches_session(session: DouyinImSession, url: str) -> bool:
|
||||
ws_dev = _ws_device_id(url)
|
||||
if not ws_dev or not ws_dev.isdigit():
|
||||
return True
|
||||
for candidate in (session.my_uid, session.web_id, session.device_id):
|
||||
if candidate and str(candidate) == ws_dev:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _ws_token_looks_encoded(url: str) -> bool:
|
||||
m = re.search(r"[?&]token=([^&\s]+)", url or "")
|
||||
if not m:
|
||||
return False
|
||||
token = unquote(m.group(1))
|
||||
return len(token) >= 40 or not token.replace("_", "").replace("-", "").isalnum()
|
||||
|
||||
|
||||
def ensure_frontier_ws(session: DouyinImSession) -> Optional[str]:
|
||||
"""Ensure session has a usable frontier WebSocket URL."""
|
||||
session.sanitize_ws_urls()
|
||||
|
||||
for url in session.ws_urls:
|
||||
if is_frontier_ws_url(url) and "sdk_cert=" in url and _ws_token_looks_encoded(url):
|
||||
session.ws_urls = [url]
|
||||
logger.info("Using captured real frontier WS URL (with sdk_cert)")
|
||||
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
|
||||
|
||||
device_id = resolve_frontier_device_id(session)
|
||||
if not device_id:
|
||||
session.ws_urls = []
|
||||
return None
|
||||
|
||||
built = build_frontier_ws_url(session, device_id)
|
||||
if built:
|
||||
session.ws_urls = [built]
|
||||
logger.info("Built frontier WS URL from cookie session")
|
||||
return built
|
||||
return None
|
||||
@@ -0,0 +1,38 @@
|
||||
"""本系统当前托管中的账号 UID 注册表。
|
||||
|
||||
用途:避免两个都在本系统托管的账号互相自动回复,形成无限回环——
|
||||
这种高频来回发送是触发抖音风控(7911)/业务拒绝(8004)的常见根因。
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("douyin_im.hosted_registry")
|
||||
|
||||
_HOSTED_UIDS: set[int] = set()
|
||||
|
||||
|
||||
def register(uid) -> None:
|
||||
try:
|
||||
u = int(uid)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if u:
|
||||
_HOSTED_UIDS.add(u)
|
||||
logger.info(f"Registered hosted uid {u} (total={len(_HOSTED_UIDS)})")
|
||||
|
||||
|
||||
def unregister(uid) -> None:
|
||||
try:
|
||||
u = int(uid)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
_HOSTED_UIDS.discard(u)
|
||||
logger.info(f"Unregistered hosted uid {u} (total={len(_HOSTED_UIDS)})")
|
||||
|
||||
|
||||
def is_hosted(uid) -> bool:
|
||||
try:
|
||||
u = int(uid)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return u in _HOSTED_UIDS
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
"""Parse Douyin IM Response protobuf for conversation metadata."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def extract_conv_meta_from_response_bytes(raw: bytes) -> dict[str, dict]:
|
||||
"""Return {conversation_id: {conversation_short_id, ticket}} from IM API protobuf body."""
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
from .static import Response_pb2 as ResponseProto
|
||||
|
||||
response_proto = ResponseProto.Response()
|
||||
response_proto.ParseFromString(raw)
|
||||
body = response_proto.body
|
||||
out: dict[str, dict] = {}
|
||||
for field in (
|
||||
"create_conversation_v2_body",
|
||||
"get_conversation_info_list_v2_response_body",
|
||||
):
|
||||
if not body.HasField(field):
|
||||
continue
|
||||
conv_body = getattr(body, field)
|
||||
for conv in conv_body.conversation_info_list:
|
||||
if not conv.conversation_id:
|
||||
continue
|
||||
out[conv.conversation_id] = {
|
||||
"conversation_short_id": str(conv.conversation_short_id),
|
||||
"ticket": conv.ticket,
|
||||
}
|
||||
return out
|
||||
except Exception:
|
||||
return {}
|
||||
@@ -0,0 +1,720 @@
|
||||
"""抖音 IM 私信图片上传。
|
||||
|
||||
复刻抖音 PC 网页版私信发图的真实链路(与"创作者中心 ImageX 图文图床"是两套,
|
||||
IM 发送只认这条链路产出的 tos-cn-o-* 资源):
|
||||
|
||||
1. GET www.douyin.com/aweme/v1/web/im/upload/config/v2 (a_bogus 签名)
|
||||
→ 返回内含 STS2 凭证(AccessKeyID + SignedSecretAccessKey)与 SpaceName(=zhenzhen)
|
||||
2. GET vod.bytedanceapi.com/?Action=ApplyUploadInner&SpaceName=zhenzhen&FileType=image
|
||||
(AWS4-HMAC-SHA256,service=vod,带 x-amz-security-token=STS2…)
|
||||
→ 返回 UploadHost / StoreUri(tos-cn-o-*) / Auth(SpaceKey JWT) / SessionKey
|
||||
3. POST https://{UploadHost}/upload/v1/{StoreUri}
|
||||
(Authorization: SpaceKey/zhenzhen/…JWT,Content-CRC32,X-Storage-U=my_uid)
|
||||
4. POST vod.bytedanceapi.com/?Action=CommitUploadInner&SpaceName=zhenzhen (AWS4 签名)
|
||||
→ 确认上传,最终 uri 即 StoreUri(tos-cn-o-*)
|
||||
|
||||
之后用该 tos-cn-o-* uri 构造 type=27 消息体即可被 IM 后端校验通过。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
import zlib
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
logger = logging.getLogger("douyin_im.image_upload")
|
||||
|
||||
_LOCAL_URL_RE = re.compile(
|
||||
r"^(/api/media/messages/|https?://(?:localhost|127\.0\.0\.1)(?::\d+)?/api/media/messages/)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
# 抖音 web 私信图片上传专用通道(VOD/ImageX inner 接口 + IM 上传配置)
|
||||
VOD_HOST = "https://vod.bytedanceapi.com/"
|
||||
VOD_REGION = "cn-north-1"
|
||||
VOD_SERVICE = "vod"
|
||||
IM_UPLOAD_CONFIG_URL = "https://www.douyin.com/aweme/v1/web/im/upload/config/v2"
|
||||
DEFAULT_SPACE_NAME = "zhenzhen"
|
||||
|
||||
|
||||
def is_local_media_url(url: str) -> bool:
|
||||
raw = (url or "").strip()
|
||||
if not raw:
|
||||
return False
|
||||
if raw.startswith("/api/media/messages/"):
|
||||
return True
|
||||
if raw.startswith("/api/media/link-cards/"):
|
||||
return True
|
||||
return bool(_LOCAL_URL_RE.match(raw))
|
||||
|
||||
|
||||
def is_douyin_cdn_url(url: str) -> bool:
|
||||
raw = (url or "").strip().lower()
|
||||
if not raw.startswith("http"):
|
||||
return False
|
||||
return any(
|
||||
host in raw
|
||||
for host in (
|
||||
"douyinpic.com",
|
||||
"byteimg.com",
|
||||
"ibyteimg.com",
|
||||
"douyinstatic.com",
|
||||
"snssdk.com",
|
||||
"vodupload.com",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def parse_local_message_media_path(url: str) -> tuple[int, str] | None:
|
||||
"""从 /api/media/messages/{account_id}/{filename} 解析 account_id 与文件名。"""
|
||||
raw = (url or "").strip()
|
||||
m = re.search(r"/api/media/messages/(\d+)/([^/?#]+)", raw)
|
||||
if not m:
|
||||
return None
|
||||
return int(m.group(1)), m.group(2)
|
||||
|
||||
|
||||
def parse_link_card_media_path(url: str) -> tuple[int, str] | None:
|
||||
"""从 /api/media/link-cards/{owner_id}/{filename} 解析 owner_id 与文件名。"""
|
||||
raw = (url or "").strip()
|
||||
m = re.search(r"/api/media/link-cards/(\d+)/([^/?#]+)", raw)
|
||||
if not m:
|
||||
return None
|
||||
return int(m.group(1)), m.group(2)
|
||||
|
||||
|
||||
|
||||
|
||||
def _requests_proxies() -> dict | None:
|
||||
try:
|
||||
from rpa_engine.runtime_config import requests_proxies
|
||||
|
||||
return requests_proxies()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _random_s() -> str:
|
||||
chars = string.digits + string.ascii_lowercase
|
||||
return "".join(random.choice(chars) for _ in range(11))
|
||||
|
||||
|
||||
def _signing_key(secret: str, date_stamp: str, region: str, service: str) -> bytes:
|
||||
k_date = hmac.new(("AWS4" + secret).encode(), date_stamp.encode(), hashlib.sha256).digest()
|
||||
k_region = hmac.new(k_date, region.encode(), hashlib.sha256).digest()
|
||||
k_service = hmac.new(k_region, service.encode(), hashlib.sha256).digest()
|
||||
return hmac.new(k_service, b"aws4_request", hashlib.sha256).digest()
|
||||
|
||||
|
||||
def _aws4_authorization(
|
||||
*,
|
||||
method: str,
|
||||
canonical_querystring: str,
|
||||
amz_date: str,
|
||||
date_stamp: str,
|
||||
session_token: str,
|
||||
access_key_id: str,
|
||||
secret_access_key: str,
|
||||
payload_hash: str | None = None,
|
||||
signed_headers: list[str] | None = None,
|
||||
service: str = VOD_SERVICE,
|
||||
region: str = VOD_REGION,
|
||||
) -> str:
|
||||
if signed_headers is None:
|
||||
signed_headers = ["x-amz-date", "x-amz-security-token"]
|
||||
if payload_hash is None:
|
||||
payload_hash = hashlib.sha256(b"").hexdigest()
|
||||
|
||||
header_lines = []
|
||||
for name in signed_headers:
|
||||
if name == "x-amz-date":
|
||||
header_lines.append(f"x-amz-date:{amz_date}\n")
|
||||
elif name == "x-amz-security-token":
|
||||
header_lines.append(f"x-amz-security-token:{session_token}\n")
|
||||
elif name == "x-amz-content-sha256":
|
||||
header_lines.append(f"x-amz-content-sha256:{payload_hash}\n")
|
||||
canonical_headers = "".join(header_lines)
|
||||
signed = ";".join(signed_headers)
|
||||
canonical_request = (
|
||||
f"{method}\n/\n{canonical_querystring}\n{canonical_headers}\n{signed}\n{payload_hash}"
|
||||
)
|
||||
credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
|
||||
string_to_sign = (
|
||||
"AWS4-HMAC-SHA256\n"
|
||||
f"{amz_date}\n"
|
||||
f"{credential_scope}\n"
|
||||
f"{hashlib.sha256(canonical_request.encode()).hexdigest()}"
|
||||
)
|
||||
signature = hmac.new(
|
||||
_signing_key(secret_access_key, date_stamp, region, service),
|
||||
string_to_sign.encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return (
|
||||
f"AWS4-HMAC-SHA256 Credential={access_key_id}/{credential_scope}, "
|
||||
f"SignedHeaders={signed}, Signature={signature}"
|
||||
)
|
||||
|
||||
|
||||
def _safe_json(resp) -> dict[str, Any]:
|
||||
text = (getattr(resp, "text", None) or "").strip()
|
||||
if not text:
|
||||
return {"error": f"空响应 (HTTP {getattr(resp, 'status_code', '?')})"}
|
||||
try:
|
||||
data = resp.json()
|
||||
return data if isinstance(data, dict) else {"error": "响应不是 JSON 对象"}
|
||||
except Exception:
|
||||
snippet = text[:200].replace("\n", " ")
|
||||
return {"error": f"非 JSON 响应 (HTTP {getattr(resp, 'status_code', '?')}): {snippet}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 第 1 步:拉取 IM 上传配置,提取 STS 凭证 + SpaceName
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _sanitize_for_log(obj: Any, depth: int = 0) -> Any:
|
||||
"""结构化脱敏:保留字段名与层级,长字符串只留前 12 位 + 长度,便于排查凭证字段。"""
|
||||
if depth > 6:
|
||||
return "…"
|
||||
if isinstance(obj, dict):
|
||||
return {str(k): _sanitize_for_log(v, depth + 1) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_sanitize_for_log(v, depth + 1) for v in obj[:3]]
|
||||
if isinstance(obj, str):
|
||||
return f"{obj[:12]}…(len={len(obj)})" if len(obj) > 24 else obj
|
||||
return obj
|
||||
|
||||
|
||||
def _find_auth_object(obj: Any, depth: int = 0) -> dict | None:
|
||||
"""找到「直接含有 STS2 会话凭证字符串」的那个 dict(即临时凭证对象)。
|
||||
|
||||
该对象里通常同时含有 AccessKeyId / SecretAccessKey / SessionToken(=STS2…)。
|
||||
关键:签名要用同级的 SecretAccessKey 字段,而**不是** STS2 令牌内部解出的
|
||||
SignedSecretAccessKey(那是服务端校验用的,拿来当签名密钥会 SignatureDoesNotMatch)。
|
||||
"""
|
||||
if depth > 8 or not isinstance(obj, (dict, list)):
|
||||
return None
|
||||
if isinstance(obj, dict):
|
||||
for value in obj.values():
|
||||
if isinstance(value, str) and value.startswith("STS2"):
|
||||
return obj
|
||||
for value in obj.values():
|
||||
found = _find_auth_object(value, depth + 1)
|
||||
if found is not None:
|
||||
return found
|
||||
else:
|
||||
for value in obj:
|
||||
found = _find_auth_object(value, depth + 1)
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def _find_sts_token(obj: Any, depth: int = 0) -> str:
|
||||
"""递归在响应里找到形如 'STS2...' 的会话凭证字符串。"""
|
||||
if depth > 8:
|
||||
return ""
|
||||
if isinstance(obj, str):
|
||||
return obj if obj.startswith("STS2") else ""
|
||||
if isinstance(obj, dict):
|
||||
for value in obj.values():
|
||||
found = _find_sts_token(value, depth + 1)
|
||||
if found:
|
||||
return found
|
||||
elif isinstance(obj, list):
|
||||
for value in obj:
|
||||
found = _find_sts_token(value, depth + 1)
|
||||
if found:
|
||||
return found
|
||||
return ""
|
||||
|
||||
|
||||
def _find_space_name(obj: Any, depth: int = 0) -> str:
|
||||
if depth > 8:
|
||||
return ""
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
if str(key).lower() in ("space_name", "spacename") and isinstance(value, str) and value:
|
||||
return value
|
||||
found = _find_space_name(value, depth + 1)
|
||||
if found:
|
||||
return found
|
||||
elif isinstance(obj, list):
|
||||
for value in obj:
|
||||
found = _find_space_name(value, depth + 1)
|
||||
if found:
|
||||
return found
|
||||
return ""
|
||||
|
||||
|
||||
def _decode_sts(sts_token: str) -> tuple[str, str]:
|
||||
"""STS2<base64(JSON)>,解出 AccessKeyID 与 SignedSecretAccessKey。"""
|
||||
try:
|
||||
b64 = sts_token[4:] if sts_token.startswith("STS2") else sts_token
|
||||
b64 += "=" * (-len(b64) % 4)
|
||||
data = json.loads(base64.b64decode(b64).decode("utf-8", "ignore"))
|
||||
ak = data.get("AccessKeyID") or data.get("AccessKeyId") or ""
|
||||
sk = data.get("SignedSecretAccessKey") or data.get("SecretAccessKey") or ""
|
||||
return ak, sk
|
||||
except Exception:
|
||||
return "", ""
|
||||
|
||||
|
||||
def _fetch_im_upload_sts(session) -> tuple[str, str, str, str]:
|
||||
"""返回 (access_key_id, secret_access_key, sts_token, space_name)。"""
|
||||
import requests
|
||||
|
||||
from .auth import DouyinAuth
|
||||
from .dy_util import (
|
||||
DEFAULT_USER_AGENT,
|
||||
generate_a_bogus,
|
||||
generate_msToken,
|
||||
generate_webid,
|
||||
splice_url,
|
||||
)
|
||||
|
||||
auth = DouyinAuth()
|
||||
auth.perepare_auth(session.cookie_header(), session.web_protect_str, session.keys_str)
|
||||
ua = session.user_agent or DEFAULT_USER_AGENT
|
||||
|
||||
params = {
|
||||
"device_platform": "webapp",
|
||||
"aid": "6383",
|
||||
"channel": "channel_pc_web",
|
||||
"update_version_code": "170400",
|
||||
"pc_client_type": "1",
|
||||
"pc_libra_divert": "Windows",
|
||||
"support_h265": "1",
|
||||
"support_dash": "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",
|
||||
"engine_name": "Blink",
|
||||
"engine_version": "120.0.0.0",
|
||||
"os_name": "Windows",
|
||||
"os_version": "10",
|
||||
"cpu_core_num": "8",
|
||||
"device_memory": "8",
|
||||
"platform": "PC",
|
||||
"downlink": "10",
|
||||
"effective_type": "4g",
|
||||
"round_trip_time": "50",
|
||||
"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, */*",
|
||||
}
|
||||
resp = requests.get(
|
||||
IM_UPLOAD_CONFIG_URL,
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=auth.cookie,
|
||||
timeout=20,
|
||||
verify=False,
|
||||
proxies=_requests_proxies(),
|
||||
)
|
||||
data = _safe_json(resp)
|
||||
if data.get("error"):
|
||||
raise RuntimeError(f"获取 IM 上传配置失败:{data['error']}")
|
||||
if data.get("status_code") not in (None, 0):
|
||||
raise RuntimeError(
|
||||
f"获取 IM 上传配置失败:status_code={data.get('status_code')} "
|
||||
f"{data.get('status_msg') or ''}"
|
||||
)
|
||||
|
||||
# 诊断:把 config/v2 响应结构(字段名保留、长字符串脱敏)打到日志,便于核对凭证字段。
|
||||
try:
|
||||
logger.info(
|
||||
"im/upload/config/v2 结构: %s",
|
||||
json.dumps(_sanitize_for_log(data), ensure_ascii=False),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 凭证块选择(决定 8003 是否发生的关键):
|
||||
# - inner_image_config / public_image_config / public_file_config 共用同一套
|
||||
# 可用于 VOD 上传的 STS 令牌,仅 space 不同;
|
||||
# - public_image_config_v2 的 token 不同,不兼容 VOD 内部上传(会报
|
||||
# "session token sequence is broken"),必须避开。
|
||||
# 空间含义:maya_review = 审核空间,图片上传后处于待审核态,作为消息发送会被拒(8003);
|
||||
# zhenzhen = 公开可发送空间,IM 图片消息应使用它。
|
||||
# 因此优先 public_image_config(zhenzhen),其凭证同样能完成 VOD 上传。
|
||||
def _block_has_sts(block: Any) -> bool:
|
||||
return isinstance(block, dict) and any(
|
||||
isinstance(v, str) and v.startswith("STS2") for v in block.values()
|
||||
)
|
||||
|
||||
auth_obj = None
|
||||
for _key in ("public_image_config", "inner_image_config"):
|
||||
if isinstance(data, dict) and _block_has_sts(data.get(_key)):
|
||||
auth_obj = data[_key]
|
||||
break
|
||||
if auth_obj is None:
|
||||
auth_obj = _find_auth_object(data)
|
||||
if not auth_obj:
|
||||
raise RuntimeError(
|
||||
"IM 上传配置响应里未找到 STS 凭证(STS2 token);可能 cookie/签名失效,请用浏览器模式重新登录"
|
||||
)
|
||||
|
||||
sts = next(
|
||||
(v for v in auth_obj.values() if isinstance(v, str) and v.startswith("STS2")),
|
||||
"",
|
||||
)
|
||||
# 优先用凭证对象同级的 AccessKeyId / SecretAccessKey(用于 SigV4 签名的真实密钥)。
|
||||
ak = (
|
||||
auth_obj.get("AccessKeyID")
|
||||
or auth_obj.get("AccessKeyId")
|
||||
or auth_obj.get("access_key_id")
|
||||
or ""
|
||||
)
|
||||
sk = (
|
||||
auth_obj.get("SecretAccessKey")
|
||||
or auth_obj.get("SecretAccesskey")
|
||||
or auth_obj.get("secret_access_key")
|
||||
or ""
|
||||
)
|
||||
# 兜底:若响应没给独立的 ak/sk,再尝试从 STS2 令牌解码(SignedSecretAccessKey 一般不可用,仅最后兜底)。
|
||||
if not ak or not sk:
|
||||
dec_ak, dec_sk = _decode_sts(sts)
|
||||
ak = ak or dec_ak
|
||||
sk = sk or dec_sk
|
||||
if not ak or not sk:
|
||||
raise RuntimeError("解析 STS 凭证失败(缺少 AccessKeyId / SecretAccessKey)")
|
||||
# space 必须取自与凭证同一块,避免凭证用 inner_image_config 而 space 误取到别处。
|
||||
space = (
|
||||
auth_obj.get("space_name")
|
||||
or auth_obj.get("SpaceName")
|
||||
or auth_obj.get("spaceName")
|
||||
or _find_space_name(data)
|
||||
or DEFAULT_SPACE_NAME
|
||||
)
|
||||
return ak, sk, sts, space
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 第 2 步:ApplyUploadInner(VOD),申请上传地址
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract_apply_inner(data: dict[str, Any]) -> tuple[str, str, str, str]:
|
||||
"""返回 (upload_host, store_uri, jwt_auth, session_key)。"""
|
||||
result = data.get("Result") or {}
|
||||
addr = result.get("InnerUploadAddress") or result.get("UploadAddress") or {}
|
||||
|
||||
nodes = addr.get("UploadNodes") or []
|
||||
if nodes:
|
||||
node = nodes[0]
|
||||
stores = node.get("StoreInfos") or []
|
||||
store = stores[0] if stores else {}
|
||||
host = node.get("UploadHost") or ""
|
||||
if not host:
|
||||
hosts = node.get("UploadHosts") or addr.get("UploadHosts") or []
|
||||
host = hosts[0] if hosts else ""
|
||||
return (
|
||||
str(host or ""),
|
||||
str(store.get("StoreUri") or ""),
|
||||
str(store.get("Auth") or ""),
|
||||
str(node.get("SessionKey") or addr.get("SessionKey") or ""),
|
||||
)
|
||||
|
||||
hosts = addr.get("UploadHosts") or []
|
||||
host = hosts[0] if hosts else ""
|
||||
stores = addr.get("StoreInfos") or []
|
||||
store = stores[0] if stores else {}
|
||||
return (
|
||||
str(host or ""),
|
||||
str(store.get("StoreUri") or ""),
|
||||
str(store.get("Auth") or ""),
|
||||
str(addr.get("SessionKey") or result.get("SessionKey") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _vod_apply_upload_inner(
|
||||
ak: str, sk: str, token: str, space: str, file_size: int
|
||||
) -> tuple[str, str, str, str]:
|
||||
import requests
|
||||
|
||||
from .dy_util import DEFAULT_USER_AGENT
|
||||
|
||||
now = datetime.datetime.utcnow()
|
||||
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
||||
date_stamp = now.strftime("%Y%m%d")
|
||||
params = {
|
||||
"Action": "ApplyUploadInner",
|
||||
"Version": "2020-11-19",
|
||||
"SpaceName": space,
|
||||
"FileType": "image",
|
||||
"IsInner": "1",
|
||||
"NeedFallback": "true",
|
||||
"FileSize": str(file_size),
|
||||
"s": _random_s(),
|
||||
}
|
||||
qs = urlencode(sorted(params.items()))
|
||||
authorization = _aws4_authorization(
|
||||
method="GET",
|
||||
canonical_querystring=qs,
|
||||
amz_date=amz_date,
|
||||
date_stamp=date_stamp,
|
||||
session_token=token,
|
||||
access_key_id=ak,
|
||||
secret_access_key=sk,
|
||||
service=VOD_SERVICE,
|
||||
)
|
||||
resp = requests.get(
|
||||
f"{VOD_HOST}?{qs}",
|
||||
headers={
|
||||
"accept": "*/*",
|
||||
"authorization": authorization,
|
||||
"user-agent": DEFAULT_USER_AGENT,
|
||||
"x-amz-date": amz_date,
|
||||
"x-amz-security-token": token,
|
||||
"Referer": "https://www.douyin.com/",
|
||||
},
|
||||
timeout=30,
|
||||
verify=False,
|
||||
proxies=_requests_proxies(),
|
||||
)
|
||||
data = _safe_json(resp)
|
||||
if data.get("error"):
|
||||
raise RuntimeError(f"申请上传地址失败:{data['error']}")
|
||||
meta = data.get("ResponseMetadata") or {}
|
||||
err = meta.get("Error")
|
||||
if err:
|
||||
raise RuntimeError(f"申请上传地址失败:{err.get('Message') or err.get('Code')}")
|
||||
return _extract_apply_inner(data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 第 3 步:上传二进制(SpaceKey JWT 鉴权)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _vod_upload_binary(
|
||||
host: str, store_uri: str, jwt_auth: str, user_id: str, raw: bytes, session=None
|
||||
) -> None:
|
||||
import requests
|
||||
|
||||
from .dy_util import DEFAULT_USER_AGENT
|
||||
|
||||
crc32 = format(zlib.crc32(raw) & 0xFFFFFFFF, "08x")
|
||||
url = f"https://{host}/upload/v1/{store_uri}"
|
||||
ua = (getattr(session, "user_agent", None) or DEFAULT_USER_AGENT)
|
||||
headers = {
|
||||
"Authorization": jwt_auth,
|
||||
"Content-CRC32": crc32,
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": 'attachment; filename="undefined"',
|
||||
"User-Agent": ua,
|
||||
}
|
||||
if user_id:
|
||||
headers["X-Storage-U"] = str(user_id)
|
||||
resp = requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
data=raw,
|
||||
timeout=60,
|
||||
verify=False,
|
||||
proxies=_requests_proxies(),
|
||||
)
|
||||
data = _safe_json(resp)
|
||||
if data.get("error"):
|
||||
raise RuntimeError(f"上传图片数据失败:{data['error']}")
|
||||
if data.get("code") not in (2000, 0, None):
|
||||
raise RuntimeError(f"上传图片数据失败:{data.get('message') or data}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 第 4 步:CommitUploadInner(VOD),确认上传
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _vod_commit_upload_inner(
|
||||
ak: str, sk: str, token: str, space: str, session_key: str
|
||||
) -> dict[str, Any]:
|
||||
import requests
|
||||
|
||||
from .dy_util import DEFAULT_USER_AGENT
|
||||
|
||||
now = datetime.datetime.utcnow()
|
||||
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
||||
date_stamp = now.strftime("%Y%m%d")
|
||||
params = {
|
||||
"Action": "CommitUploadInner",
|
||||
"Version": "2020-11-19",
|
||||
"SpaceName": space,
|
||||
}
|
||||
qs = urlencode(sorted(params.items()))
|
||||
body = json.dumps({"SessionKey": session_key, "Functions": []}, separators=(",", ":"))
|
||||
payload_hash = hashlib.sha256(body.encode()).hexdigest()
|
||||
signed_headers = ["x-amz-content-sha256", "x-amz-date", "x-amz-security-token"]
|
||||
authorization = _aws4_authorization(
|
||||
method="POST",
|
||||
canonical_querystring=qs,
|
||||
amz_date=amz_date,
|
||||
date_stamp=date_stamp,
|
||||
session_token=token,
|
||||
access_key_id=ak,
|
||||
secret_access_key=sk,
|
||||
payload_hash=payload_hash,
|
||||
signed_headers=signed_headers,
|
||||
service=VOD_SERVICE,
|
||||
)
|
||||
resp = requests.post(
|
||||
f"{VOD_HOST}?{qs}",
|
||||
data=body,
|
||||
headers={
|
||||
"accept": "*/*",
|
||||
"authorization": authorization,
|
||||
"content-type": "application/json",
|
||||
"user-agent": DEFAULT_USER_AGENT,
|
||||
"x-amz-content-sha256": payload_hash,
|
||||
"x-amz-date": amz_date,
|
||||
"x-amz-security-token": token,
|
||||
"Referer": "https://www.douyin.com/",
|
||||
},
|
||||
timeout=30,
|
||||
verify=False,
|
||||
proxies=_requests_proxies(),
|
||||
)
|
||||
data = _safe_json(resp)
|
||||
if data.get("error"):
|
||||
raise RuntimeError(f"确认上传失败:{data['error']}")
|
||||
meta = data.get("ResponseMetadata") or {}
|
||||
err = meta.get("Error")
|
||||
if err:
|
||||
raise RuntimeError(f"确认上传失败:{err.get('Message') or err.get('Code')}")
|
||||
return data.get("Result") or {}
|
||||
|
||||
|
||||
def upload_im_image(
|
||||
session,
|
||||
raw: bytes,
|
||||
*,
|
||||
filename: str = "image.jpg",
|
||||
content_type: str = "image/jpeg",
|
||||
) -> dict[str, Any]:
|
||||
"""上传图片到抖音 IM 私信图床(VOD/zhenzhen 空间)。
|
||||
|
||||
成功返回 {uri(tos-cn-o-*), url, url_list, md5};失败返回 {"error": "..."}。
|
||||
"""
|
||||
del filename, content_type # VOD 按二进制上传,文件名仅用于本地存储
|
||||
if not raw:
|
||||
return {"error": "图片为空"}
|
||||
try:
|
||||
ak, sk, token, space = _fetch_im_upload_sts(session)
|
||||
host, store_uri, jwt_auth, session_key = _vod_apply_upload_inner(
|
||||
ak, sk, token, space, len(raw)
|
||||
)
|
||||
if not host or not store_uri or not jwt_auth:
|
||||
return {"error": "申请上传地址失败:缺少 UploadHost/StoreUri/Auth"}
|
||||
|
||||
user_id = str(getattr(session, "my_uid", "") or "")
|
||||
_vod_upload_binary(host, store_uri, jwt_auth, user_id, raw, session)
|
||||
_vod_commit_upload_inner(ak, sk, token, space, session_key)
|
||||
|
||||
uri = store_uri.lstrip("/")
|
||||
out: dict[str, Any] = {"uri": uri, "md5": hashlib.md5(raw).hexdigest()}
|
||||
|
||||
from .message_content import uri_to_cdn_urls
|
||||
|
||||
urls = uri_to_cdn_urls(uri)
|
||||
if urls:
|
||||
out["url_list"] = urls
|
||||
out["url"] = urls[0]
|
||||
|
||||
logger.info("Uploaded IM image via VOD uri=%s host=%s space=%s", uri, host, space)
|
||||
return out
|
||||
except Exception as exc:
|
||||
logger.warning("upload_im_image (VOD) failed: %s", exc)
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
def prepare_image_reply_spec(spec: dict[str, Any], session, upload_dir: str) -> tuple[dict[str, Any], str]:
|
||||
"""若图片仍是本地地址,则上传到抖音 CDN 并补全 uri。返回 (spec, error)。"""
|
||||
if spec.get("type") != "image":
|
||||
return spec, ""
|
||||
|
||||
uri = str(spec.get("uri") or "").strip()
|
||||
url = str(spec.get("url") or "").strip()
|
||||
|
||||
# 已经持有抖音 CDN 的 uri,说明图片早已上传完成(且经历过风控/转码),直接复用即可。
|
||||
# 关键修复:不要因为 url 仍是本机预览地址(/api/media/...)而再次上传——
|
||||
# 重复上传会拿到一个“刚提交、尚未完成风控/转码”的新 uri,发送时常被抖音以
|
||||
# raw_check_code=1 / status_code=8003 拒绝(与是否互关无关)。
|
||||
# 发送链路(build_msg_payload)只用 uri / url_list,从不使用这个本机 url,故本机 url 无害。
|
||||
if uri and not is_local_media_url(uri):
|
||||
return spec, ""
|
||||
|
||||
if url and is_douyin_cdn_url(url) and not uri:
|
||||
return spec, ""
|
||||
|
||||
raw: bytes | None = None
|
||||
filename = "image.jpg"
|
||||
content_type = "image/jpeg"
|
||||
|
||||
if is_local_media_url(url):
|
||||
import os
|
||||
|
||||
card_parsed = parse_link_card_media_path(url)
|
||||
if card_parsed:
|
||||
# 卡片封面在 uploads/link-cards/{owner}/ 下,与消息图片目录(uploads/messages)不同。
|
||||
owner_id, fname = card_parsed
|
||||
link_cards_dir = os.path.join(os.path.dirname(upload_dir), "link-cards")
|
||||
path = os.path.join(link_cards_dir, str(owner_id), fname)
|
||||
else:
|
||||
parsed = parse_local_message_media_path(url)
|
||||
if not parsed:
|
||||
return spec, "无法解析本地图片路径"
|
||||
_, fname = parsed
|
||||
path = os.path.join(upload_dir, str(parsed[0]), fname)
|
||||
if not os.path.isfile(path):
|
||||
return spec, f"本地图片不存在:{fname}"
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
filename = fname
|
||||
if fname.lower().endswith(".png"):
|
||||
content_type = "image/png"
|
||||
elif fname.lower().endswith(".webp"):
|
||||
content_type = "image/webp"
|
||||
elif fname.lower().endswith(".gif"):
|
||||
content_type = "image/gif"
|
||||
|
||||
if raw is None:
|
||||
if url.startswith("http") and not is_douyin_cdn_url(url):
|
||||
return spec, "图片地址必须是抖音 CDN 或本地上传后的地址,外部 URL 无法用于 IM 发送"
|
||||
return spec, "缺少可上传的图片数据"
|
||||
|
||||
uploaded = upload_im_image(session, raw, filename=filename, content_type=content_type)
|
||||
if uploaded.get("error"):
|
||||
return spec, uploaded["error"]
|
||||
if not uploaded.get("uri"):
|
||||
return spec, "抖音图片上传失败:未返回 uri"
|
||||
|
||||
merged = {
|
||||
**spec,
|
||||
**uploaded,
|
||||
"type": "image",
|
||||
"text": spec.get("text") or "[图片]",
|
||||
}
|
||||
return merged, ""
|
||||
@@ -0,0 +1,746 @@
|
||||
"""私信消息内容解析、存储与展示(文本 / 图片 / 表情 / 语音 / 视频)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
MSG_TYPE_TEXT = 7
|
||||
MSG_TYPE_STICKER = 5
|
||||
MSG_TYPE_VOICE = 17
|
||||
MSG_TYPE_IMAGE = 27
|
||||
MSG_TYPE_VIDEO = 8
|
||||
MSG_TYPE_LINK_CARD = 70
|
||||
|
||||
_TYPE_LABELS = {
|
||||
"text": "文本",
|
||||
"image": "图片",
|
||||
"sticker": "表情",
|
||||
"voice": "语音",
|
||||
"video": "视频",
|
||||
"link": "链接",
|
||||
"link_card": "链接卡片",
|
||||
}
|
||||
|
||||
_PLACEHOLDER_MARKERS = {
|
||||
"[表情包]",
|
||||
"[语音]",
|
||||
"[图片]",
|
||||
"[视频]",
|
||||
"[未读消息]",
|
||||
}
|
||||
|
||||
_URI_HINT_RE = re.compile(
|
||||
r"(tos-cn|aweme-|voice/|ies-music|\.mp3|\.m4a|\.aac|\.mpeg|\.webp|\.jpeg|\.jpg|\.png|\.gif)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_AUDIO_URL_RE = re.compile(
|
||||
r"(douyin-user-audio|/audio/|sc=audio|voice/|ies-music|\.mp3|\.m4a|\.aac|\.mpeg|\.wav|\.ogg)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_VIDEO_URL_RE = re.compile(
|
||||
r"(sc=video|/video/|\.mp4|\.mov|\.webm|\.m3u8)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_MEDIA_STRING_KEYS = (
|
||||
"url",
|
||||
"uri",
|
||||
"main_url",
|
||||
"download_url",
|
||||
"remote_url",
|
||||
"encrypt_url",
|
||||
"play_url",
|
||||
"secret_url",
|
||||
"audio_url",
|
||||
"video_url",
|
||||
"cover_url",
|
||||
"local_path",
|
||||
)
|
||||
|
||||
_MEDIA_NESTED_KEYS = (
|
||||
"resource_url",
|
||||
"static_url",
|
||||
"animate_url",
|
||||
"cover_url",
|
||||
"thumb_url",
|
||||
"origin_url",
|
||||
"image",
|
||||
"picture",
|
||||
"pic",
|
||||
"sticker",
|
||||
"emoji",
|
||||
"audio",
|
||||
"voice",
|
||||
"video",
|
||||
"media",
|
||||
"large_url",
|
||||
"medium_url",
|
||||
"thumb",
|
||||
"avatar_thumb",
|
||||
"play_url",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_uri_path(raw: str) -> str:
|
||||
path = (raw or "").strip().lstrip("/")
|
||||
if path.startswith("obj/"):
|
||||
path = path[4:]
|
||||
return path
|
||||
|
||||
|
||||
def uri_to_cdn_urls(uri: str, *, prefer_voice: bool = False) -> list[str]:
|
||||
"""将抖音 IM 中的 uri / tos 路径转为可访问的 CDN URL 候选列表。"""
|
||||
raw = (uri or "").strip()
|
||||
if not raw:
|
||||
return []
|
||||
if raw.startswith("//"):
|
||||
return [f"https:{raw}"]
|
||||
if raw.startswith("http://") or raw.startswith("https://"):
|
||||
return [raw]
|
||||
|
||||
path = _normalize_uri_path(raw)
|
||||
if not path:
|
||||
return []
|
||||
|
||||
candidates: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(url: str) -> None:
|
||||
url = (url or "").strip()
|
||||
if url and url not in seen:
|
||||
seen.add(url)
|
||||
candidates.append(url)
|
||||
|
||||
lower = path.lower()
|
||||
is_voice = prefer_voice or lower.startswith("voice/") or lower.endswith((".mp3", ".m4a", ".aac"))
|
||||
is_image = (
|
||||
not is_voice
|
||||
and (
|
||||
"tos-cn-i" in lower
|
||||
or "aweme-" in lower
|
||||
or lower.endswith((".jpeg", ".jpg", ".png", ".webp", ".gif"))
|
||||
)
|
||||
)
|
||||
|
||||
if is_voice:
|
||||
for host in (
|
||||
"sf6-cdn-tos.douyinstatic.com",
|
||||
"sf3-cdn-tos.douyinstatic.com",
|
||||
"lf3-static.bytednsdoc.com",
|
||||
):
|
||||
add(f"https://{host}/obj/{path}")
|
||||
add(f"https://p3.douyinpic.com/obj/{path}")
|
||||
|
||||
if is_image or "tos-cn" in lower or "aweme" in lower:
|
||||
add(f"https://p3.douyinpic.com/obj/{path}")
|
||||
for size in ("720x720", "480x480", "300x300", "200x200", "100x100"):
|
||||
add(f"https://p3.douyinpic.com/aweme/{size}/{path}")
|
||||
add(f"https://p9-dy.byteimg.com/img/{path}")
|
||||
add(f"https://p6-dy.byteimg.com/img/{path}")
|
||||
|
||||
add(f"https://p3.douyinpic.com/obj/{path}")
|
||||
add(f"https://p3-sign.douyinpic.com/obj/{path}".replace("-sign", ""))
|
||||
return candidates
|
||||
|
||||
|
||||
def resolve_media_uri(uri: str, *, prefer_voice: bool = False) -> str:
|
||||
urls = uri_to_cdn_urls(uri, prefer_voice=prefer_voice)
|
||||
return urls[0] if urls else ""
|
||||
|
||||
|
||||
def _looks_like_audio_url(value: str) -> bool:
|
||||
raw = (value or "").strip()
|
||||
return bool(raw and _AUDIO_URL_RE.search(raw))
|
||||
|
||||
|
||||
def _looks_like_video_url(value: str) -> bool:
|
||||
raw = (value or "").strip()
|
||||
return bool(raw and _VIDEO_URL_RE.search(raw))
|
||||
|
||||
|
||||
def _infer_media_type_from_url(url: str) -> str:
|
||||
"""根据 URL 特征推断媒体类型(语音/视频/图片)。"""
|
||||
if _looks_like_audio_url(url):
|
||||
return "voice"
|
||||
if _looks_like_video_url(url):
|
||||
return "video"
|
||||
return "image"
|
||||
|
||||
|
||||
def _looks_like_media_uri(value: str) -> bool:
|
||||
raw = (value or "").strip()
|
||||
if not raw or raw.startswith("{"):
|
||||
return False
|
||||
if raw.startswith("http://") or raw.startswith("https://") or raw.startswith("//"):
|
||||
return True
|
||||
return bool(_URI_HINT_RE.search(raw))
|
||||
|
||||
|
||||
def _resolve_string_media(value: str, *, prefer_voice: bool = False) -> tuple[str, str]:
|
||||
raw = (value or "").strip()
|
||||
if not raw:
|
||||
return "", ""
|
||||
if raw.startswith("//"):
|
||||
return f"https:{raw}", raw
|
||||
if raw.startswith("http://") or raw.startswith("https://"):
|
||||
return raw, ""
|
||||
if _looks_like_media_uri(raw):
|
||||
return resolve_media_uri(raw, prefer_voice=prefer_voice), raw
|
||||
return "", ""
|
||||
|
||||
|
||||
def _valid_sticker_id(value: Any) -> str:
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
try:
|
||||
if int(value) == 0:
|
||||
return ""
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
sticker_id = str(value).strip()
|
||||
return "" if sticker_id in ("0", "null", "None") else sticker_id
|
||||
|
||||
|
||||
def _collect_media_candidates(value: Any, out: list[str], *, prefer_voice: bool = False, depth: int = 0) -> None:
|
||||
if depth > 12:
|
||||
return
|
||||
if isinstance(value, str):
|
||||
raw = value.strip()
|
||||
if raw.startswith("http://") or raw.startswith("https://") or raw.startswith("//"):
|
||||
url, _ = _resolve_string_media(raw, prefer_voice=prefer_voice)
|
||||
if url:
|
||||
out.append(url)
|
||||
return
|
||||
if isinstance(value, dict):
|
||||
for list_key in ("url_list", "urls", "urlList"):
|
||||
urls = value.get(list_key)
|
||||
if isinstance(urls, list):
|
||||
for item in urls:
|
||||
_collect_media_candidates(item, out, prefer_voice=prefer_voice, depth=depth + 1)
|
||||
for key in _MEDIA_STRING_KEYS:
|
||||
direct = value.get(key)
|
||||
if isinstance(direct, str):
|
||||
url, _ = _resolve_string_media(direct, prefer_voice=prefer_voice)
|
||||
if url:
|
||||
out.append(url)
|
||||
for nested_key in _MEDIA_NESTED_KEYS:
|
||||
_collect_media_candidates(value.get(nested_key), out, prefer_voice=prefer_voice, depth=depth + 1)
|
||||
for nested in value.values():
|
||||
if isinstance(nested, (dict, list)):
|
||||
_collect_media_candidates(nested, out, prefer_voice=prefer_voice, depth=depth + 1)
|
||||
return
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
_collect_media_candidates(item, out, prefer_voice=prefer_voice, depth=depth + 1)
|
||||
|
||||
|
||||
def _pick_http_url(value: Any, *, prefer_voice: bool = False) -> str:
|
||||
candidates: list[str] = []
|
||||
_collect_media_candidates(value, candidates, prefer_voice=prefer_voice)
|
||||
return candidates[0] if candidates else ""
|
||||
|
||||
|
||||
def _pick_media_uri(value: Any) -> str:
|
||||
if isinstance(value, str) and _looks_like_media_uri(value) and not value.strip().startswith("http"):
|
||||
return _normalize_uri_path(value)
|
||||
if isinstance(value, dict):
|
||||
for key in ("uri", "local_path", "remote_url"):
|
||||
direct = value.get(key)
|
||||
if isinstance(direct, str) and _looks_like_media_uri(direct) and not direct.strip().startswith("http"):
|
||||
return _normalize_uri_path(direct)
|
||||
for nested_key in _MEDIA_NESTED_KEYS:
|
||||
uri = _pick_media_uri(value.get(nested_key))
|
||||
if uri:
|
||||
return uri
|
||||
for nested in value.values():
|
||||
if isinstance(nested, (dict, list)):
|
||||
uri = _pick_media_uri(nested)
|
||||
if uri:
|
||||
return uri
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
uri = _pick_media_uri(item)
|
||||
if uri:
|
||||
return uri
|
||||
return ""
|
||||
|
||||
|
||||
def _coerce_message_type(value: Any, default: int = MSG_TYPE_TEXT) -> int:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _parse_content_json(content_raw: str | dict | None) -> dict[str, Any]:
|
||||
if isinstance(content_raw, dict):
|
||||
data = content_raw
|
||||
else:
|
||||
raw = str(content_raw or "").strip()
|
||||
if raw.startswith("{"):
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if isinstance(parsed, dict):
|
||||
data = parsed
|
||||
else:
|
||||
return {}
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
else:
|
||||
return {}
|
||||
|
||||
for nested_key in ("ext", "ai_ext", "extra", "payload", "data"):
|
||||
nested = data.get(nested_key)
|
||||
if isinstance(nested, str) and nested.strip().startswith("{"):
|
||||
try:
|
||||
nested_data = json.loads(nested)
|
||||
if isinstance(nested_data, dict):
|
||||
merged = {**nested_data, **data}
|
||||
data = merged
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int | None:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def format_im_message(content_raw: str | dict | None, message_type: int = MSG_TYPE_TEXT) -> dict[str, Any]:
|
||||
"""将 IM 原始 content 解析为统一结构 {type, text, url, ...}。"""
|
||||
content_json = _parse_content_json(content_raw)
|
||||
embedded_type = _coerce_message_type(
|
||||
content_json.get("message_type")
|
||||
or content_json.get("messageType")
|
||||
or content_json.get("msg_type"),
|
||||
message_type,
|
||||
)
|
||||
if embedded_type != MSG_TYPE_TEXT:
|
||||
message_type = embedded_type
|
||||
|
||||
_EMPTY_MEDIA_DEFAULTS = {
|
||||
MSG_TYPE_IMAGE: ("image", "[图片]"),
|
||||
MSG_TYPE_STICKER: ("sticker", "[表情包]"),
|
||||
MSG_TYPE_VOICE: ("voice", "[语音]"),
|
||||
MSG_TYPE_VIDEO: ("video", "[视频]"),
|
||||
MSG_TYPE_LINK_CARD: ("link_card", "[链接卡片]"),
|
||||
}
|
||||
|
||||
if not content_json:
|
||||
raw = str(content_raw or "").strip()
|
||||
if not raw:
|
||||
# 抖音相册图片(type 27)/部分语音等会以「空 content」推送,URL 不随推送下发。
|
||||
# 不能直接丢弃,否则消息「收不到」;这里按类型返回占位,URL 留空待后续补取。
|
||||
if message_type in _EMPTY_MEDIA_DEFAULTS:
|
||||
t, txt = _EMPTY_MEDIA_DEFAULTS[message_type]
|
||||
return {"type": t, "text": txt}
|
||||
return {"type": "text", "text": ""}
|
||||
if raw in _PLACEHOLDER_MARKERS:
|
||||
mapping = {
|
||||
"[图片]": "image",
|
||||
"[表情包]": "sticker",
|
||||
"[语音]": "voice",
|
||||
"[视频]": "video",
|
||||
}
|
||||
return {"type": mapping.get(raw, "text"), "text": raw}
|
||||
if message_type == MSG_TYPE_TEXT:
|
||||
return {"type": "text", "text": raw}
|
||||
content_json = {"text": raw}
|
||||
|
||||
prefer_voice = message_type == MSG_TYPE_VOICE or bool(content_json.get("audio") or content_json.get("voice"))
|
||||
url = _pick_http_url(content_json, prefer_voice=prefer_voice)
|
||||
media_uri = _pick_media_uri(content_json)
|
||||
if not url and media_uri:
|
||||
url = resolve_media_uri(media_uri, prefer_voice=prefer_voice)
|
||||
duration = _safe_int(
|
||||
content_json.get("duration")
|
||||
or content_json.get("audio_duration")
|
||||
or content_json.get("video_duration")
|
||||
)
|
||||
width = _safe_int(content_json.get("width") or content_json.get("w"))
|
||||
height = _safe_int(content_json.get("height") or content_json.get("h"))
|
||||
|
||||
def _media_payload(msg_type: str, text: str, **extra: Any) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"type": msg_type, "text": text}
|
||||
if url:
|
||||
payload["url"] = url
|
||||
elif media_uri:
|
||||
payload["uri"] = media_uri
|
||||
payload.update({k: v for k, v in extra.items() if v not in (None, "", 0)})
|
||||
return payload
|
||||
|
||||
# 语音/视频也会带 resource_url,不能仅凭该字段判为图片;优先按类型与 URL 特征识别。
|
||||
is_voice = (
|
||||
message_type == MSG_TYPE_VOICE
|
||||
or bool(content_json.get("audio") or content_json.get("voice"))
|
||||
or (url and _looks_like_audio_url(url))
|
||||
)
|
||||
is_video = (
|
||||
message_type == MSG_TYPE_VIDEO
|
||||
or bool(content_json.get("video"))
|
||||
or (url and _looks_like_video_url(url))
|
||||
)
|
||||
if is_voice and not is_video:
|
||||
return _media_payload("voice", "[语音]", duration=duration)
|
||||
if is_video:
|
||||
return _media_payload("video", "[视频]", duration=duration, width=width, height=height)
|
||||
|
||||
has_image_hint = (
|
||||
message_type == MSG_TYPE_IMAGE
|
||||
or content_json.get("image")
|
||||
or content_json.get("inline_pic")
|
||||
or (
|
||||
content_json.get("resource_url")
|
||||
and not (url and (_looks_like_audio_url(url) or _looks_like_video_url(url)))
|
||||
)
|
||||
)
|
||||
if has_image_hint:
|
||||
# 抖音相册私图(biz_tag=aweme_im)的大图 URL 是加密内容,浏览器无法直接渲染;
|
||||
# 但 content 内嵌 inline_pic(base64 WEBP 缩略图),直接转 data URI 即可显示。
|
||||
inline = content_json.get("inline_pic")
|
||||
if isinstance(inline, str) and inline.strip():
|
||||
b64 = re.sub(r"\s+", "", inline)
|
||||
data_uri = f"data:image/webp;base64,{b64}"
|
||||
payload = {"type": "image", "text": "[图片]", "url": data_uri}
|
||||
if width:
|
||||
payload["width"] = width
|
||||
if height:
|
||||
payload["height"] = height
|
||||
return payload
|
||||
return _media_payload("image", "[图片]", width=width, height=height)
|
||||
if message_type == MSG_TYPE_STICKER or content_json.get("static_url") or content_json.get("animate_url") or _valid_sticker_id(
|
||||
content_json.get("sticker_id") or content_json.get("id")
|
||||
):
|
||||
sticker_id = _valid_sticker_id(content_json.get("sticker_id") or content_json.get("id"))
|
||||
return _media_payload(
|
||||
"sticker",
|
||||
"[表情包]",
|
||||
sticker_id=sticker_id,
|
||||
name=str(content_json.get("display_name") or content_json.get("name") or ""),
|
||||
)
|
||||
|
||||
link_card = _parse_link_card_payload(content_json, message_type)
|
||||
if link_card:
|
||||
return link_card
|
||||
|
||||
rich_link = _parse_rich_text_link(content_json)
|
||||
if rich_link:
|
||||
return rich_link
|
||||
|
||||
text = (
|
||||
str(content_json.get("text") or content_json.get("content") or content_json.get("message") or "")
|
||||
).strip()
|
||||
if not text and url:
|
||||
inferred = _infer_media_type_from_url(url)
|
||||
if inferred == "voice":
|
||||
return {"type": "voice", "text": "[语音]", "url": url, "duration": duration}
|
||||
if inferred == "video":
|
||||
return {"type": "video", "text": "[视频]", "url": url, "duration": duration}
|
||||
if message_type == MSG_TYPE_IMAGE:
|
||||
return {"type": "image", "text": "[图片]", "url": url, "width": width, "height": height}
|
||||
if message_type == MSG_TYPE_STICKER:
|
||||
return {"type": "sticker", "text": "[表情包]", "url": url}
|
||||
if message_type == MSG_TYPE_VOICE:
|
||||
return {"type": "voice", "text": "[语音]", "url": url, "duration": duration}
|
||||
if message_type == MSG_TYPE_VIDEO:
|
||||
return {"type": "video", "text": "[视频]", "url": url, "duration": duration}
|
||||
|
||||
final_text = text or str(content_raw or "").strip()
|
||||
emoji = _resolve_text_emoji(final_text)
|
||||
if emoji:
|
||||
return emoji
|
||||
return {"type": "text", "text": final_text}
|
||||
|
||||
|
||||
def _parse_link_card_payload(content_json: dict[str, Any], message_type: int) -> dict[str, Any] | None:
|
||||
link_info = content_json.get("link_info")
|
||||
if not isinstance(link_info, dict):
|
||||
link_info = {}
|
||||
has_link = (
|
||||
message_type == MSG_TYPE_LINK_CARD
|
||||
or link_info
|
||||
or content_json.get("link_url")
|
||||
or content_json.get("cover_url")
|
||||
)
|
||||
if not has_link:
|
||||
return None
|
||||
title = str(content_json.get("title") or link_info.get("title") or "").strip()
|
||||
desc = str(
|
||||
content_json.get("desc")
|
||||
or content_json.get("description")
|
||||
or link_info.get("desc")
|
||||
or link_info.get("description")
|
||||
or ""
|
||||
).strip()
|
||||
url = str(
|
||||
content_json.get("link_url")
|
||||
or content_json.get("url")
|
||||
or link_info.get("url")
|
||||
or link_info.get("link_url")
|
||||
or ""
|
||||
).strip()
|
||||
cover = str(content_json.get("cover_url") or link_info.get("cover_url") or "").strip()
|
||||
text = title or desc or url or "[链接卡片]"
|
||||
payload: dict[str, Any] = {
|
||||
"type": "link_card",
|
||||
"text": text,
|
||||
"title": title,
|
||||
"desc": desc,
|
||||
}
|
||||
if url:
|
||||
payload["url"] = url
|
||||
if cover:
|
||||
payload["cover_url"] = cover
|
||||
return payload
|
||||
|
||||
|
||||
def _parse_rich_text_link(content_json: dict[str, Any]) -> dict[str, Any] | None:
|
||||
rich = content_json.get("richTextInfos")
|
||||
if not isinstance(rich, list):
|
||||
return None
|
||||
for item in rich:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
link = str(item.get("link") or item.get("url") or "").strip()
|
||||
if not link:
|
||||
continue
|
||||
text = str(item.get("text") or item.get("display_text") or link).strip()
|
||||
return {"type": "link", "text": text or link, "url": link}
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_text_emoji(text: str) -> dict[str, Any] | None:
|
||||
"""文字表情 [酷拽] 等:查标准表情表,命中则转成可显示的贴纸。"""
|
||||
stripped = (text or "").strip()
|
||||
if not stripped or not (stripped.startswith("[") and stripped.endswith("]")):
|
||||
return None
|
||||
try:
|
||||
from .emoji_pack import looks_like_emoji_token, lookup_emoji_url
|
||||
|
||||
if not looks_like_emoji_token(stripped):
|
||||
return None
|
||||
url = lookup_emoji_url(stripped)
|
||||
if url:
|
||||
return {
|
||||
"type": "sticker",
|
||||
"text": stripped,
|
||||
"url": url,
|
||||
"name": stripped.strip("[]"),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def parse_incoming_message(data: dict[str, Any]) -> str:
|
||||
"""从 IM API / WebSocket 消息 dict 提取并序列化展示内容。"""
|
||||
if not isinstance(data, dict):
|
||||
return str(data or "").strip()
|
||||
|
||||
msg_type = _coerce_message_type(
|
||||
data.get("message_type") or data.get("messageType") or data.get("msg_type"),
|
||||
MSG_TYPE_TEXT,
|
||||
)
|
||||
content_raw = (
|
||||
data.get("content")
|
||||
or data.get("message")
|
||||
or data.get("msg")
|
||||
or data.get("lastMessage")
|
||||
or data.get("last_msg")
|
||||
or data.get("preview")
|
||||
or data.get("brief")
|
||||
or ""
|
||||
)
|
||||
if isinstance(content_raw, dict):
|
||||
if not msg_type or msg_type == MSG_TYPE_TEXT:
|
||||
msg_type = _coerce_message_type(
|
||||
content_raw.get("message_type")
|
||||
or content_raw.get("messageType")
|
||||
or content_raw.get("msg_type"),
|
||||
msg_type,
|
||||
)
|
||||
parsed = format_im_message(content_raw, msg_type)
|
||||
return serialize_message_content(parsed)
|
||||
|
||||
if isinstance(content_raw, str):
|
||||
raw = content_raw.strip()
|
||||
if raw.startswith("{"):
|
||||
parsed = format_im_message(raw, msg_type)
|
||||
if parsed.get("type") != "text" or parsed.get("url") or msg_type != MSG_TYPE_TEXT:
|
||||
return serialize_message_content(parsed)
|
||||
if raw in _PLACEHOLDER_MARKERS and msg_type != MSG_TYPE_TEXT:
|
||||
parsed = format_im_message(raw, msg_type)
|
||||
return serialize_message_content(parsed)
|
||||
if msg_type != MSG_TYPE_TEXT:
|
||||
parsed = format_im_message(raw, msg_type)
|
||||
return serialize_message_content(parsed)
|
||||
return raw
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def serialize_message_content(msg: dict[str, Any]) -> str:
|
||||
"""序列化写入 message_logs / reply_content。"""
|
||||
msg_type = (msg.get("type") or "text").strip()
|
||||
if msg_type == "text":
|
||||
text = str(msg.get("text") or "").strip()
|
||||
return text
|
||||
cleaned = {k: v for k, v in msg.items() if v not in (None, "", [], {})}
|
||||
return json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def parse_stored_content(raw: str | None) -> dict[str, Any]:
|
||||
"""解析数据库中的 message_content / reply_content。"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return {"type": "text", "text": ""}
|
||||
if text.startswith("{"):
|
||||
try:
|
||||
data = json.loads(text)
|
||||
if isinstance(data, dict) and data.get("type"):
|
||||
if not data.get("url") and data.get("uri"):
|
||||
prefer_voice = data.get("type") == "voice"
|
||||
resolved = resolve_media_uri(str(data["uri"]), prefer_voice=prefer_voice)
|
||||
if resolved:
|
||||
data = {**data, "url": resolved}
|
||||
url = str(data.get("url") or "").strip()
|
||||
if url:
|
||||
inferred = _infer_media_type_from_url(url)
|
||||
current = data.get("type")
|
||||
if current == "image" and inferred in ("voice", "video"):
|
||||
data = {
|
||||
**data,
|
||||
"type": inferred,
|
||||
"text": "[语音]" if inferred == "voice" else "[视频]",
|
||||
}
|
||||
elif current not in ("voice", "video", "image", "sticker") and inferred:
|
||||
data = {**data, "type": inferred}
|
||||
return data
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if text in _PLACEHOLDER_MARKERS:
|
||||
mapping = {
|
||||
"[图片]": "image",
|
||||
"[表情包]": "sticker",
|
||||
"[语音]": "voice",
|
||||
"[视频]": "video",
|
||||
}
|
||||
return {"type": mapping.get(text, "text"), "text": text}
|
||||
return {"type": "text", "text": text}
|
||||
|
||||
|
||||
def message_preview(raw: str | None) -> str:
|
||||
"""会话列表/日志摘要。"""
|
||||
msg = parse_stored_content(raw)
|
||||
msg_type = msg.get("type") or "text"
|
||||
if msg_type == "text":
|
||||
return str(msg.get("text") or "")
|
||||
label = _TYPE_LABELS.get(msg_type, msg.get("text") or "[消息]")
|
||||
extra = str(msg.get("name") or "").strip()
|
||||
if extra and msg_type == "sticker":
|
||||
return f"[表情] {extra}"
|
||||
return str(msg.get("text") or label)
|
||||
|
||||
|
||||
def normalize_outgoing_content(
|
||||
content: str = "",
|
||||
message_type: str | None = None,
|
||||
media_url: str | None = None,
|
||||
sticker_url: str | None = None,
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
sticker_id: str | None = None,
|
||||
) -> str:
|
||||
"""构造可发送/可落库的 content 字符串。"""
|
||||
raw = (content or "").strip()
|
||||
parsed_json: dict[str, Any] | None = None
|
||||
if raw.startswith("{"):
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, dict) and data.get("type"):
|
||||
parsed_json = data
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if parsed_json:
|
||||
merged = dict(parsed_json)
|
||||
if width and not merged.get("width"):
|
||||
merged["width"] = width
|
||||
if height and not merged.get("height"):
|
||||
merged["height"] = height
|
||||
if sticker_id and not merged.get("sticker_id"):
|
||||
merged["sticker_id"] = sticker_id
|
||||
url = (media_url or sticker_url or "").strip()
|
||||
if url and not merged.get("url"):
|
||||
merged["url"] = url
|
||||
return serialize_message_content(merged)
|
||||
|
||||
explicit_type = (message_type or "").strip().lower()
|
||||
if explicit_type in ("image", "sticker", "voice", "video", "text"):
|
||||
if explicit_type == "text":
|
||||
return (content or "").strip()
|
||||
payload: dict[str, Any] = {"type": explicit_type}
|
||||
url = (media_url or sticker_url or "").strip()
|
||||
if url:
|
||||
payload["url"] = url
|
||||
if explicit_type == "sticker" and sticker_id:
|
||||
payload["sticker_id"] = sticker_id
|
||||
if width:
|
||||
payload["width"] = width
|
||||
if height:
|
||||
payload["height"] = height
|
||||
text = (content or "").strip()
|
||||
if text:
|
||||
payload["text"] = text
|
||||
elif explicit_type == "image":
|
||||
payload["text"] = "[图片]"
|
||||
elif explicit_type == "sticker":
|
||||
payload["text"] = "[表情包]"
|
||||
return serialize_message_content(payload)
|
||||
|
||||
raw = (content or "").strip()
|
||||
if raw.startswith("{"):
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, dict) and data.get("type"):
|
||||
return serialize_message_content(data)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return raw
|
||||
|
||||
|
||||
def is_media_message(raw: str | None) -> bool:
|
||||
return parse_stored_content(raw).get("type") not in (None, "text")
|
||||
|
||||
|
||||
def format_system_log_message(raw: str | None) -> str:
|
||||
"""系统诊断日志中的消息摘要。"""
|
||||
msg = parse_stored_content(raw)
|
||||
msg_type = msg.get("type") or "text"
|
||||
if msg_type == "text":
|
||||
return str(msg.get("text") or "")
|
||||
parts = [message_preview(raw)]
|
||||
url = str(msg.get("url") or "").strip()
|
||||
if url:
|
||||
parts.append(f"URL: {url}")
|
||||
duration = msg.get("duration")
|
||||
if duration:
|
||||
parts.append(f"时长: {duration}s")
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
def extract_urls_from_detail(detail: str | None) -> list[str]:
|
||||
if not detail:
|
||||
return []
|
||||
return re.findall(r"https?://[^\s\]|))\"']+", detail)
|
||||
@@ -0,0 +1,258 @@
|
||||
"""极简 protobuf wire 解码器(无第三方依赖)。
|
||||
|
||||
用于解析抖音 IM 发送私信的响应:官方 Response.proto 只建模了
|
||||
create/get_info/new_message_notify 三种 body,没有“发送消息响应”,
|
||||
导致仅凭 error_desc 为空就误判为发送成功。这里直接按 wire 格式解码,
|
||||
读取真实的 status_code / server_message_id,判定是否真的投递成功。
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def _format_status_json(status_json: dict) -> str:
|
||||
"""把内嵌 status JSON 格式化为更可读的失败原因。"""
|
||||
code = status_json.get("status_code")
|
||||
raw_check = status_json.get("raw_check_code")
|
||||
decision = status_json.get("decision_type")
|
||||
parts = [f"status_code={code}"]
|
||||
if raw_check is not None:
|
||||
parts.append(f"raw_check_code={raw_check}")
|
||||
if decision:
|
||||
parts.append(f"decision_type={decision}")
|
||||
return ";".join(parts)
|
||||
|
||||
|
||||
def _read_varint(buf: bytes, i: int) -> tuple[int, int]:
|
||||
shift = 0
|
||||
result = 0
|
||||
n = len(buf)
|
||||
while i < n:
|
||||
b = buf[i]
|
||||
i += 1
|
||||
result |= (b & 0x7F) << shift
|
||||
if not (b & 0x80):
|
||||
return result, i
|
||||
shift += 7
|
||||
if shift > 70:
|
||||
break
|
||||
raise ValueError("truncated varint")
|
||||
|
||||
|
||||
def decode_fields(buf: bytes) -> list[tuple[int, int, Any]]:
|
||||
"""返回 [(field_num, wire_type, value), ...]。
|
||||
|
||||
wire_type: 0=varint(int), 1=64bit(int), 2=length-delimited(bytes), 5=32bit(int)
|
||||
"""
|
||||
out: list[tuple[int, int, Any]] = []
|
||||
i = 0
|
||||
n = len(buf)
|
||||
while i < n:
|
||||
key, i = _read_varint(buf, i)
|
||||
field = key >> 3
|
||||
wt = key & 7
|
||||
if wt == 0:
|
||||
val, i = _read_varint(buf, i)
|
||||
out.append((field, wt, val))
|
||||
elif wt == 2:
|
||||
ln, i = _read_varint(buf, i)
|
||||
val = buf[i:i + ln]
|
||||
i += ln
|
||||
out.append((field, wt, val))
|
||||
elif wt == 5:
|
||||
val = int.from_bytes(buf[i:i + 4], "little")
|
||||
i += 4
|
||||
out.append((field, wt, val))
|
||||
elif wt == 1:
|
||||
val = int.from_bytes(buf[i:i + 8], "little")
|
||||
i += 8
|
||||
out.append((field, wt, val))
|
||||
else:
|
||||
raise ValueError(f"unsupported wire type {wt}")
|
||||
return out
|
||||
|
||||
|
||||
def _collect_big_varints(buf: bytes, acc: list[int], depth: int = 0) -> None:
|
||||
"""递归收集疑似 ID 的大整数(server_message_id / short_id 等都是大数)。"""
|
||||
if depth > 6:
|
||||
return
|
||||
try:
|
||||
fields = decode_fields(buf)
|
||||
except Exception:
|
||||
return
|
||||
for _field, wt, val in fields:
|
||||
if wt == 0 and isinstance(val, int) and val > 10 ** 12:
|
||||
acc.append(val)
|
||||
elif wt == 2 and isinstance(val, (bytes, bytearray)) and val:
|
||||
_collect_big_varints(bytes(val), acc, depth + 1)
|
||||
|
||||
|
||||
def _extract_status_json(raw: bytes) -> Optional[dict]:
|
||||
"""抖音发送响应的 body 内嵌一段 JSON:{"status_code":x,"tips":"...","status_msg":{...}}。
|
||||
|
||||
这才是“消息是否真正投递”的权威结论(status_code=0 才是真成功)。
|
||||
顶层 message=OK 只是接口层面的“已受理”,不代表已投递。
|
||||
"""
|
||||
marker = b'"status_code"'
|
||||
idx = raw.find(marker)
|
||||
if idx < 0:
|
||||
return None
|
||||
start = raw.rfind(b"{", 0, idx)
|
||||
if start < 0:
|
||||
return None
|
||||
depth = 0
|
||||
in_str = False
|
||||
esc = False
|
||||
end = -1
|
||||
for i in range(start, len(raw)):
|
||||
c = raw[i]
|
||||
if in_str:
|
||||
if esc:
|
||||
esc = False
|
||||
elif c == 0x5C: # backslash
|
||||
esc = True
|
||||
elif c == 0x22: # quote
|
||||
in_str = False
|
||||
continue
|
||||
if c == 0x22:
|
||||
in_str = True
|
||||
elif c == 0x7B: # {
|
||||
depth += 1
|
||||
elif c == 0x7D: # }
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = i + 1
|
||||
break
|
||||
if end < 0:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw[start:end].decode("utf-8", "ignore"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def analyze_send_response(raw: bytes) -> dict:
|
||||
"""分析“发送私信”的 protobuf 响应,判定是否真正投递成功。
|
||||
|
||||
顶层 Response 字段(见 Response.proto):
|
||||
1=cmd, 2=sequence_id, 3=error_desc(string), 4=message(string),
|
||||
5=inbox_type, 6=body(ResponseBody)。
|
||||
|
||||
注意:顶层没有 status_code 字段。真正“消息已写入服务端”的标志是
|
||||
body(field 6) 里带有服务端分配的 server_message_id(大整数)。
|
||||
sequence_id(field 2) 也是大整数,因此只在 body 内部查找 message_id,
|
||||
避免把 sequence_id 误当成投递成功标志。
|
||||
|
||||
返回:
|
||||
ok: 是否真正发送成功(body 内带服务端 message_id,或 message=OK 且有 body)
|
||||
cmd: 顶层 cmd
|
||||
message: 顶层 message 文本(field 4)
|
||||
error_desc: 顶层 error_desc 文本(field 3)
|
||||
server_message_id: body 内服务端消息 ID(投递成功的强信号)
|
||||
has_body: 是否带 body
|
||||
summary: 顶层字段概览 + hex 片段,便于排查
|
||||
"""
|
||||
info = {
|
||||
"ok": False,
|
||||
"cmd": None,
|
||||
"status": None,
|
||||
"status_code": None,
|
||||
"raw_check_code": None,
|
||||
"delivered_with_notice": False,
|
||||
"status_reason": "",
|
||||
"message": "",
|
||||
"error_desc": "",
|
||||
"server_message_id": None,
|
||||
"has_body": False,
|
||||
"summary": "",
|
||||
}
|
||||
if not raw:
|
||||
info["summary"] = "空响应"
|
||||
return info
|
||||
try:
|
||||
fields = decode_fields(raw)
|
||||
except Exception as e:
|
||||
info["summary"] = f"解码失败: {e}; hex={raw[:120].hex()}"
|
||||
return info
|
||||
|
||||
body = None
|
||||
parts = []
|
||||
for field, wt, val in fields:
|
||||
if field == 1 and wt == 0:
|
||||
info["cmd"] = val
|
||||
elif field == 3 and wt == 2:
|
||||
try:
|
||||
info["error_desc"] = bytes(val).decode("utf-8", "ignore")
|
||||
except Exception:
|
||||
pass
|
||||
elif field == 4 and wt == 2:
|
||||
try:
|
||||
info["message"] = bytes(val).decode("utf-8", "ignore")
|
||||
except Exception:
|
||||
pass
|
||||
elif field == 6 and wt == 2:
|
||||
body = bytes(val)
|
||||
info["has_body"] = len(body) > 0
|
||||
|
||||
if wt == 0:
|
||||
parts.append(f"{field}=int:{val}")
|
||||
elif wt == 2:
|
||||
parts.append(f"{field}=bytes[{len(val)}]")
|
||||
else:
|
||||
parts.append(f"{field}={val}")
|
||||
info["summary"] = " ".join(parts) + f" | hex={raw[:120].hex()}"
|
||||
|
||||
# 只在 body 内部查找服务端 message_id(避免误用顶层 sequence_id)
|
||||
if body:
|
||||
ids: list[int] = []
|
||||
_collect_big_varints(body, ids)
|
||||
if ids:
|
||||
info["server_message_id"] = max(ids)
|
||||
|
||||
# 权威结论:body 内嵌 JSON 的 status_code(0 才是真成功)
|
||||
status_json = _extract_status_json(raw)
|
||||
if status_json is not None:
|
||||
info["status_code"] = status_json.get("status_code")
|
||||
info["raw_check_code"] = status_json.get("raw_check_code")
|
||||
tips = (status_json.get("tips") or "").strip()
|
||||
status_msg = status_json.get("status_msg")
|
||||
msg_text = ""
|
||||
if isinstance(status_msg, dict):
|
||||
# 抖音把人类可读提示放在 status_msg.msg_content.tips
|
||||
mc = status_msg.get("msg_content")
|
||||
if isinstance(mc, dict):
|
||||
msg_text = (mc.get("tips") or mc.get("content") or "").strip()
|
||||
if not msg_text:
|
||||
msg_text = (
|
||||
status_msg.get("toast")
|
||||
or status_msg.get("content")
|
||||
or status_msg.get("msg")
|
||||
or ""
|
||||
)
|
||||
elif isinstance(status_msg, str):
|
||||
msg_text = status_msg
|
||||
info["status_reason"] = tips or msg_text or _format_status_json(status_json)
|
||||
|
||||
msg_ok = info["message"].strip().upper() == "OK"
|
||||
|
||||
# 优先用 status_code 判定:明确给了 status_code 就以它为准(0=成功,非0=另判)
|
||||
if info["status_code"] is not None:
|
||||
if info["status_code"] == 0 and not info["error_desc"]:
|
||||
info["ok"] = True
|
||||
elif info["raw_check_code"] == 0 and msg_ok and not info["error_desc"]:
|
||||
# raw_check_code=0 表示已通过抖音风控/安全校验;配合 message=OK,
|
||||
# 说明消息已实际投递。此时非零 status_code 只是“业务侧提示”
|
||||
# (如营销/陌生人限制提醒),对方仍能收到,不应判为发送失败。
|
||||
info["ok"] = True
|
||||
info["delivered_with_notice"] = True
|
||||
else:
|
||||
# raw_check_code=1(被风控拦截)或缺少 OK 标志:判为未送达
|
||||
info["ok"] = False
|
||||
else:
|
||||
# 没有内嵌 status_code 时,退回“message=OK 且 body 内有服务端 message_id”
|
||||
info["ok"] = bool(
|
||||
not info["error_desc"]
|
||||
and msg_ok
|
||||
and info["server_message_id"] is not None
|
||||
)
|
||||
return info
|
||||
@@ -0,0 +1,225 @@
|
||||
"""私信对方用户资料抓取(昵称 / 头像 / UID)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from rpa_engine.device_profiles import resolve_user_agent
|
||||
from .auth import DouyinAuth
|
||||
from .conv_util import resolve_peer_uid
|
||||
from .dy_util import (
|
||||
DEFAULT_USER_AGENT,
|
||||
generate_a_bogus,
|
||||
generate_msToken,
|
||||
generate_webid,
|
||||
splice_url,
|
||||
)
|
||||
from .protocol import _pick_avatar_url
|
||||
from .session import DouyinImSession
|
||||
|
||||
logger = logging.getLogger("douyin_im.peer_profile")
|
||||
|
||||
_profile_cache: dict[str, dict[str, str]] = {}
|
||||
_profile_cache_at: dict[str, float] = {}
|
||||
_PROFILE_SUCCESS_TTL = 6 * 3600
|
||||
_PROFILE_FAILURE_TTL = 5 * 60
|
||||
|
||||
|
||||
def _cache_key(account_id: int, peer_uid: str) -> str:
|
||||
return f"{account_id}:{peer_uid}"
|
||||
|
||||
|
||||
def _pick_str(data: dict, *keys: str) -> str:
|
||||
for key in keys:
|
||||
value = data.get(key)
|
||||
if value is not None and str(value).strip():
|
||||
return str(value).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_profile_from_payload(data: Any) -> dict[str, str]:
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
nodes = [data, data.get("user"), data.get("user_info"), data.get("data")]
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
uid = _pick_str(node, "uid", "user_id", "user_uid", "id")
|
||||
nickname = _pick_str(
|
||||
node,
|
||||
"nickname",
|
||||
"nick_name",
|
||||
"unique_id",
|
||||
"display_name",
|
||||
"name",
|
||||
)
|
||||
avatar = _pick_avatar_url(node)
|
||||
if uid or nickname or avatar:
|
||||
return {"uid": uid, "nickname": nickname, "avatar_url": avatar}
|
||||
return {}
|
||||
|
||||
|
||||
def _requests_proxies() -> dict | None:
|
||||
try:
|
||||
from rpa_engine.runtime_config import requests_proxies
|
||||
|
||||
return requests_proxies()
|
||||
except Exception:
|
||||
return 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)
|
||||
return auth, ua
|
||||
|
||||
|
||||
def is_generic_peer_name(name: str, peer_uid: str = "") -> bool:
|
||||
value = (name or "").strip()
|
||||
if not value:
|
||||
return True
|
||||
if peer_uid and value == peer_uid:
|
||||
return True
|
||||
if value.isdigit():
|
||||
return True
|
||||
if value.startswith("用户") and value[2:].isdigit():
|
||||
return True
|
||||
if value.startswith("会话") and len(value) <= 16:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def fetch_peer_profile_sync(
|
||||
session: DouyinImSession,
|
||||
peer_uid: int | str,
|
||||
account_id: int = 0,
|
||||
) -> dict[str, str]:
|
||||
uid = str(peer_uid or "").strip()
|
||||
if not uid.isdigit():
|
||||
return {}
|
||||
|
||||
cache_key = _cache_key(account_id, uid)
|
||||
cached = _profile_cache.get(cache_key)
|
||||
cached_at = _profile_cache_at.get(cache_key, 0.0)
|
||||
if cached:
|
||||
ttl = (
|
||||
_PROFILE_SUCCESS_TTL
|
||||
if cached.get("nickname") or cached.get("avatar_url")
|
||||
else _PROFILE_FAILURE_TTL
|
||||
)
|
||||
if time.time() - cached_at < ttl:
|
||||
return dict(cached)
|
||||
|
||||
result = {"uid": uid, "nickname": "", "avatar_url": ""}
|
||||
try:
|
||||
auth, ua = _build_auth(session)
|
||||
except Exception as exc:
|
||||
logger.warning(f"build auth for peer profile failed: {exc}")
|
||||
_profile_cache[cache_key] = dict(result)
|
||||
_profile_cache_at[cache_key] = time.time()
|
||||
return result
|
||||
|
||||
try:
|
||||
web_id = session.web_id or generate_webid(auth, "https://www.douyin.com/")
|
||||
if web_id and not session.web_id:
|
||||
# Reuse the homepage-derived ID for every peer on this account.
|
||||
session.web_id = str(web_id)
|
||||
except Exception as exc:
|
||||
logger.debug(f"generate webid for peer profile failed: {exc}")
|
||||
web_id = session.web_id or ""
|
||||
|
||||
headers = {
|
||||
"User-Agent": ua,
|
||||
"Referer": "https://www.douyin.com/",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
}
|
||||
base_params = {
|
||||
"device_platform": "webapp",
|
||||
"aid": "6383",
|
||||
"channel": "channel_pc_web",
|
||||
"publish_video_strategy_type": "2",
|
||||
"user_id": uid,
|
||||
"sec_user_id": "",
|
||||
"verifyFp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "",
|
||||
"fp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "",
|
||||
"webid": web_id,
|
||||
"msToken": auth.msToken or generate_msToken(),
|
||||
}
|
||||
endpoints = [
|
||||
"https://www.douyin.com/aweme/v1/web/user/profile/other/",
|
||||
"https://www.douyin.com/aweme/v1/web/im/user/info/",
|
||||
]
|
||||
|
||||
proxies = _requests_proxies()
|
||||
for url in endpoints:
|
||||
try:
|
||||
params = dict(base_params)
|
||||
query = splice_url(params)
|
||||
params["a_bogus"] = generate_a_bogus(query, user_agent=ua)
|
||||
resp = requests.get(
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=auth.cookie,
|
||||
verify=False,
|
||||
timeout=12,
|
||||
proxies=proxies,
|
||||
)
|
||||
data = resp.json()
|
||||
extracted = _extract_profile_from_payload(data)
|
||||
if extracted.get("uid") and not result["uid"]:
|
||||
result["uid"] = extracted["uid"]
|
||||
if extracted.get("nickname") and not result["nickname"]:
|
||||
result["nickname"] = extracted["nickname"]
|
||||
if extracted.get("avatar_url") and not result["avatar_url"]:
|
||||
result["avatar_url"] = extracted["avatar_url"]
|
||||
if result["nickname"] and result["avatar_url"]:
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.debug(f"peer profile fetch failed for {url}: {exc}")
|
||||
|
||||
# Cache both success and failure. Without a short negative TTL, missing or
|
||||
# rate-limited profiles were fetched again for every account poll.
|
||||
_profile_cache[cache_key] = dict(result)
|
||||
_profile_cache_at[cache_key] = time.time()
|
||||
return result
|
||||
|
||||
|
||||
async def fetch_peer_profile(
|
||||
session: DouyinImSession,
|
||||
peer_uid: int | str,
|
||||
account_id: int = 0,
|
||||
) -> dict[str, str]:
|
||||
from .traffic_control import get_traffic_controller
|
||||
|
||||
controller = get_traffic_controller()
|
||||
async with controller.background_slot(account_id, "peer profile"):
|
||||
return await asyncio.to_thread(
|
||||
fetch_peer_profile_sync,
|
||||
session,
|
||||
peer_uid,
|
||||
account_id,
|
||||
)
|
||||
|
||||
|
||||
def enrich_conversation_item(conv: dict, my_uid: int = 0) -> dict:
|
||||
"""补全会话项中的 peer_uid / sender_id。"""
|
||||
item = dict(conv or {})
|
||||
conv_id = str(item.get("conversation_id") or "").strip()
|
||||
peer_uid = str(item.get("peer_uid") or item.get("sender_id") or "").strip()
|
||||
if (not peer_uid or not peer_uid.isdigit()) and conv_id and my_uid:
|
||||
resolved = resolve_peer_uid(conv_id, int(my_uid))
|
||||
if resolved:
|
||||
peer_uid = str(resolved)
|
||||
if peer_uid:
|
||||
item["peer_uid"] = peer_uid
|
||||
item["sender_id"] = peer_uid
|
||||
elif conv_id:
|
||||
item["sender_id"] = conv_id
|
||||
return item
|
||||
@@ -0,0 +1,120 @@
|
||||
import json
|
||||
import random
|
||||
import uuid
|
||||
|
||||
from .static import Request_pb2 as RequestProto
|
||||
from .dy_util import (
|
||||
generate_webid,
|
||||
generate_req_sign,
|
||||
generate_millisecond,
|
||||
normalize_client_cert,
|
||||
DEFAULT_USER_AGENT,
|
||||
)
|
||||
|
||||
|
||||
def _ua_headers(auth) -> tuple[str, str]:
|
||||
ua = getattr(auth, "user_agent", None) or DEFAULT_USER_AGENT
|
||||
browser_version = ua.split("Mozilla/", 1)[-1] if "Mozilla/" in ua else ua
|
||||
return ua, browser_version
|
||||
|
||||
|
||||
class ProtoBuilder:
|
||||
@staticmethod
|
||||
def build_normal_request(auth, cmd):
|
||||
ua, browser_version = _ua_headers(auth)
|
||||
request = RequestProto.Request()
|
||||
request.cmd = cmd
|
||||
request.sequence_id = random.randint(10000, 11000)
|
||||
request.sdk_version = "1.1.3"
|
||||
request.token = auth.ticket if auth.ticket else ""
|
||||
request.refer = 3
|
||||
request.inbox_type = 0
|
||||
request.build_number = "5fa6ff1:Detached: 5fa6ff1111fd53aafc4c753505d3c93daad74d27"
|
||||
did = str(getattr(auth, "device_id", "") or "0")
|
||||
request.device_id = did
|
||||
request.device_platform = 'douyin_pc'
|
||||
request.headers['session_aid'] = '6383'
|
||||
request.headers['session_did'] = did
|
||||
request.headers['app_name'] = 'douyin_pc'
|
||||
request.headers['priority_region'] = 'cn'
|
||||
request.headers['user_agent'] = ua
|
||||
request.headers['cookie_enabled'] = 'true'
|
||||
request.headers['browser_language'] = 'zh-CN'
|
||||
request.headers['browser_platform'] = 'Win32'
|
||||
request.headers['browser_name'] = 'Mozilla'
|
||||
request.headers['browser_version'] = browser_version
|
||||
request.headers['browser_online'] = 'true'
|
||||
request.headers['screen_width'] = '1707'
|
||||
request.headers['screen_height'] = '960'
|
||||
request.headers['referer'] = ''
|
||||
request.headers['timezone_name'] = 'Etc/GMT-8'
|
||||
request.headers['deviceId'] = did
|
||||
request.headers['webid'] = generate_webid(auth)
|
||||
request.headers['fp'] = auth.cookie.get('s_v_web_id', '') if auth.cookie else ''
|
||||
request.headers['is-retry'] = '0'
|
||||
request.auth_type = 4
|
||||
request.biz = 'douyin_web'
|
||||
request.access = 'web_sdk'
|
||||
request.ts_sign = auth.ts_sign if auth.ts_sign else ""
|
||||
request.sdk_cert = normalize_client_cert(auth.client_cert or "")
|
||||
return request
|
||||
|
||||
@staticmethod
|
||||
def build_create_conversation_request(auth, toId, myId):
|
||||
request = ProtoBuilder.build_normal_request(auth, 609)
|
||||
request.body.create_conversation_v2_body.conversation_type = 1
|
||||
request.body.create_conversation_v2_body.participants.extend([int(toId), int(myId)])
|
||||
reuqest_sign = generate_req_sign({
|
||||
"sign_data": f"avatar_url=&idempotent_id=&name=&participants={toId},{myId}",
|
||||
"certType": "cookie",
|
||||
"scene": "web_protect"
|
||||
}, auth.private_key)
|
||||
request.reuqest_sign = reuqest_sign
|
||||
return request
|
||||
|
||||
@staticmethod
|
||||
def build_get_conversation_list_info_request(auth, toId, myId, conversation_short_id):
|
||||
request = ProtoBuilder.build_normal_request(auth, 610)
|
||||
request.body.get_conversation_info_list_v2_body.data.conversation_id = f"0:1:{myId}:{toId}"
|
||||
request.body.get_conversation_info_list_v2_body.data.conversation_short_id = int(conversation_short_id)
|
||||
request.body.get_conversation_info_list_v2_body.data.conversation_type = 1
|
||||
return request
|
||||
|
||||
@staticmethod
|
||||
def build_send_message_request(
|
||||
auth,
|
||||
conversation_id,
|
||||
conversation_short_id,
|
||||
ticket,
|
||||
msg_content,
|
||||
message_type: int = 7,
|
||||
):
|
||||
client_message_id = str(uuid.uuid4())
|
||||
request = ProtoBuilder.build_normal_request(auth, 100)
|
||||
request.body.send_message_body.conversation_id = conversation_id
|
||||
request.body.send_message_body.conversation_type = 1
|
||||
request.body.send_message_body.conversation_short_id = int(conversation_short_id)
|
||||
request.body.send_message_body.content = json.dumps(msg_content, ensure_ascii=False,
|
||||
separators=(',', ':'))
|
||||
request.body.send_message_body.ext.append(
|
||||
RequestProto.ExtValue(key='s:client_message_id', value=client_message_id)
|
||||
)
|
||||
request.body.send_message_body.ext.append(
|
||||
RequestProto.ExtValue(key='s:stime', value=str(generate_millisecond()))
|
||||
)
|
||||
request.body.send_message_body.ext.append(
|
||||
RequestProto.ExtValue(key='s:mentioned_users', value='')
|
||||
)
|
||||
request.body.send_message_body.message_type = int(message_type)
|
||||
request.body.send_message_body.ticket = ticket
|
||||
request.body.send_message_body.client_message_id = client_message_id
|
||||
|
||||
# 签名数据计算
|
||||
sign_data_str = f'content={json.dumps(msg_content, separators=(",", ":"), ensure_ascii=False)}' + f'&conversation_id={conversation_id}&conversation_short_id={conversation_short_id}'
|
||||
req_sign = generate_req_sign({
|
||||
"sign_data": sign_data_str,
|
||||
"certType": "cookie",
|
||||
"scene": "web_protect"
|
||||
}, auth.private_key)
|
||||
request.reuqest_sign = req_sign
|
||||
return request
|
||||
@@ -0,0 +1,518 @@
|
||||
import gzip
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from .message_content import (
|
||||
MSG_TYPE_IMAGE,
|
||||
MSG_TYPE_LINK_CARD,
|
||||
MSG_TYPE_STICKER,
|
||||
MSG_TYPE_TEXT,
|
||||
MSG_TYPE_VIDEO,
|
||||
MSG_TYPE_VOICE,
|
||||
_coerce_message_type,
|
||||
format_im_message,
|
||||
message_preview,
|
||||
parse_incoming_message,
|
||||
parse_stored_content,
|
||||
serialize_message_content,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("douyin_im.protocol")
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def _is_control_payload(content_json: Any, msg_type: int = 0) -> bool:
|
||||
"""判断是否为「会话控制/状态更新」等非聊天内容帧。
|
||||
|
||||
例如 command_type=6 的连续互动统计(consecutive_chat_data)、ext_data 元数据更新、
|
||||
message_type>=50000 的系统通知等——这些不是用户发的消息,不应记录/展示成聊天气泡,
|
||||
更不应触发自动回复。
|
||||
"""
|
||||
try:
|
||||
if msg_type and int(msg_type) >= 50000:
|
||||
return True
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if isinstance(content_json, dict) and ("command_type" in content_json or "ext_data" in content_json):
|
||||
return True
|
||||
return False
|
||||
|
||||
_WS_DEBUG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "ws_media_debug.log")
|
||||
|
||||
|
||||
def _should_emit_ws_message(
|
||||
conversation_id: str,
|
||||
msg_type: int,
|
||||
) -> bool:
|
||||
"""判断 WS 帧是否为用户聊天消息(控制帧已在 _is_control_payload 过滤)。"""
|
||||
if not conversation_id:
|
||||
return False
|
||||
try:
|
||||
if int(msg_type) >= 50000:
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _dump_ws_message(msg_type: int, conversation_id: str, content_str: str, msg: Any = None) -> None:
|
||||
"""把每条 WS 消息的全部字段落到调试文件,便于排查媒体字段结构。
|
||||
|
||||
默认关闭,仅当设置环境变量 KEFU_WS_DEBUG=1 时写盘,避免生产环境无界增长 / 泄露聊天内容。
|
||||
content 为空时(如 type=26 瘦推送)会额外打印 protobuf 其余字段,确保「接收到的全部信息」可见。
|
||||
"""
|
||||
if os.getenv("KEFU_WS_DEBUG", "") not in ("1", "true", "True"):
|
||||
return
|
||||
try:
|
||||
extra = ""
|
||||
if msg is not None:
|
||||
fields = {}
|
||||
try:
|
||||
for f, v in msg.ListFields():
|
||||
if f.name == "content":
|
||||
continue
|
||||
fields[f.name] = v
|
||||
except Exception:
|
||||
pass
|
||||
if fields:
|
||||
extra = " | fields=" + json.dumps(fields, ensure_ascii=False, default=str)
|
||||
line = (
|
||||
f"{datetime.now().isoformat()} type={msg_type} "
|
||||
f"conv={conversation_id} content={content_str}{extra}\n"
|
||||
)
|
||||
with open(_WS_DEBUG_PATH, "a", encoding="utf-8") as fh:
|
||||
fh.write(line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _walk_strings(data: Any, depth: int = 0, max_depth: int = 10):
|
||||
if depth > max_depth:
|
||||
return
|
||||
if isinstance(data, dict):
|
||||
for v in data.values():
|
||||
yield from _walk_strings(v, depth + 1, max_depth)
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
yield from _walk_strings(item, depth + 1, max_depth)
|
||||
elif isinstance(data, str) and data.strip():
|
||||
yield data.strip()
|
||||
|
||||
|
||||
def extract_json_objects(raw: bytes | str) -> list[dict]:
|
||||
"""从二进制帧中尽量提取 JSON 对象"""
|
||||
if isinstance(raw, bytes):
|
||||
for codec in ("utf-8", "latin-1"):
|
||||
try:
|
||||
text = raw.decode(codec, errors="ignore")
|
||||
break
|
||||
except Exception:
|
||||
text = ""
|
||||
else:
|
||||
text = ""
|
||||
else:
|
||||
text = raw
|
||||
|
||||
results = []
|
||||
for match in re.finditer(r"\{[^{}]{0,2000}\}", text):
|
||||
chunk = match.group(0)
|
||||
try:
|
||||
obj = json.loads(chunk)
|
||||
if isinstance(obj, dict):
|
||||
results.append(obj)
|
||||
except Exception:
|
||||
continue
|
||||
return results
|
||||
|
||||
|
||||
def parse_ws_payload(raw: bytes | str) -> list[dict]:
|
||||
"""解析 WebSocket 二进制帧,返回标准化消息 dict 列表"""
|
||||
messages = []
|
||||
|
||||
# 尝试 Protobuf 解包
|
||||
if isinstance(raw, bytes):
|
||||
try:
|
||||
from .static import Live_pb2, Response_pb2
|
||||
frame = Live_pb2.PushFrame()
|
||||
frame.ParseFromString(raw)
|
||||
if frame.payloadType == 'pb':
|
||||
response = Response_pb2.Response()
|
||||
response.ParseFromString(frame.payload)
|
||||
body = response.body
|
||||
if body.HasField("new_message_notify"):
|
||||
notify = body.new_message_notify
|
||||
if notify.HasField("message"):
|
||||
msg = notify.message
|
||||
sender = str(msg.sender)
|
||||
msg_type = msg.message_type
|
||||
conversation_id = msg.conversation_id
|
||||
content_str = msg.content
|
||||
server_message_id = str(getattr(msg, "server_message_id", "") or "")
|
||||
|
||||
_dump_ws_message(msg_type, conversation_id, content_str, msg)
|
||||
|
||||
text_content = ""
|
||||
media_msg: dict = {}
|
||||
content_json: dict = {}
|
||||
try:
|
||||
content_json = json.loads(content_str) if content_str else {}
|
||||
if not isinstance(content_json, dict):
|
||||
content_json = {}
|
||||
media_msg = format_im_message(content_json, msg_type)
|
||||
text_content = media_msg.get("text") or ""
|
||||
except Exception:
|
||||
media_msg = format_im_message(content_str or "", msg_type)
|
||||
text_content = media_msg.get("text") or content_str or ""
|
||||
|
||||
# 跳过会话控制/状态更新等非聊天内容帧(不记录、不展示、不触发自动回复)
|
||||
if _is_control_payload(content_json, msg_type):
|
||||
logger.debug(
|
||||
"Skip control WS frame: type=%s conv=%s", msg_type, conversation_id
|
||||
)
|
||||
return messages
|
||||
|
||||
if _should_emit_ws_message(conversation_id, msg_type):
|
||||
sender_uid = str(msg.sender)
|
||||
if media_msg and (
|
||||
media_msg.get("text")
|
||||
or media_msg.get("type") not in (None, "text", "")
|
||||
):
|
||||
display_content = serialize_message_content(media_msg)
|
||||
else:
|
||||
display_content = text_content or content_str
|
||||
payload = {
|
||||
"sender_name": sender_uid,
|
||||
"sender_uid": sender_uid,
|
||||
"content": display_content,
|
||||
"raw_content": content_str,
|
||||
"conversation_id": conversation_id,
|
||||
"unread_count": 1,
|
||||
"server_message_id": server_message_id,
|
||||
"message_type": msg_type,
|
||||
}
|
||||
if msg_type in (
|
||||
MSG_TYPE_IMAGE,
|
||||
MSG_TYPE_STICKER,
|
||||
MSG_TYPE_VOICE,
|
||||
MSG_TYPE_VIDEO,
|
||||
MSG_TYPE_LINK_CARD,
|
||||
):
|
||||
if not media_msg.get("url") and not media_msg.get("uri"):
|
||||
logger.warning(
|
||||
"Media WS message missing url: type=%s content=%s",
|
||||
msg_type,
|
||||
(content_str or "")[:800],
|
||||
)
|
||||
elif not media_msg.get("url"):
|
||||
logger.info(
|
||||
"Media WS message resolved via uri: type=%s uri=%s",
|
||||
msg_type,
|
||||
media_msg.get("uri"),
|
||||
)
|
||||
messages.append(payload)
|
||||
logger.info(
|
||||
"Protobuf WS message parsed: sender=%s type=%s content=%s conv=%s",
|
||||
sender,
|
||||
msg_type,
|
||||
text_content,
|
||||
conversation_id,
|
||||
)
|
||||
return messages
|
||||
except Exception as e:
|
||||
logger.debug(f"Protobuf WS parse failed: {e}")
|
||||
|
||||
if isinstance(raw, str):
|
||||
payloads = [raw.encode("utf-8", errors="ignore")]
|
||||
else:
|
||||
payloads = [raw]
|
||||
# 尝试 gzip 解压(frontier 常见)
|
||||
try:
|
||||
payloads.append(gzip.decompress(raw))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for payload in payloads:
|
||||
# 1) 直接 JSON
|
||||
if isinstance(payload, bytes):
|
||||
text = payload.decode("utf-8", errors="ignore").strip()
|
||||
else:
|
||||
text = str(payload).strip()
|
||||
if text.startswith("{") or text.startswith("["):
|
||||
try:
|
||||
data = json.loads(text)
|
||||
messages.extend(normalize_im_payload(data))
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) 嵌入 JSON
|
||||
for obj in extract_json_objects(payload):
|
||||
messages.extend(normalize_im_payload(obj))
|
||||
|
||||
# 3) 纯文本兜底
|
||||
if isinstance(payload, bytes):
|
||||
text = payload.decode("utf-8", errors="ignore")
|
||||
plain = _extract_plain_text(text)
|
||||
if plain:
|
||||
messages.append({"content": plain, "sender_name": "", "raw_content": plain, "raw": True})
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
def normalize_im_payload_from_bytes(raw: bytes) -> list[dict]:
|
||||
"""Try to extract conversation/message payloads from binary IM API responses."""
|
||||
results: list[dict] = []
|
||||
for obj in extract_json_objects(raw):
|
||||
results.extend(normalize_im_payload(obj))
|
||||
if results:
|
||||
return results
|
||||
|
||||
try:
|
||||
from .static import Response_pb2
|
||||
response = Response_pb2.Response()
|
||||
response.ParseFromString(raw)
|
||||
body = response.body
|
||||
for field in (
|
||||
"get_conversation_info_list_v2_response_body",
|
||||
"create_conversation_v2_body",
|
||||
):
|
||||
if body.HasField(field):
|
||||
conv_body = getattr(body, field)
|
||||
for conv in conv_body.conversation_info_list:
|
||||
conv_id = conv.conversation_id
|
||||
peer_uid = ""
|
||||
parts = conv_id.split(":")
|
||||
if len(parts) >= 4:
|
||||
peer_uid = parts[-1]
|
||||
label = f"用户{peer_uid[-6:]}" if peer_uid else conv_id
|
||||
results.append({
|
||||
"conversation_id": conv_id,
|
||||
"sender_name": label,
|
||||
"content": "",
|
||||
"unread_count": 0,
|
||||
"peer_uid": peer_uid,
|
||||
"conversation_short_id": str(conv.conversation_short_id),
|
||||
"ticket": conv.ticket,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return results
|
||||
|
||||
|
||||
def normalize_im_payload(data: Any, depth: int = 0) -> list[dict]:
|
||||
"""递归标准化 IM JSON 为 {sender_name, content, conversation_id, unread_count}"""
|
||||
if depth > 12:
|
||||
return []
|
||||
results = []
|
||||
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
results.extend(normalize_im_payload(item, depth + 1))
|
||||
return results
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return results
|
||||
|
||||
sender = (
|
||||
_pick_str(data, "sender_name", "senderName", "nickname", "nick_name", "userName", "peerName")
|
||||
or _pick_nested(data, ("core_info", "user_info", "peer_info"), "nick_name", "nickname", "name")
|
||||
)
|
||||
sender_avatar = _pick_avatar_url(data)
|
||||
content = _pick_message_text(data)
|
||||
msg_type = _coerce_message_type(
|
||||
data.get("message_type") or data.get("messageType") or data.get("msg_type"),
|
||||
MSG_TYPE_TEXT,
|
||||
)
|
||||
# 会话控制/状态更新帧(command_type / ext_data / 系统通知)直接忽略,不当作聊天消息
|
||||
if _is_control_payload(data, msg_type) or _is_control_payload(data.get("content"), msg_type):
|
||||
return results
|
||||
if msg_type != MSG_TYPE_TEXT or (isinstance(data.get("content"), dict)):
|
||||
parsed = parse_incoming_message(data)
|
||||
if parsed:
|
||||
content = parsed
|
||||
elif content and content.startswith("{"):
|
||||
try:
|
||||
parsed = format_im_message(json.loads(content), msg_type)
|
||||
content = serialize_message_content(parsed)
|
||||
except Exception:
|
||||
pass
|
||||
elif content in _NON_TEXT_MESSAGE_MARKERS:
|
||||
parsed = parse_stored_content(content)
|
||||
content = serialize_message_content(parsed)
|
||||
conv_id = _pick_str(
|
||||
data,
|
||||
"conversation_id",
|
||||
"conversationId",
|
||||
"conv_id",
|
||||
"cid",
|
||||
)
|
||||
unread = data.get("unread_count") or data.get("unreadCount") or data.get("unread_cnt") or 0
|
||||
try:
|
||||
unread = int(unread or 0)
|
||||
except (TypeError, ValueError):
|
||||
unread = 0
|
||||
|
||||
if content and len(content) < 500:
|
||||
from_self = data.get("is_self") or data.get("isSelf") or data.get("fromSelf") or data.get("self")
|
||||
if not from_self:
|
||||
raw_content = _extract_raw_content(data) or content
|
||||
results.append({
|
||||
"sender_name": sender or "未知用户",
|
||||
"sender_avatar": sender_avatar or None,
|
||||
"content": content,
|
||||
"raw_content": raw_content,
|
||||
"conversation_id": conv_id or "",
|
||||
"unread_count": unread,
|
||||
"message_type": msg_type,
|
||||
})
|
||||
|
||||
if sender and unread > 0 and not content:
|
||||
results.append({
|
||||
"sender_name": sender,
|
||||
"sender_avatar": sender_avatar or None,
|
||||
"content": "[未读消息]",
|
||||
"raw_content": "[未读消息]",
|
||||
"conversation_id": conv_id or "",
|
||||
"unread_count": unread,
|
||||
"message_type": msg_type,
|
||||
})
|
||||
|
||||
for key in ("conversations", "conversation_list", "data", "messages", "messagesList", "body"):
|
||||
nested = data.get(key)
|
||||
if nested is not None:
|
||||
results.extend(normalize_im_payload(nested, depth + 1))
|
||||
|
||||
for value in data.values():
|
||||
if isinstance(value, (dict, list)):
|
||||
results.extend(normalize_im_payload(value, depth + 1))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _extract_raw_content(data: dict) -> str:
|
||||
for key in ("content", "message", "msg", "lastMessage", "last_msg", "preview", "brief"):
|
||||
val = data.get(key)
|
||||
if isinstance(val, str) and val.strip():
|
||||
return val.strip()
|
||||
if isinstance(val, dict):
|
||||
return json.dumps(val, ensure_ascii=False, separators=(",", ":"))
|
||||
return ""
|
||||
|
||||
|
||||
def _pick_str(data: dict, *keys: str) -> str:
|
||||
for key in keys:
|
||||
val = data.get(key)
|
||||
if isinstance(val, str) and val.strip():
|
||||
return val.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _pick_nested(data: dict, parent_keys: tuple, *child_keys: str) -> str:
|
||||
for pk in parent_keys:
|
||||
nested = data.get(pk)
|
||||
if isinstance(nested, dict):
|
||||
val = _pick_str(nested, *child_keys)
|
||||
if val:
|
||||
return val
|
||||
return ""
|
||||
|
||||
|
||||
def _avatar_from_value(val: Any) -> str:
|
||||
if isinstance(val, str) and val.strip().startswith("http"):
|
||||
return val.strip()
|
||||
if isinstance(val, dict):
|
||||
direct = val.get("url")
|
||||
if isinstance(direct, str) and direct.startswith("http"):
|
||||
return direct.strip()
|
||||
for list_key in ("url_list", "urls"):
|
||||
urls = val.get(list_key)
|
||||
if isinstance(urls, list):
|
||||
for item in urls:
|
||||
if isinstance(item, str) and item.startswith("http"):
|
||||
return item.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _pick_avatar_url(data: dict) -> str:
|
||||
for key in ("avatar_url", "avatarUrl", "head_url", "headUrl", "avatar"):
|
||||
url = _avatar_from_value(data.get(key))
|
||||
if url:
|
||||
return url
|
||||
for thumb_key in ("avatar_thumb", "avatar_medium", "avatar_larger", "avatarThumb"):
|
||||
url = _avatar_from_value(data.get(thumb_key))
|
||||
if url:
|
||||
return url
|
||||
for parent_key in ("core_info", "user_info", "peer_info", "target_user", "conversation_core_info"):
|
||||
nested = data.get(parent_key)
|
||||
if isinstance(nested, dict):
|
||||
url = _pick_avatar_url(nested)
|
||||
if url:
|
||||
return url
|
||||
return ""
|
||||
|
||||
|
||||
def _pick_message_text(data: dict) -> str:
|
||||
for key in (
|
||||
"text",
|
||||
"content",
|
||||
"message",
|
||||
"msg",
|
||||
"lastMessage",
|
||||
"last_msg",
|
||||
"preview",
|
||||
"brief",
|
||||
):
|
||||
val = data.get(key)
|
||||
if isinstance(val, str) and val.strip():
|
||||
return val.strip()
|
||||
if isinstance(val, dict):
|
||||
inner = _pick_str(val, "text", "content", "message")
|
||||
if inner:
|
||||
return inner
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_plain_text(text: str) -> Optional[str]:
|
||||
text = (text or "").strip()
|
||||
if not text or len(text) > 200:
|
||||
return None
|
||||
if text.startswith("{") or text.startswith("["):
|
||||
return None
|
||||
# 过滤明显二进制垃圾
|
||||
printable = sum(1 for c in text if c.isprintable() or c in "\n\r\t")
|
||||
if printable / max(len(text), 1) < 0.8:
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
_NON_TEXT_MESSAGE_MARKERS = {
|
||||
"[表情包]",
|
||||
"[语音]",
|
||||
"[图片]",
|
||||
"[视频]",
|
||||
"[未读消息]",
|
||||
}
|
||||
|
||||
|
||||
def should_skip_auto_reply(content: str) -> tuple[bool, str]:
|
||||
"""判断收到的内容是否不适合触发自动回复(如纯点赞/表情互动)。"""
|
||||
from .message_content import is_media_message, message_preview
|
||||
|
||||
text = (content or "").strip()
|
||||
if not text:
|
||||
return True, "空消息"
|
||||
if is_media_message(text):
|
||||
return True, f"非文本消息({message_preview(text)})"
|
||||
if text in _NON_TEXT_MESSAGE_MARKERS:
|
||||
return True, f"非文本消息({text})"
|
||||
if re.fullmatch(r"(\[赞\])+", text):
|
||||
return True, "表情互动消息(点赞),抖音通常不允许对此类消息自动回复"
|
||||
if re.fullmatch(r"\[[^\]]+\](\[[^\]]+\])*", text) and "http" not in text:
|
||||
inner = re.sub(r"[\[\]]", "", text)
|
||||
if len(inner) <= 20 and not any(ch.isalnum() for ch in inner):
|
||||
return True, f"非文本互动消息({text})"
|
||||
return False, ""
|
||||
@@ -0,0 +1,330 @@
|
||||
"""自动回复内容解析与 IM 消息体构造(文本 / 网址 / 卡片)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Tuple
|
||||
|
||||
from .message_content import (
|
||||
MSG_TYPE_IMAGE,
|
||||
MSG_TYPE_STICKER,
|
||||
MSG_TYPE_TEXT,
|
||||
message_preview,
|
||||
parse_stored_content,
|
||||
serialize_message_content,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_reply_spec(data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
reply_type = data.get("type")
|
||||
if reply_type in ("text", "link", "card", "image", "sticker"):
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def parse_reply_messages(raw: str) -> list[dict[str, Any]]:
|
||||
"""解析规则中的 reply_content,支持单条或多条回复。"""
|
||||
raw = (raw or "").strip()
|
||||
if not raw:
|
||||
return [{"type": "text", "text": ""}]
|
||||
if raw.startswith("{") or raw.startswith("["):
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, dict) and isinstance(data.get("messages"), list):
|
||||
specs = [_normalize_reply_spec(item) for item in data["messages"]]
|
||||
specs = [item for item in specs if item]
|
||||
if specs:
|
||||
return specs
|
||||
if isinstance(data, list):
|
||||
specs = [_normalize_reply_spec(item) for item in data]
|
||||
specs = [item for item in specs if item]
|
||||
if specs:
|
||||
return specs
|
||||
if isinstance(data, dict):
|
||||
spec = _normalize_reply_spec(data)
|
||||
if spec:
|
||||
return [spec]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return [{"type": "text", "text": raw}]
|
||||
|
||||
|
||||
def parse_reply_content(raw: str) -> dict[str, Any]:
|
||||
"""解析发送/规则中的 content。纯字符串视为文本,JSON 为结构化消息。"""
|
||||
raw = (raw or "").strip()
|
||||
if raw.startswith("{"):
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, dict):
|
||||
spec = _normalize_reply_spec(data)
|
||||
if spec:
|
||||
return spec
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
parsed = parse_stored_content(raw)
|
||||
if parsed.get("type") != "text":
|
||||
return parsed
|
||||
return parse_reply_messages(raw)[0]
|
||||
|
||||
|
||||
def _format_reply_spec(spec: dict[str, Any]) -> str:
|
||||
reply_type = spec.get("type", "text")
|
||||
if reply_type in ("image", "sticker", "voice", "video"):
|
||||
return message_preview(serialize_message_content(spec))
|
||||
if reply_type == "text":
|
||||
return (spec.get("text") or "").strip()
|
||||
if reply_type == "link":
|
||||
text = (spec.get("text") or spec.get("title") or "").strip()
|
||||
url = (spec.get("url") or "").strip()
|
||||
if text and url:
|
||||
return f"{text} → {url}"
|
||||
return text or url
|
||||
if reply_type == "card":
|
||||
title = (spec.get("title") or "").strip()
|
||||
desc = (spec.get("desc") or spec.get("description") or "").strip()
|
||||
page_url = (spec.get("url") or "").strip()
|
||||
if title and page_url:
|
||||
return f"[卡片] {title} → {page_url}"
|
||||
return title or desc or page_url or "[卡片]"
|
||||
return message_preview(serialize_message_content(spec))
|
||||
|
||||
|
||||
def format_reply_display(raw: str) -> str:
|
||||
"""将 reply_content 格式化为日志/列表中的可读摘要。"""
|
||||
preview = message_preview(raw)
|
||||
if preview:
|
||||
return preview
|
||||
specs = parse_reply_messages(raw)
|
||||
parts = [_format_reply_spec(spec) for spec in specs]
|
||||
parts = [part for part in parts if part]
|
||||
if not parts:
|
||||
return (raw or "").strip()
|
||||
if len(parts) == 1:
|
||||
return parts[0]
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
def serialize_reply_messages(specs: list[dict[str, Any]]) -> str:
|
||||
cleaned = [spec for spec in specs if _normalize_reply_spec(spec)]
|
||||
if not cleaned:
|
||||
cleaned = [{"type": "text", "text": ""}]
|
||||
if len(cleaned) == 1:
|
||||
return serialize_reply_content(cleaned[0])
|
||||
return json.dumps({"messages": cleaned}, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _extract_link_card_media_path(url: str) -> str:
|
||||
value = (url or "").strip()
|
||||
idx = value.find("/api/media/link-cards/")
|
||||
return value[idx:] if idx >= 0 else ""
|
||||
|
||||
|
||||
def expand_card_spec(spec: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""卡片专属发送规则:展开为「封面图片 + 标题/内容/可点击链接文字」两条消息。
|
||||
|
||||
抖音 Web 协议无法发原生合并卡片(type=70 带图→8004/不带图→空白),因此卡片以
|
||||
「图片消息(横幅) + 文本(标题/内容/链接)」组合呈现:图片提供视觉、文本提供可点击跳转。
|
||||
图片消息仅互关用户可收(陌生人会被 8003 拦截,但文本仍可送达,不影响链接触达)。
|
||||
"""
|
||||
title = (spec.get("title") or "").strip()
|
||||
desc = (spec.get("desc") or spec.get("description") or "").strip()
|
||||
target = (
|
||||
spec.get("target_url")
|
||||
or spec.get("url")
|
||||
or spec.get("link_url")
|
||||
or ""
|
||||
).strip()
|
||||
if "localhost" in target or "127.0.0.1" in target:
|
||||
target = (spec.get("target_url") or spec.get("link_url") or "").strip() or target
|
||||
|
||||
cover = (spec.get("image_path") or "").strip()
|
||||
if not cover:
|
||||
cover = _extract_link_card_media_path(spec.get("cover_url") or "")
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
if cover:
|
||||
out.append({"type": "image", "url": cover, "text": "[图片]"})
|
||||
lines = [x for x in (title, desc, target) if x]
|
||||
text = "\n".join(lines) if lines else target
|
||||
if text:
|
||||
out.append({"type": "text", "text": text})
|
||||
if not out:
|
||||
out.append({"type": "text", "text": target})
|
||||
return out
|
||||
|
||||
|
||||
def split_reply_payloads(raw: str) -> list[str]:
|
||||
"""将规则 reply_content 拆成可逐条发送的 payload 列表。卡片单独展开为图片+文本。"""
|
||||
payloads: list[str] = []
|
||||
for spec in parse_reply_messages(raw):
|
||||
if spec.get("type") == "card":
|
||||
payloads.extend(serialize_reply_content(s) for s in expand_card_spec(spec))
|
||||
else:
|
||||
payloads.append(serialize_reply_content(spec))
|
||||
return payloads
|
||||
|
||||
|
||||
def serialize_reply_log(payloads: list[str]) -> str:
|
||||
"""把实际逐条发送的 payload(JSON 字符串)合并为结构化的日志内容。
|
||||
|
||||
单条直接返回该 payload;多条用 {"messages":[...]} 包裹,便于前端逐条渲染
|
||||
(图片/表情正常显示为媒体,而不是被压扁成 "图片" 这样的占位文本)。
|
||||
"""
|
||||
specs: list[dict[str, Any]] = []
|
||||
for payload in payloads:
|
||||
raw = (payload or "").strip()
|
||||
if not raw:
|
||||
continue
|
||||
spec: dict[str, Any] | None = None
|
||||
if raw.startswith("{"):
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
spec = _normalize_reply_spec(data)
|
||||
except json.JSONDecodeError:
|
||||
spec = None
|
||||
if spec is None:
|
||||
spec = {"type": "text", "text": raw}
|
||||
specs.append(spec)
|
||||
return serialize_reply_messages(specs)
|
||||
|
||||
|
||||
def serialize_reply_content(spec: dict[str, Any]) -> str:
|
||||
return json.dumps(spec, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def build_msg_payload(spec: dict[str, Any]) -> Tuple[dict[str, Any], int]:
|
||||
"""根据回复规格构造 IM msg_content 与 message_type。"""
|
||||
reply_type = spec.get("type", "text")
|
||||
|
||||
if reply_type == "text":
|
||||
text = (spec.get("text") or "").strip()
|
||||
return (
|
||||
{
|
||||
"mention_users": [],
|
||||
"aweType": 700,
|
||||
"richTextInfos": [],
|
||||
"text": text,
|
||||
},
|
||||
7,
|
||||
)
|
||||
|
||||
if reply_type == "link":
|
||||
display = (spec.get("text") or spec.get("title") or "").strip()
|
||||
url = (spec.get("url") or "").strip()
|
||||
if not display:
|
||||
display = url
|
||||
msg_content: dict[str, Any] = {
|
||||
"mention_users": [],
|
||||
"aweType": 700,
|
||||
"richTextInfos": [],
|
||||
"text": display,
|
||||
}
|
||||
if url and display:
|
||||
msg_content["richTextInfos"] = [
|
||||
{
|
||||
"start": 0,
|
||||
"end": len(display),
|
||||
"type": 2,
|
||||
"link": url,
|
||||
"text": display,
|
||||
}
|
||||
]
|
||||
return msg_content, 7
|
||||
|
||||
if reply_type == "card":
|
||||
# 卡片在 split_reply_payloads 阶段已展开为「图片 + 文本」两条,正常不会走到这里。
|
||||
# 兜底:万一收到未展开的卡片 spec,退化为「标题/内容/链接」文本,确保可送达。
|
||||
title = (spec.get("title") or "").strip()
|
||||
desc = (spec.get("desc") or spec.get("description") or "").strip()
|
||||
target = (
|
||||
spec.get("target_url")
|
||||
or spec.get("url")
|
||||
or spec.get("link_url")
|
||||
or ""
|
||||
).strip()
|
||||
lines = [x for x in (title, desc, target) if x]
|
||||
text = "\n".join(lines) if lines else target
|
||||
return (
|
||||
{"mention_users": [], "aweType": 700, "richTextInfos": [], "text": text},
|
||||
MSG_TYPE_TEXT,
|
||||
)
|
||||
|
||||
if reply_type == "image":
|
||||
uri = (spec.get("uri") or "").strip().lstrip("/")
|
||||
url = (spec.get("url") or "").strip()
|
||||
width = spec.get("width")
|
||||
height = spec.get("height")
|
||||
md5 = (spec.get("md5") or "").strip()
|
||||
url_list = spec.get("url_list")
|
||||
if not isinstance(url_list, list):
|
||||
url_list = [url] if url.startswith("http") else []
|
||||
|
||||
clean_urls = [str(u).strip() for u in url_list if str(u).strip().startswith("http")]
|
||||
|
||||
resource_url: dict[str, Any] = {}
|
||||
if uri:
|
||||
resource_url["uri"] = uri
|
||||
if clean_urls:
|
||||
resource_url["url_list"] = clean_urls
|
||||
if md5:
|
||||
resource_url["md5"] = md5
|
||||
if width:
|
||||
try:
|
||||
resource_url["width"] = int(width)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if height:
|
||||
try:
|
||||
resource_url["height"] = int(height)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
msg_content: dict[str, Any] = {
|
||||
"aweType": 2702,
|
||||
"from_gallery": 1,
|
||||
"create_type": 0,
|
||||
}
|
||||
if resource_url:
|
||||
msg_content["resource_url"] = resource_url
|
||||
if uri:
|
||||
msg_content["local_path"] = uri
|
||||
if md5:
|
||||
msg_content["md5"] = md5
|
||||
if width:
|
||||
try:
|
||||
msg_content["cover_width"] = int(width)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if height:
|
||||
try:
|
||||
msg_content["cover_height"] = int(height)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return msg_content, MSG_TYPE_IMAGE
|
||||
|
||||
if reply_type == "sticker":
|
||||
url = (spec.get("url") or "").strip()
|
||||
sticker_id = spec.get("sticker_id") or spec.get("id")
|
||||
msg_content = {
|
||||
"display_name": (spec.get("name") or spec.get("text") or "[表情包]").strip(),
|
||||
}
|
||||
if sticker_id:
|
||||
msg_content["id"] = sticker_id
|
||||
msg_content["sticker_id"] = sticker_id
|
||||
if url:
|
||||
msg_content["static_url"] = {"url_list": [url]}
|
||||
msg_content["animate_url"] = {"url_list": [url]}
|
||||
return msg_content, MSG_TYPE_STICKER
|
||||
|
||||
text = format_reply_display(serialize_reply_content(spec))
|
||||
return (
|
||||
{
|
||||
"mention_users": [],
|
||||
"aweType": 700,
|
||||
"richTextInfos": [],
|
||||
"text": text,
|
||||
},
|
||||
7,
|
||||
)
|
||||
@@ -0,0 +1,352 @@
|
||||
"""Observable per-account serial queue for delayed automatic replies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Awaitable, Callable, Iterable, Optional
|
||||
|
||||
|
||||
logger = logging.getLogger("douyin_im.reply_queue")
|
||||
|
||||
ReplyCallback = Callable[[], Awaitable[Any]]
|
||||
ErrorCallback = Callable[[str, BaseException], None]
|
||||
DetailsMerger = Callable[[dict[str, Any]], dict[str, Any]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _QueueItem:
|
||||
job_id: str
|
||||
due_at: float
|
||||
slot_seconds: float
|
||||
callback: ReplyCallback
|
||||
description: str
|
||||
queued_at: float
|
||||
merge_keys: frozenset[str] = field(default_factory=frozenset)
|
||||
details: dict[str, Any] = field(default_factory=dict)
|
||||
expedited: bool = False
|
||||
|
||||
|
||||
class AccountReplyQueue:
|
||||
"""Run and expose delayed reply jobs for one hosted account.
|
||||
|
||||
A single consumer is the only code path allowed to invoke callbacks. Jobs
|
||||
selected for immediate delivery are moved to an urgent FIFO, so they can
|
||||
never overlap an already active send. Removing a scheduled job also moves
|
||||
every job behind it forward by the removed job's reserved slot.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
account_id: int,
|
||||
on_error: Optional[ErrorCallback] = None,
|
||||
) -> None:
|
||||
self.account_id = account_id
|
||||
self._on_error = on_error
|
||||
self._waiting: list[_QueueItem] = []
|
||||
self._urgent: deque[_QueueItem] = deque()
|
||||
self._active_item: Optional[_QueueItem] = None
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._running = False
|
||||
self._state_lock = asyncio.Lock()
|
||||
self._wake = asyncio.Event()
|
||||
self._tail_due_at = 0.0
|
||||
|
||||
@property
|
||||
def pending_count(self) -> int:
|
||||
"""Approximate active + urgent + waiting count for lightweight badges."""
|
||||
return (
|
||||
len(self._waiting)
|
||||
+ len(self._urgent)
|
||||
+ (1 if self._active_item is not None else 0)
|
||||
)
|
||||
|
||||
async def start(self) -> None:
|
||||
async with self._state_lock:
|
||||
if self._task and not self._task.done():
|
||||
return
|
||||
self._running = True
|
||||
self._tail_due_at = 0.0
|
||||
self._wake.clear()
|
||||
self._task = asyncio.create_task(
|
||||
self._run(),
|
||||
name=f"account-reply-queue-{self.account_id}",
|
||||
)
|
||||
|
||||
async def enqueue(
|
||||
self,
|
||||
delay_seconds: float,
|
||||
callback: ReplyCallback,
|
||||
description: str = "",
|
||||
details: Optional[dict[str, Any]] = None,
|
||||
merge_key: str = "",
|
||||
merge_keys: Optional[Iterable[str]] = None,
|
||||
) -> int:
|
||||
"""Append one reply job and return its current 1-based queue position."""
|
||||
interval = max(0.0, float(delay_seconds or 0))
|
||||
loop = asyncio.get_running_loop()
|
||||
async with self._state_lock:
|
||||
if not self._running or not self._task or self._task.done():
|
||||
raise RuntimeError("reply queue is not running")
|
||||
due_at = max(loop.time(), self._tail_due_at) + interval
|
||||
self._tail_due_at = due_at
|
||||
self._waiting.append(
|
||||
_QueueItem(
|
||||
job_id=uuid.uuid4().hex,
|
||||
due_at=due_at,
|
||||
slot_seconds=interval,
|
||||
callback=callback,
|
||||
description=description,
|
||||
queued_at=time.time(),
|
||||
merge_keys=self._normalize_merge_keys(
|
||||
merge_keys if merge_keys is not None else merge_key
|
||||
),
|
||||
details=deepcopy(details or {}),
|
||||
)
|
||||
)
|
||||
position = self.pending_count
|
||||
self._wake.set()
|
||||
return position
|
||||
|
||||
@staticmethod
|
||||
def _normalize_merge_keys(value: str | Iterable[str]) -> frozenset[str]:
|
||||
values = [value] if isinstance(value, str) else list(value or [])
|
||||
return frozenset(str(item or "").strip() for item in values if str(item or "").strip())
|
||||
|
||||
@staticmethod
|
||||
def _merge_keys_match(
|
||||
existing: frozenset[str],
|
||||
incoming: frozenset[str],
|
||||
) -> bool:
|
||||
existing_conversations = {key for key in existing if key.startswith("conv:")}
|
||||
incoming_conversations = {key for key in incoming if key.startswith("conv:")}
|
||||
if existing_conversations & incoming_conversations:
|
||||
return True
|
||||
# Two explicit, different conversation IDs must never merge just because
|
||||
# their partial source data happens to expose the same peer identifier.
|
||||
if existing_conversations and incoming_conversations:
|
||||
return False
|
||||
existing_peers = {key for key in existing if key.startswith("peer:")}
|
||||
incoming_peers = {key for key in incoming if key.startswith("peer:")}
|
||||
return bool(existing_peers & incoming_peers)
|
||||
|
||||
async def merge_pending(
|
||||
self,
|
||||
merge_key: str | Iterable[str],
|
||||
details_merger: DetailsMerger,
|
||||
) -> dict[str, Any]:
|
||||
"""Merge details into one queued conversation without changing its slot.
|
||||
|
||||
Only waiting and urgent jobs are mutable. Once the consumer marks a job
|
||||
active, its callback is sealed and a later message must follow the normal
|
||||
new-message path.
|
||||
"""
|
||||
normalized_keys = self._normalize_merge_keys(merge_key)
|
||||
if not normalized_keys:
|
||||
return {"status": "not_found"}
|
||||
|
||||
async with self._state_lock:
|
||||
if not self._running or not self._task or self._task.done():
|
||||
return {"status": "not_running"}
|
||||
|
||||
active_offset = 1 if self._active_item is not None else 0
|
||||
matches: list[tuple[_QueueItem, str, int]] = []
|
||||
|
||||
for index, candidate in enumerate(self._urgent):
|
||||
if self._merge_keys_match(candidate.merge_keys, normalized_keys):
|
||||
matches.append((candidate, "ready", active_offset + index + 1))
|
||||
|
||||
waiting_offset = active_offset + len(self._urgent)
|
||||
for index, candidate in enumerate(self._waiting):
|
||||
if self._merge_keys_match(candidate.merge_keys, normalized_keys):
|
||||
matches.append((candidate, "waiting", waiting_offset + index + 1))
|
||||
|
||||
if not matches:
|
||||
return {"status": "not_found"}
|
||||
|
||||
incoming_conversations = {
|
||||
key for key in normalized_keys if key.startswith("conv:")
|
||||
}
|
||||
if not incoming_conversations:
|
||||
matched_conversations = {
|
||||
key
|
||||
for candidate, _, _ in matches
|
||||
for key in candidate.merge_keys
|
||||
if key.startswith("conv:")
|
||||
}
|
||||
if len(matched_conversations) > 1:
|
||||
return {"status": "not_found", "reason": "ambiguous_peer"}
|
||||
|
||||
item, item_status, position = matches[0]
|
||||
|
||||
merged_details = details_merger(deepcopy(item.details))
|
||||
if not isinstance(merged_details, dict):
|
||||
raise TypeError("reply queue details merger must return a dict")
|
||||
item.details = deepcopy(merged_details)
|
||||
item.merge_keys = frozenset(item.merge_keys | normalized_keys)
|
||||
return {
|
||||
"status": "merged",
|
||||
"job_id": item.job_id,
|
||||
"position": position,
|
||||
"queue_status": item_status,
|
||||
"message_count": int(item.details.get("message_count") or 1),
|
||||
}
|
||||
|
||||
async def snapshot(self) -> list[dict[str, Any]]:
|
||||
"""Return a callback-free management snapshot ordered by execution."""
|
||||
loop = asyncio.get_running_loop()
|
||||
now_mono = loop.time()
|
||||
now_epoch = time.time()
|
||||
async with self._state_lock:
|
||||
ordered: list[tuple[_QueueItem, str]] = []
|
||||
if self._active_item is not None:
|
||||
ordered.append((self._active_item, "sending"))
|
||||
ordered.extend((item, "ready") for item in self._urgent)
|
||||
ordered.extend((item, "waiting") for item in self._waiting)
|
||||
|
||||
result = []
|
||||
for position, (item, status) in enumerate(ordered, start=1):
|
||||
remaining = 0.0 if status != "waiting" else max(0.0, item.due_at - now_mono)
|
||||
scheduled_epoch = now_epoch + max(0.0, item.due_at - now_mono)
|
||||
payload = {
|
||||
"job_id": item.job_id,
|
||||
"account_id": self.account_id,
|
||||
"position": position,
|
||||
"status": status,
|
||||
"expedited": bool(item.expedited),
|
||||
"description": item.description,
|
||||
"interval_seconds": int(round(item.slot_seconds)),
|
||||
"enqueued_at": datetime.fromtimestamp(
|
||||
item.queued_at, tz=timezone.utc
|
||||
).isoformat(),
|
||||
"scheduled_at": datetime.fromtimestamp(
|
||||
scheduled_epoch, tz=timezone.utc
|
||||
).isoformat(),
|
||||
"remaining_seconds": int(max(0, round(remaining))),
|
||||
}
|
||||
# Details are controlled by DouyinImService and never contain callbacks/session data.
|
||||
payload.update(deepcopy(item.details))
|
||||
result.append(payload)
|
||||
return result
|
||||
|
||||
async def send_now(self, job_id: str) -> dict[str, Any]:
|
||||
"""Move one waiting job to the urgent FIFO and free its future slot."""
|
||||
job_id = str(job_id or "").strip()
|
||||
async with self._state_lock:
|
||||
if not self._running or not self._task or self._task.done():
|
||||
return {"status": "not_running", "job_id": job_id}
|
||||
if self._active_item and self._active_item.job_id == job_id:
|
||||
return {"status": "already_sending", "job_id": job_id}
|
||||
if any(item.job_id == job_id for item in self._urgent):
|
||||
return {"status": "already_requested", "job_id": job_id}
|
||||
|
||||
selected_index = next(
|
||||
(index for index, item in enumerate(self._waiting) if item.job_id == job_id),
|
||||
None,
|
||||
)
|
||||
if selected_index is None:
|
||||
return {"status": "not_found", "job_id": job_id}
|
||||
|
||||
item = self._waiting.pop(selected_index)
|
||||
shift_seconds = max(0.0, item.slot_seconds)
|
||||
shifted_count = 0
|
||||
for later in self._waiting[selected_index:]:
|
||||
later.due_at -= shift_seconds
|
||||
shifted_count += 1
|
||||
|
||||
item.due_at = asyncio.get_running_loop().time()
|
||||
item.expedited = True
|
||||
self._urgent.append(item)
|
||||
self._recalculate_tail_due_at()
|
||||
self._wake.set()
|
||||
return {
|
||||
"status": "accepted",
|
||||
"job_id": job_id,
|
||||
"shifted_count": shifted_count,
|
||||
}
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel the active wait/send and discard all remaining jobs."""
|
||||
async with self._state_lock:
|
||||
self._running = False
|
||||
self._wake.set()
|
||||
task = self._task
|
||||
self._task = None
|
||||
|
||||
if task:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async with self._state_lock:
|
||||
self._waiting.clear()
|
||||
self._urgent.clear()
|
||||
self._active_item = None
|
||||
self._tail_due_at = 0.0
|
||||
self._wake.clear()
|
||||
|
||||
def _recalculate_tail_due_at(self) -> None:
|
||||
scheduled = [item.due_at for item in self._waiting]
|
||||
self._tail_due_at = max(scheduled, default=0.0)
|
||||
|
||||
async def _run(self) -> None:
|
||||
while True:
|
||||
item: Optional[_QueueItem] = None
|
||||
wait_seconds: Optional[float] = None
|
||||
async with self._state_lock:
|
||||
if not self._running:
|
||||
return
|
||||
if self._urgent:
|
||||
item = self._urgent.popleft()
|
||||
elif self._waiting:
|
||||
candidate = self._waiting[0]
|
||||
remaining = candidate.due_at - asyncio.get_running_loop().time()
|
||||
if remaining <= 0:
|
||||
item = self._waiting.pop(0)
|
||||
else:
|
||||
wait_seconds = remaining
|
||||
|
||||
if item is not None:
|
||||
self._active_item = item
|
||||
self._wake.clear()
|
||||
|
||||
if item is None:
|
||||
try:
|
||||
if wait_seconds is None:
|
||||
await self._wake.wait()
|
||||
else:
|
||||
await asyncio.wait_for(self._wake.wait(), timeout=wait_seconds)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
continue
|
||||
|
||||
try:
|
||||
await item.callback()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Account %s queued reply failed (%s)",
|
||||
self.account_id,
|
||||
item.description,
|
||||
)
|
||||
if self._on_error:
|
||||
try:
|
||||
self._on_error(item.description, exc)
|
||||
except Exception:
|
||||
logger.debug("Reply queue error callback failed", exc_info=True)
|
||||
finally:
|
||||
async with self._state_lock:
|
||||
if self._active_item is item:
|
||||
self._active_item = None
|
||||
if not self._waiting:
|
||||
self._tail_due_at = 0.0
|
||||
self._wake.set()
|
||||
@@ -0,0 +1,983 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
from utils import system_logger
|
||||
from .frontier import ensure_frontier_ws
|
||||
from .http_client import DouyinImHttpClient, format_session_credential_summary
|
||||
from .session import DouyinImSession
|
||||
from .ws_client import DouyinImWsClient
|
||||
from .reply_queue import AccountReplyQueue
|
||||
from .traffic_control import get_traffic_controller
|
||||
|
||||
from .reply_payload import format_reply_display, serialize_reply_log
|
||||
from .conv_util import resolve_peer_uid
|
||||
from .peer_profile import (
|
||||
enrich_conversation_item,
|
||||
fetch_peer_profile,
|
||||
is_generic_peer_name,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("douyin_im.service")
|
||||
|
||||
MatchReplyFn = Callable[[str], Awaitable[Optional[list[str]]]]
|
||||
LogFn = Callable[..., Awaitable[None]]
|
||||
ReceivedLogFn = Callable[..., Awaitable[None]]
|
||||
|
||||
|
||||
class DouyinImService:
|
||||
"""抖音 IM 直连服务:WebSocket 实时监听 + HTTP 轮询 + 自动回复"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: DouyinImSession,
|
||||
match_reply: MatchReplyFn,
|
||||
log_fn: LogFn,
|
||||
account_id: int,
|
||||
received_log_fn: Optional[ReceivedLogFn] = None,
|
||||
reply_delay_seconds: int = 0,
|
||||
reply_delay_resolver: Optional[Callable[[], Awaitable[int]]] = None,
|
||||
reply_cooldown_seconds: Optional[int] = None,
|
||||
cooldown_resolver: Optional[Callable[[], Awaitable[int]]] = None,
|
||||
refresh_credentials: Optional[Callable[[], Awaitable[bool]]] = None,
|
||||
follow_tick: Optional[Callable[[], Awaitable[None]]] = None,
|
||||
on_session_invalid: Optional[Callable[[str], Awaitable[None]]] = None,
|
||||
):
|
||||
self.session = session
|
||||
self.match_reply = match_reply
|
||||
self.log_fn = log_fn
|
||||
self.received_log_fn = received_log_fn
|
||||
self.account_id = account_id
|
||||
# 由 worker 注入:周期性检测新粉丝并发送关注欢迎语(约每 60s 触发一次)
|
||||
self.follow_tick = follow_tick
|
||||
# 由 worker 注入:检测到 IM 登录失效(INVALID_REQUEST)时回调,用于自动下线
|
||||
self.on_session_invalid = on_session_invalid
|
||||
self._session_invalid_strikes = 0
|
||||
self._session_invalid_fired = False
|
||||
self.reply_delay_seconds = max(0, int(reply_delay_seconds or 0))
|
||||
# 实时解析账号排队间隔:账号专属优先,否则使用系统默认值。
|
||||
self._reply_delay_resolver = reply_delay_resolver
|
||||
self._reply_queue = AccountReplyQueue(
|
||||
account_id=self.account_id,
|
||||
on_error=self._on_reply_queue_error,
|
||||
)
|
||||
# WS 帧与 HTTP 轮询会并发进入;按到达顺序串行完成预处理/入队,确保 FIFO。
|
||||
self._incoming_lock = asyncio.Lock()
|
||||
# 该账号专属冷却秒数;None 表示继承全局系统设置(仅作为无 resolver 时的兜底)
|
||||
self._cooldown_override = (
|
||||
max(0, int(reply_cooldown_seconds)) if reply_cooldown_seconds is not None else None
|
||||
)
|
||||
# 实时解析冷却秒数的回调(账号专属优先,否则全局);优先于 _cooldown_override
|
||||
self._cooldown_resolver = cooldown_resolver
|
||||
# 由 worker 注入:触发后台重新采集 web_protect/keys(刷新 ts_sign),返回是否刷新成功
|
||||
self.refresh_credentials = refresh_credentials
|
||||
self._running = False
|
||||
self._replied_keys: set[str] = set()
|
||||
self._logged_keys: set[str] = set()
|
||||
self._received_logged_keys: set[str] = set()
|
||||
# 每个对话/用户最近一次自动回复的时间戳(monotonic 秒),用于冷却窗口去重
|
||||
self._last_reply_at: dict[str, float] = {}
|
||||
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._ws_client: Optional[DouyinImWsClient] = None
|
||||
self.last_error: str = ""
|
||||
|
||||
def _reply_key(self, conversation_key: str, content: str) -> str:
|
||||
return f"{conversation_key}::{content}"
|
||||
|
||||
@staticmethod
|
||||
def _reply_queue_merge_keys(
|
||||
conversation_id: str,
|
||||
peer_uid: str,
|
||||
) -> tuple[str, ...]:
|
||||
"""Return every stable identifier currently known for one conversation."""
|
||||
conversation_id = str(conversation_id or "").strip()
|
||||
peer_uid = str(peer_uid or "").strip()
|
||||
aliases: list[str] = []
|
||||
if conversation_id:
|
||||
aliases.append(f"conv:{conversation_id}")
|
||||
if peer_uid:
|
||||
aliases.append(f"peer:{peer_uid}")
|
||||
return tuple(aliases)
|
||||
|
||||
@staticmethod
|
||||
def _merge_reply_queue_details(
|
||||
existing: dict,
|
||||
*,
|
||||
incoming_content: str,
|
||||
sender_name: str,
|
||||
sender_id: str,
|
||||
sender_avatar: Optional[str],
|
||||
conversation_id: str,
|
||||
) -> dict:
|
||||
"""Append one received message while preserving the task's one reply."""
|
||||
merged = dict(existing or {})
|
||||
contents = merged.get("incoming_contents")
|
||||
if isinstance(contents, list):
|
||||
contents = list(contents)
|
||||
else:
|
||||
contents = []
|
||||
if not contents and "incoming_content" in merged:
|
||||
contents.append(str(merged.get("incoming_content") or ""))
|
||||
|
||||
latest_content = str(incoming_content or "")
|
||||
contents.append(latest_content)
|
||||
merged["incoming_content"] = latest_content
|
||||
merged["incoming_contents"] = contents
|
||||
merged["message_count"] = len(contents)
|
||||
|
||||
if sender_name:
|
||||
merged["sender_name"] = sender_name
|
||||
if sender_id:
|
||||
merged["sender_id"] = sender_id
|
||||
if sender_avatar:
|
||||
merged["sender_avatar"] = sender_avatar
|
||||
if conversation_id:
|
||||
merged["conversation_id"] = conversation_id
|
||||
return merged
|
||||
|
||||
def _cooldown_seconds_sync(self) -> int:
|
||||
"""无 resolver 时的兜底:账号专属优先,否则取全局设置;0 表示关闭。"""
|
||||
if self._cooldown_override is not None:
|
||||
return self._cooldown_override
|
||||
try:
|
||||
from auth.system_settings import get_cached_settings
|
||||
|
||||
return max(0, int(get_cached_settings().auto_reply_cooldown_seconds or 0))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
async def _resolve_cooldown_seconds(self) -> int:
|
||||
"""实时解析冷却秒数:优先用 worker 注入的 resolver(账号优先、否则全局),否则兜底。"""
|
||||
if self._cooldown_resolver is not None:
|
||||
try:
|
||||
return max(0, int(await self._cooldown_resolver() or 0))
|
||||
except Exception as e:
|
||||
logger.debug(f"cooldown resolver failed: {e}")
|
||||
return self._cooldown_seconds_sync()
|
||||
|
||||
def _reply_delay_seconds_sync(self) -> int:
|
||||
"""无 resolver 时解析排队间隔;0 表示不启用排队规则。"""
|
||||
if self.reply_delay_seconds > 0:
|
||||
return self.reply_delay_seconds
|
||||
try:
|
||||
from auth.system_settings import get_cached_settings
|
||||
|
||||
return max(0, int(get_cached_settings().auto_reply_delay_seconds or 0))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
async def _resolve_reply_delay_seconds(self) -> int:
|
||||
"""实时解析账号生效的回复排队间隔。"""
|
||||
if self._reply_delay_resolver is not None:
|
||||
try:
|
||||
return max(0, int(await self._reply_delay_resolver() or 0))
|
||||
except Exception as exc:
|
||||
logger.debug(f"reply delay resolver failed: {exc}")
|
||||
return self._reply_delay_seconds_sync()
|
||||
|
||||
def _on_reply_queue_error(self, description: str, exc: BaseException) -> None:
|
||||
system_logger.record(
|
||||
"账号回复队列执行失败",
|
||||
detail=f"{description or '自动回复任务'}:{exc}",
|
||||
level="error",
|
||||
category="send",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
|
||||
def _peer_in_cooldown(self, peer_key: str, cooldown: int) -> bool:
|
||||
if cooldown <= 0 or not peer_key:
|
||||
return False
|
||||
last = self._last_reply_at.get(peer_key)
|
||||
if last is None:
|
||||
return False
|
||||
return (time.monotonic() - last) < cooldown
|
||||
|
||||
def _resolve_sender_name(self, msg: dict) -> str:
|
||||
sender_uid = str(msg.get("sender_uid") or msg.get("sender_name") or "").strip()
|
||||
conv_id = str(msg.get("conversation_id") or "")
|
||||
name = (msg.get("sender_name") or "").strip()
|
||||
if name and not name.isdigit():
|
||||
return name
|
||||
if sender_uid and self._conv_names.get(sender_uid):
|
||||
return self._conv_names[sender_uid]
|
||||
if conv_id and self._conv_names.get(conv_id):
|
||||
return self._conv_names[conv_id]
|
||||
if sender_uid:
|
||||
return f"用户{sender_uid[-6:]}" if len(sender_uid) > 6 else f"用户{sender_uid}"
|
||||
return "未知用户"
|
||||
|
||||
def _is_self_message(self, msg: dict) -> bool:
|
||||
sender_uid = str(msg.get("sender_uid") or "").strip()
|
||||
if not sender_uid or not self.session.my_uid:
|
||||
return False
|
||||
try:
|
||||
return int(sender_uid) == int(self.session.my_uid)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
async def _resolve_peer_profile(
|
||||
self,
|
||||
conv_id: str,
|
||||
sender_uid: str,
|
||||
sender: str,
|
||||
sender_avatar: str,
|
||||
) -> tuple[str, str, str]:
|
||||
my_uid = int(self.session.my_uid or 0)
|
||||
peer_uid = str(sender_uid or "").strip()
|
||||
if (not peer_uid or not peer_uid.isdigit()) and conv_id and my_uid:
|
||||
resolved = resolve_peer_uid(conv_id, my_uid)
|
||||
if resolved:
|
||||
peer_uid = str(resolved)
|
||||
|
||||
meta = self._conv_meta.get(conv_id, {}) if conv_id else {}
|
||||
name = (sender or meta.get("sender_name") or "").strip()
|
||||
avatar = (sender_avatar or meta.get("sender_avatar") or "").strip()
|
||||
|
||||
if peer_uid and self._conv_names.get(peer_uid):
|
||||
cached_name = self._conv_names[peer_uid]
|
||||
if is_generic_peer_name(name, peer_uid):
|
||||
name = cached_name
|
||||
if conv_id and self._conv_names.get(conv_id) and is_generic_peer_name(name, peer_uid):
|
||||
name = self._conv_names[conv_id]
|
||||
|
||||
if peer_uid and (is_generic_peer_name(name, peer_uid) or not avatar):
|
||||
profile = await fetch_peer_profile(self.session, peer_uid, self.account_id)
|
||||
if profile.get("nickname"):
|
||||
name = profile["nickname"]
|
||||
self._conv_names[peer_uid] = name
|
||||
if profile.get("avatar_url"):
|
||||
avatar = profile["avatar_url"]
|
||||
if profile.get("uid"):
|
||||
peer_uid = str(profile["uid"])
|
||||
|
||||
if not name:
|
||||
name = self._resolve_sender_name(
|
||||
{"sender_uid": peer_uid, "conversation_id": conv_id, "sender_name": sender}
|
||||
)
|
||||
return name, avatar, peer_uid
|
||||
|
||||
async def _fetch_message_by_id(self, conv_id: str, server_message_id: str) -> dict | None:
|
||||
"""按 server_message_id 调 get_by_conversation 拉取该条消息的完整数据
|
||||
(含真实 content / message_type / URL)。命中返回原始消息 dict,否则 None。"""
|
||||
if not conv_id or not server_message_id:
|
||||
return None
|
||||
try:
|
||||
from .auth import DouyinAuth
|
||||
|
||||
controller = get_traffic_controller()
|
||||
async with controller.background_slot(self.account_id, "message detail fetch"):
|
||||
meta = self._conv_meta.get(conv_id, {})
|
||||
short_id = str(meta.get("conversation_short_id") or "")
|
||||
auth = DouyinAuth.from_im_session(self.session)
|
||||
my_uid = int(self.session.my_uid or 0)
|
||||
async with DouyinImHttpClient(self.session, account_id=self.account_id) as http:
|
||||
if not short_id:
|
||||
peer_uid = resolve_peer_uid(conv_id, my_uid)
|
||||
if peer_uid:
|
||||
_, short_id, _ = await http.get_conversation_info(
|
||||
auth, int(peer_uid), my_uid, conv_id, 0
|
||||
)
|
||||
if short_id:
|
||||
self._conv_meta[conv_id] = {
|
||||
**self._conv_meta.get(conv_id, {}),
|
||||
"conversation_short_id": short_id,
|
||||
}
|
||||
messages = await http.get_conversation_messages(
|
||||
auth, conv_id, int(short_id or 0), limit=20
|
||||
)
|
||||
for m in messages:
|
||||
if str(m.get("server_message_id") or "") == server_message_id:
|
||||
return m
|
||||
except Exception as e:
|
||||
logger.debug(f"_fetch_message_by_id failed: {e}")
|
||||
return None
|
||||
|
||||
async def _enrich_media_content(self, conv_id: str, server_message_id: str, content: str) -> str:
|
||||
"""媒体消息(相册图片/语音/视频)WS 推送 content 为空时,按 server_message_id
|
||||
调 get_by_conversation 拉取真实内容并补全 URL。命中失败则原样返回。"""
|
||||
if not conv_id or not server_message_id or not content:
|
||||
return content
|
||||
try:
|
||||
from .message_content import parse_stored_content, format_im_message, serialize_message_content
|
||||
|
||||
parsed = parse_stored_content(content)
|
||||
mtype = parsed.get("type")
|
||||
if mtype not in ("image", "voice", "video"):
|
||||
return content
|
||||
if parsed.get("url"):
|
||||
return content # 已有 URL(如商店表情/带 url 的图)
|
||||
|
||||
m = await self._fetch_message_by_id(conv_id, server_message_id)
|
||||
if m:
|
||||
real = format_im_message(m.get("content") or "", int(m.get("message_type") or 0))
|
||||
if real.get("url"):
|
||||
enriched = serialize_message_content(real)
|
||||
logger.info(
|
||||
"Enriched media via get_by_conversation: smid=%s type=%s",
|
||||
server_message_id, real.get("type"),
|
||||
)
|
||||
return enriched
|
||||
except Exception as e:
|
||||
logger.debug(f"_enrich_media_content failed: {e}")
|
||||
return content
|
||||
|
||||
async def _handle_incoming(self, msg: dict):
|
||||
# asyncio.Lock 按等待顺序唤醒。锁只覆盖解析、去重、规则匹配与入队;
|
||||
# 未启用排队时,真正的网络发送仍在锁外执行,保持原有并发行为。
|
||||
async with self._incoming_lock:
|
||||
immediate_reply = await self._prepare_incoming(msg)
|
||||
if immediate_reply is not None and self._running:
|
||||
await immediate_reply()
|
||||
|
||||
async def _prepare_incoming(
|
||||
self,
|
||||
msg: dict,
|
||||
) -> Optional[Callable[[], Awaitable[None]]]:
|
||||
if self._is_self_message(msg):
|
||||
return
|
||||
|
||||
conv_id = msg.get("conversation_id") or ""
|
||||
sender_uid = str(msg.get("sender_uid") or "")
|
||||
sender = self._resolve_sender_name(msg)
|
||||
sender_avatar = str(msg.get("sender_avatar") or "").strip()
|
||||
sender, sender_avatar, peer_uid = await self._resolve_peer_profile(
|
||||
conv_id, sender_uid, sender, sender_avatar
|
||||
)
|
||||
content = (msg.get("content") or "").strip()
|
||||
has_raw_ws = "raw_content" in msg
|
||||
raw_incoming = msg.get("raw_content") if has_raw_ws else None
|
||||
ws_message_type = msg.get("message_type")
|
||||
# 每条 WS 消息带唯一 server_message_id:用它去重,避免“同一用户重复发送
|
||||
# 相同文字(如多次‘你好’)被按内容去重而整条丢弃”,这是“有时收不到”的根因。
|
||||
# HTTP 轮询的会话预览没有该 ID,则退回按 内容 去重(避免对同一未读重复回复)。
|
||||
server_message_id = str(msg.get("server_message_id") or "")
|
||||
# WS 仅推送瘦消息(如 type=26)content 为空:按 server_message_id 回 HTTP 拉取
|
||||
# 完整消息,补全 content / message_type,确保「接收到的全部信息」都被记录。
|
||||
if not content and server_message_id and not (raw_incoming or "").strip():
|
||||
real = await self._fetch_message_by_id(conv_id, server_message_id)
|
||||
if real:
|
||||
real_content = (real.get("content") or "").strip()
|
||||
if real_content:
|
||||
raw_incoming = real.get("content")
|
||||
has_raw_ws = True
|
||||
real_type = real.get("message_type")
|
||||
if real_type is not None:
|
||||
ws_message_type = real_type
|
||||
try:
|
||||
from .message_content import format_im_message, serialize_message_content
|
||||
|
||||
parsed = format_im_message(real.get("content") or "", int(real_type or 0))
|
||||
content = serialize_message_content(parsed) if parsed else real_content
|
||||
except Exception:
|
||||
content = real_content
|
||||
logger.info(
|
||||
"Enriched empty WS push via get_by_conversation: smid=%s type=%s",
|
||||
server_message_id, real_type,
|
||||
)
|
||||
# 相册图片/语音等 WS 推送 content 为空,按 server_message_id 拉取真实内容补 URL
|
||||
content = await self._enrich_media_content(conv_id, server_message_id, content)
|
||||
unread = int(msg.get("unread_count") or 0)
|
||||
|
||||
if conv_id:
|
||||
self._conv_meta[conv_id] = {
|
||||
**self._conv_meta.get(conv_id, {}),
|
||||
"conversation_id": conv_id,
|
||||
"sender_name": sender,
|
||||
"sender_avatar": sender_avatar or self._conv_meta.get(conv_id, {}).get("sender_avatar"),
|
||||
"content": content or self._conv_meta.get(conv_id, {}).get("content", ""),
|
||||
"unread_count": unread,
|
||||
"peer_uid": peer_uid,
|
||||
}
|
||||
if sender and peer_uid:
|
||||
self._conv_names[peer_uid] = sender
|
||||
|
||||
if not content and unread <= 0 and raw_incoming is None and not server_message_id:
|
||||
return
|
||||
|
||||
if content == "[未读消息]" and sender in self._conv_previews:
|
||||
content = self._conv_previews.get(sender, content)
|
||||
|
||||
if server_message_id:
|
||||
log_key = f"mid:{server_message_id}"
|
||||
key = f"mid:{server_message_id}"
|
||||
else:
|
||||
# HTTP 会话预览通常没有 message_id;必须带 conversation_id/peer_uid,
|
||||
# 否则两个同名用户发送相同内容会被误判成同一条消息。
|
||||
conversation_key = str(conv_id or peer_uid or sender or "unknown")
|
||||
log_key = self._reply_key(conversation_key, content or "[未读]")
|
||||
key = self._reply_key(conversation_key, content)
|
||||
|
||||
log_kwargs = {
|
||||
"sender_name": sender,
|
||||
"sender_id": peer_uid or conv_id or None,
|
||||
"sender_avatar": sender_avatar or self._conv_meta.get(conv_id, {}).get("sender_avatar"),
|
||||
"message": content or (raw_incoming if raw_incoming is not None else ""),
|
||||
}
|
||||
|
||||
# 接收消息原始日志:WS content 原样落库(瘦推送已回 HTTP 补全为真实 content)
|
||||
if self.received_log_fn and has_raw_ws:
|
||||
recv_key = f"recv:mid:{server_message_id}" if server_message_id else f"recv:{log_key}"
|
||||
if recv_key not in self._received_logged_keys:
|
||||
self._received_logged_keys.add(recv_key)
|
||||
message_type = ws_message_type
|
||||
try:
|
||||
message_type = int(message_type) if message_type is not None else None
|
||||
except (TypeError, ValueError):
|
||||
message_type = None
|
||||
await self.received_log_fn(
|
||||
sender_name=sender,
|
||||
sender_id=peer_uid or conv_id or None,
|
||||
sender_avatar=log_kwargs.get("sender_avatar"),
|
||||
raw_content="" if raw_incoming is None else raw_incoming,
|
||||
conversation_id=conv_id or None,
|
||||
message_type=message_type,
|
||||
server_message_id=server_message_id or None,
|
||||
)
|
||||
|
||||
if log_key not in self._logged_keys and content:
|
||||
self._logged_keys.add(log_key)
|
||||
await self.log_fn(
|
||||
**log_kwargs,
|
||||
reply=None,
|
||||
status="received",
|
||||
)
|
||||
try:
|
||||
from .message_content import format_system_log_message, parse_stored_content
|
||||
|
||||
parsed = parse_stored_content(content)
|
||||
msg_type = parsed.get("type") or "text"
|
||||
detail = format_system_log_message(content)
|
||||
if server_message_id:
|
||||
detail = f"{detail} | mid={server_message_id}"
|
||||
system_logger.record(
|
||||
f"收到{'' if msg_type == 'text' else '['+msg_type+']'}消息:{sender}",
|
||||
detail=detail,
|
||||
level="info",
|
||||
category="recv",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(f"record recv system log failed: {exc}")
|
||||
|
||||
if key in self._replied_keys:
|
||||
return
|
||||
# WS 与 HTTP 轮询可能同时发现同一条消息。检查后立即占位(中间不 await),
|
||||
# 防止延迟排队期间被重复加入发送队列。
|
||||
self._replied_keys.add(key)
|
||||
|
||||
# 同账号、同会话只保留一个尚未发送的回复任务。后续来信只追加到
|
||||
# 原任务详情,不改变它的发送时间、位置或已经匹配好的回复。
|
||||
queue_merge_keys = self._reply_queue_merge_keys(conv_id, peer_uid)
|
||||
if queue_merge_keys and self._running:
|
||||
merge_result = await self._reply_queue.merge_pending(
|
||||
queue_merge_keys,
|
||||
lambda existing: self._merge_reply_queue_details(
|
||||
existing,
|
||||
incoming_content=content or "",
|
||||
sender_name=sender,
|
||||
sender_id=peer_uid or conv_id or "",
|
||||
sender_avatar=log_kwargs.get("sender_avatar"),
|
||||
conversation_id=conv_id,
|
||||
),
|
||||
)
|
||||
if merge_result.get("status") == "merged":
|
||||
if content:
|
||||
self._conv_previews[sender] = content
|
||||
message_count = int(merge_result.get("message_count") or 1)
|
||||
logger.info(
|
||||
"Merged message into queued reply for %s on account %s: "
|
||||
"job=%s messages=%s position=%s",
|
||||
sender,
|
||||
self.account_id,
|
||||
merge_result.get("job_id"),
|
||||
message_count,
|
||||
merge_result.get("position"),
|
||||
)
|
||||
system_logger.record(
|
||||
"同一会话消息已合并到回复队列",
|
||||
detail=(
|
||||
f"{sender} 的新消息已并入原任务;当前共 {message_count} 条消息,"
|
||||
"发送时间和队列位置保持不变。"
|
||||
),
|
||||
level="info",
|
||||
category="send",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
return
|
||||
|
||||
# 收到新消息即尝试自动回复,不按消息类型/托管关系/内容形态过滤
|
||||
replies = await self.match_reply(content if content != "[未读消息]" else "")
|
||||
if not replies:
|
||||
replies = await self.match_reply("")
|
||||
if not replies:
|
||||
await self.log_fn(
|
||||
**log_kwargs,
|
||||
reply=None,
|
||||
status="ignored",
|
||||
error="未配置任何自动回复规则,请在「自动回复规则」中添加至少一条启用规则",
|
||||
)
|
||||
if content:
|
||||
self._conv_previews[sender] = content
|
||||
return
|
||||
|
||||
# 冷却窗口:同一用户在设定时间内,无论发多少条消息,只自动回复一次(账号设置优先,否则全局)
|
||||
peer_key = (peer_uid or conv_id or sender or "").strip()
|
||||
cooldown = await self._resolve_cooldown_seconds()
|
||||
if self._peer_in_cooldown(peer_key, cooldown):
|
||||
logger.info(
|
||||
f"Auto-reply to {sender} skipped: within {cooldown}s cooldown window"
|
||||
)
|
||||
if content:
|
||||
self._conv_previews[sender] = content
|
||||
system_logger.record(
|
||||
"自动回复已跳过(冷却中)",
|
||||
detail=f"{sender} 在 {cooldown} 秒冷却窗口内重复发送,未重复回复",
|
||||
level="info",
|
||||
category="send",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
return
|
||||
# 提前标记回复时间,确保冷却窗口内(含延迟期间)的后续消息都被抑制
|
||||
if peer_key and cooldown > 0:
|
||||
self._last_reply_at[peer_key] = time.monotonic()
|
||||
|
||||
if content:
|
||||
self._conv_previews[sender] = content
|
||||
|
||||
delay_seconds = await self._resolve_reply_delay_seconds()
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
async def send_reply() -> None:
|
||||
await self._send_auto_reply(
|
||||
sender=sender,
|
||||
content=content,
|
||||
conv_id=conv_id,
|
||||
replies=replies,
|
||||
peer_key=peer_key,
|
||||
cooldown=cooldown,
|
||||
log_kwargs=log_kwargs,
|
||||
)
|
||||
|
||||
if delay_seconds > 0:
|
||||
position = await self._reply_queue.enqueue(
|
||||
delay_seconds,
|
||||
send_reply,
|
||||
description=f"回复 {sender}",
|
||||
details={
|
||||
"sender_name": sender,
|
||||
"sender_id": peer_uid or conv_id or None,
|
||||
"sender_avatar": log_kwargs.get("sender_avatar"),
|
||||
"conversation_id": conv_id or None,
|
||||
"incoming_content": content or "",
|
||||
"incoming_contents": [content or ""],
|
||||
"message_count": 1,
|
||||
"replies": list(replies),
|
||||
},
|
||||
merge_keys=queue_merge_keys,
|
||||
)
|
||||
logger.info(
|
||||
"Queued reply to %s for account %s: position=%s interval=%ss",
|
||||
sender,
|
||||
self.account_id,
|
||||
position,
|
||||
delay_seconds,
|
||||
)
|
||||
system_logger.record(
|
||||
"自动回复已进入账号队列",
|
||||
detail=(
|
||||
f"{sender} 当前排在第 {position} 位;账号生效间隔为 {delay_seconds} 秒,"
|
||||
"账号内计时与排位独立;到点后再进入全局带宽队列逐条投递。"
|
||||
),
|
||||
level="info",
|
||||
category="send",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
return
|
||||
|
||||
# 账号与系统均未配置排队间隔:跳过排队规则,保持原来的立即回复。
|
||||
return send_reply
|
||||
|
||||
async def _send_auto_reply(
|
||||
self,
|
||||
*,
|
||||
sender: str,
|
||||
content: str,
|
||||
conv_id: str,
|
||||
replies: list[str],
|
||||
peer_key: str,
|
||||
cooldown: int,
|
||||
log_kwargs: dict,
|
||||
) -> None:
|
||||
"""发送一项已匹配的自动回复任务,并记录原有消息/系统日志。"""
|
||||
if not self._running:
|
||||
return
|
||||
reply_displays: list[str] = []
|
||||
sent_any = False
|
||||
send_error = ""
|
||||
meta = self._conv_meta.get(conv_id, {})
|
||||
for index, reply in enumerate(replies):
|
||||
if not self._running:
|
||||
send_error = self.last_error or "托管已停止,后续回复已取消"
|
||||
break
|
||||
if index > 0:
|
||||
await asyncio.sleep(0.6)
|
||||
if not self._running:
|
||||
send_error = self.last_error or "托管已停止,后续回复已取消"
|
||||
break
|
||||
reply_display = format_reply_display(reply)
|
||||
reply_displays.append(reply_display)
|
||||
sent = False
|
||||
if conv_id:
|
||||
sent, resolved = await self._send_text(
|
||||
conv_id,
|
||||
reply,
|
||||
conversation_short_id=str(meta.get("conversation_short_id") or ""),
|
||||
)
|
||||
if sent:
|
||||
if resolved:
|
||||
meta = {**meta, **resolved, "conversation_id": conv_id}
|
||||
self._conv_meta[conv_id] = meta
|
||||
else:
|
||||
send_error = self.last_error or "IM API 发送失败"
|
||||
else:
|
||||
send_error = "缺少会话 ID,无法发送自动回复"
|
||||
if sent:
|
||||
sent_any = True
|
||||
|
||||
combined_display = " | ".join(reply_displays)
|
||||
# 日志里存结构化内容(单条直接存 payload,多条用 {"messages":[...]} 包裹),
|
||||
# 这样图片/表情等媒体回复会被前端渲染为真实媒体,而不是被压成 "图片" 占位文字。
|
||||
reply_log_content = serialize_reply_log(replies)
|
||||
if not sent_any:
|
||||
logger.warning(
|
||||
f"IM API send failed for [{sender}]: {send_error}; reply saved to log only"
|
||||
)
|
||||
# 发送彻底失败:清除冷却时间戳,避免把没收到回复的用户锁在冷却窗口内
|
||||
if peer_key and cooldown > 0:
|
||||
self._last_reply_at.pop(peer_key, None)
|
||||
|
||||
await self.log_fn(
|
||||
**log_kwargs,
|
||||
reply=reply_log_content,
|
||||
status="replied" if sent_any else "failed",
|
||||
error=None if sent_any else (send_error or "IM API 发送失败"),
|
||||
)
|
||||
if sent_any:
|
||||
system_logger.record(
|
||||
"自动回复成功",
|
||||
detail=f"已回复 {sender}:{combined_display}",
|
||||
level="success",
|
||||
category="send",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
else:
|
||||
system_logger.record(
|
||||
"自动回复失败",
|
||||
detail=f"回复 {sender} 失败:{send_error}(收到:{content})",
|
||||
level="error",
|
||||
category="send",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
logger.info(
|
||||
f"Auto-reply to {sender}: {content!r} -> {combined_display!r} "
|
||||
f"(sent={sent_any}, count={len(replies)})"
|
||||
)
|
||||
|
||||
async def _index_conversations(self, conversations: list[dict]):
|
||||
my_uid = int(self.session.my_uid or 0)
|
||||
for raw in conversations:
|
||||
conv = enrich_conversation_item(raw, my_uid)
|
||||
conv_id = str(conv.get("conversation_id") or "")
|
||||
name = (conv.get("sender_name") or "").strip()
|
||||
avatar = str(conv.get("sender_avatar") or "").strip()
|
||||
peer_uid = str(conv.get("peer_uid") or "")
|
||||
|
||||
if peer_uid and (is_generic_peer_name(name, peer_uid) or not avatar):
|
||||
profile = await fetch_peer_profile(self.session, peer_uid, self.account_id)
|
||||
if profile.get("nickname"):
|
||||
name = profile["nickname"]
|
||||
conv["sender_name"] = name
|
||||
if profile.get("avatar_url"):
|
||||
avatar = profile["avatar_url"]
|
||||
conv["sender_avatar"] = avatar
|
||||
|
||||
if conv_id:
|
||||
self._conv_meta[conv_id] = {
|
||||
**conv,
|
||||
"sender_name": name,
|
||||
"sender_avatar": avatar or None,
|
||||
"peer_uid": peer_uid,
|
||||
}
|
||||
if name:
|
||||
self._conv_names[conv_id] = name
|
||||
if peer_uid and name:
|
||||
self._conv_names[peer_uid] = name
|
||||
|
||||
async def _poll_conversations(self):
|
||||
controller = get_traffic_controller()
|
||||
async with controller.background_slot(self.account_id, "conversation poll"):
|
||||
async with DouyinImHttpClient(self.session, account_id=self.account_id) as http:
|
||||
unread_total = await http.get_unread_count()
|
||||
if unread_total:
|
||||
logger.info(f"IM unread total: {unread_total}")
|
||||
conversations = await http.get_conversations()
|
||||
await self._index_conversations(conversations)
|
||||
# Message handling may wait in the global send lane. Do not keep one
|
||||
# of the scarce background HTTP slots occupied while that happens.
|
||||
for conv in conversations:
|
||||
unread = int(conv.get("unread_count") or 0)
|
||||
if unread > 0 or conv.get("content"):
|
||||
await self._handle_incoming(conv)
|
||||
|
||||
async def _verify_account_uid(self):
|
||||
"""启动时用 query/user 接口核验账号真实 UID,修正采集端可能取错的 my_uid/device_id。
|
||||
|
||||
采集端从 tea_cache 推断的 my_uid 可能是访客/对方 id,会导致会话列表为 0、
|
||||
创建会话 INVALID_REQUEST。这里在建连前先校正,保证后续所有请求身份正确。
|
||||
"""
|
||||
if getattr(self.session, "uid_verified", False) and self.session.my_uid:
|
||||
return
|
||||
try:
|
||||
from .auth import DouyinAuth
|
||||
auth = DouyinAuth.from_im_session(self.session)
|
||||
controller = get_traffic_controller()
|
||||
async with controller.background_slot(self.account_id, "account UID verify"):
|
||||
async with DouyinImHttpClient(self.session, account_id=self.account_id) as http:
|
||||
old = int(self.session.my_uid or 0)
|
||||
resolved = await asyncio.to_thread(http._resolve_authoritative_uid, auth)
|
||||
if resolved and old and int(resolved) != old:
|
||||
system_logger.record(
|
||||
"已自动校正账号 UID",
|
||||
detail=f"采集端识别 UID={old},接口核验真实 UID={resolved},已修正后再建立私信连接。",
|
||||
level="info",
|
||||
category="system",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"启动核验账号 UID 失败(沿用采集值):{e}")
|
||||
|
||||
async def run(self):
|
||||
"""主循环:WebSocket + HTTP 轮询"""
|
||||
self._running = True
|
||||
await self._reply_queue.start()
|
||||
await self._verify_account_uid()
|
||||
# ensure_frontier_ws 可能触发签名/HTTP(阻塞),放线程池避免多账号启动时卡死事件循环
|
||||
controller = get_traffic_controller()
|
||||
async with controller.background_slot(self.account_id, "frontier discovery"):
|
||||
await asyncio.to_thread(ensure_frontier_ws, self.session)
|
||||
has_ws = bool(self.session.frontier_ws_url())
|
||||
cred_summary = format_session_credential_summary(self.session)
|
||||
logger.info(cred_summary)
|
||||
logger.info(
|
||||
f"Starting IM direct service for account {self.account_id} "
|
||||
f"(ws={'yes' if has_ws else 'no'})"
|
||||
)
|
||||
system_logger.record(
|
||||
"私信托管已启动",
|
||||
detail=f"实时接收通道:{'已就绪' if has_ws else '不可用(仅 HTTP 轮询)'}\n{cred_summary}",
|
||||
level="success" if has_ws else "warning",
|
||||
category="system",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
|
||||
try:
|
||||
from .emoji_pack import ensure_emoji_map, is_fresh
|
||||
|
||||
if not is_fresh():
|
||||
async with controller.background_slot(self.account_id, "emoji preload"):
|
||||
await asyncio.to_thread(ensure_emoji_map, self.session)
|
||||
except Exception as e:
|
||||
logger.debug(f"emoji map preload failed: {e}")
|
||||
|
||||
self._ws_client = DouyinImWsClient(
|
||||
self.session, self._handle_incoming, account_id=self.account_id
|
||||
)
|
||||
await self._ws_client.start()
|
||||
|
||||
try:
|
||||
await self._poll_conversations()
|
||||
except Exception as e:
|
||||
logger.warning(f"Initial conversation poll failed: {e}")
|
||||
system_logger.record(
|
||||
"首次会话轮询失败",
|
||||
detail=f"{e}",
|
||||
level="warning",
|
||||
category="poll",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
|
||||
loop_count = 0
|
||||
while self._running:
|
||||
# The initial poll above is authoritative. Sleep before the next
|
||||
# recurring tick so startup cannot issue two back-to-back polls.
|
||||
await asyncio.sleep(5)
|
||||
if not self._running:
|
||||
break
|
||||
loop_count += 1
|
||||
try:
|
||||
if loop_count % 3 == 0:
|
||||
await self._poll_conversations()
|
||||
if loop_count % 6 == 0:
|
||||
logger.info(f"IM direct tick #{loop_count} account={self.account_id}")
|
||||
# 关注欢迎语:约每 60s 检测一次新粉丝(独立于私信轮询,失败不影响主循环)
|
||||
if self.follow_tick and loop_count % 12 == 0:
|
||||
try:
|
||||
await self.follow_tick()
|
||||
except Exception as e:
|
||||
logger.error(f"follow welcome tick error: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"IM poll error: {e}")
|
||||
system_logger.record(
|
||||
"会话轮询出错",
|
||||
detail=f"拉取会话/未读时出错:{e}",
|
||||
level="error",
|
||||
category="poll",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
|
||||
async def stop(self):
|
||||
self._running = False
|
||||
await get_traffic_controller().send_queue.cancel_account(self.account_id)
|
||||
await self._reply_queue.stop()
|
||||
if self._ws_client:
|
||||
await self._ws_client.stop()
|
||||
|
||||
def get_cached_conversations(self) -> list[dict]:
|
||||
"""返回运行中缓存的会话(来自 WS / 轮询)。"""
|
||||
results = []
|
||||
seen = set()
|
||||
for conv_id, meta in self._conv_meta.items():
|
||||
name = (meta.get("sender_name") or "").strip()
|
||||
key = conv_id or name
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
results.append({
|
||||
"conversation_id": conv_id,
|
||||
"sender_name": name or f"会话{conv_id[-8:]}" if conv_id else "未知用户",
|
||||
"sender_avatar": meta.get("sender_avatar") or None,
|
||||
"sender_id": str(meta.get("peer_uid") or meta.get("sender_id") or conv_id or ""),
|
||||
"peer_uid": str(meta.get("peer_uid") or ""),
|
||||
"content": str(meta.get("content") or ""),
|
||||
"unread_count": int(meta.get("unread_count") or 0),
|
||||
})
|
||||
return results
|
||||
|
||||
async def get_reply_queue_snapshot(self) -> list[dict]:
|
||||
"""返回当前账号自动回复队列的可管理快照。"""
|
||||
return await self._reply_queue.snapshot()
|
||||
|
||||
async def send_queued_reply_now(self, job_id: str) -> dict:
|
||||
"""把指定自动回复任务移入账号紧急队列;实际发送仍由单消费者串行执行。"""
|
||||
return await self._reply_queue.send_now(job_id)
|
||||
|
||||
async def _send_text(
|
||||
self,
|
||||
conversation_id: str,
|
||||
content: str,
|
||||
conversation_short_id: str = "",
|
||||
) -> tuple[bool, Optional[dict]]:
|
||||
"""发送一条私信;若因签名凭证失效(7911)失败,刷新 web_protect 后自动重试一次。
|
||||
|
||||
返回 (是否成功, 解析到的会话 meta)。失败原因写入 self.last_error。
|
||||
"""
|
||||
for attempt in range(2):
|
||||
async with DouyinImHttpClient(self.session, account_id=self.account_id) as http:
|
||||
sent = await http.send_text_message(
|
||||
conversation_id,
|
||||
content,
|
||||
conversation_short_id=conversation_short_id,
|
||||
)
|
||||
self.last_error = http.last_error
|
||||
needs_refresh = http.last_send_needs_refresh
|
||||
if sent:
|
||||
resolved = http.last_send_meta.get(conversation_id)
|
||||
self.session.conv_meta.update(http.session.conv_meta)
|
||||
self._session_invalid_strikes = 0 # 发送成功 → 登录有效
|
||||
return True, resolved
|
||||
|
||||
# 仅在“签名凭证失效”时刷新并重试一次
|
||||
if attempt == 0 and needs_refresh and self.refresh_credentials:
|
||||
logger.warning(
|
||||
f"Send hit credential-expiry(7911) for {conversation_id}; "
|
||||
"refreshing web_protect and retrying once..."
|
||||
)
|
||||
try:
|
||||
refreshed = await self.refresh_credentials()
|
||||
except Exception as e:
|
||||
logger.warning(f"refresh_credentials raised: {e}")
|
||||
refreshed = False
|
||||
if refreshed:
|
||||
continue
|
||||
break
|
||||
await self._note_session_invalid(self.last_error)
|
||||
return False, None
|
||||
|
||||
async def _note_session_invalid(self, error: str) -> None:
|
||||
"""根据发送失败原因判断 IM 是否已退出登录;连续 INVALID_REQUEST 即触发自动下线。
|
||||
|
||||
INVALID_REQUEST 来自 create_conversation/发送:会话/签名被抖音判为无效,强相关于「登录失效」。
|
||||
而 8xxx/7xxx 等业务错误(关系/频控/内容)说明请求已到达抖音、登录仍有效,重置计数。
|
||||
"""
|
||||
err = error or ""
|
||||
if "INVALID_REQUEST" not in err:
|
||||
self._session_invalid_strikes = 0
|
||||
return
|
||||
self._session_invalid_strikes += 1
|
||||
if self._session_invalid_strikes < 2 or self._session_invalid_fired:
|
||||
return
|
||||
self._session_invalid_fired = True
|
||||
reason = "IM 会话失效(INVALID_REQUEST),登录可能已退出"
|
||||
logger.warning(
|
||||
f"Account {self.account_id} {reason};连续 {self._session_invalid_strikes} 次 -> 自动下线"
|
||||
)
|
||||
system_logger.record(
|
||||
"IM 登录失效,自动下线",
|
||||
detail=f"{reason}(连续 {self._session_invalid_strikes} 次发送返回 INVALID_REQUEST)。"
|
||||
"请停止托管后用浏览器模式重新登录并打开私信页,再重新启动托管。",
|
||||
level="error",
|
||||
category="auth",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
self._running = False # 让主循环尽快退出
|
||||
if self.on_session_invalid:
|
||||
try:
|
||||
await self.on_session_invalid(reason)
|
||||
except Exception as e:
|
||||
logger.error(f"on_session_invalid handler error: {e}")
|
||||
|
||||
async def send_message(self, conversation_id: str, content: str) -> bool:
|
||||
"""手动发送私信"""
|
||||
from .conv_util import normalize_conversation_id
|
||||
from .auth import DouyinAuth
|
||||
|
||||
auth = DouyinAuth()
|
||||
auth.perepare_auth(
|
||||
self.session.cookie_header(),
|
||||
self.session.web_protect_str,
|
||||
self.session.keys_str,
|
||||
)
|
||||
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
|
||||
if my_uid:
|
||||
conversation_id = normalize_conversation_id(conversation_id, my_uid)
|
||||
|
||||
meta = self._conv_meta.get(conversation_id, {})
|
||||
sent, resolved = await self._send_text(
|
||||
conversation_id,
|
||||
content,
|
||||
conversation_short_id=str(meta.get("conversation_short_id") or ""),
|
||||
)
|
||||
if sent and resolved:
|
||||
self._conv_meta[conversation_id] = {
|
||||
**meta,
|
||||
**resolved,
|
||||
"conversation_id": conversation_id,
|
||||
}
|
||||
return sent
|
||||
@@ -0,0 +1,260 @@
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
IM_TOKEN_COOKIES = ("sessionid", "sessionid_ss")
|
||||
|
||||
|
||||
def is_frontier_ws_url(url: str) -> bool:
|
||||
"""判断是否为抖音 IM frontier 长连接地址。
|
||||
|
||||
真实抓包里 host 可能是 frontier-im.douyin.com,也可能是
|
||||
frontierNN-normal.zijieapi.com 这类内部别名,二者都要认。
|
||||
"""
|
||||
if not url or "token=" not in url:
|
||||
return False
|
||||
return "frontier-im.douyin.com" in url or ("frontier" in url and "zijieapi.com" in url)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DouyinImSession:
|
||||
"""抖音 IM 直连所需会话信息(从 Cookie + 浏览器抓包获得)"""
|
||||
|
||||
cookies: dict = field(default_factory=dict)
|
||||
ws_urls: list = field(default_factory=list)
|
||||
device_id: str = ""
|
||||
web_id: str = ""
|
||||
user_agent: str = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
keys_str: str = ""
|
||||
web_protect_str: str = ""
|
||||
my_uid: int = 0
|
||||
# my_uid 是否已用 query/user 接口核验过(采集端推断的 my_uid 可能取错 tea_cache id)
|
||||
uid_verified: bool = False
|
||||
conv_meta: dict = field(default_factory=dict)
|
||||
# 方案 A:直接复用浏览器抓到的真实 frontier 连接凭证(绕开我们自己推导 token/access_key 不准的问题)
|
||||
sdk_cert: str = "" # bd-ticket-guard 客户端证书(frontier sdk_cert / HTTP client-cert)
|
||||
frontier_ts_sign: str = "" # 抓包得到的新鲜 ts_sign(覆盖 web_protect 里可能已过期的)
|
||||
|
||||
@classmethod
|
||||
def from_storage_state(cls, data: dict, extra: Optional[dict] = None) -> "DouyinImSession":
|
||||
extra = extra or {}
|
||||
cookies = {}
|
||||
cookie_items = data.get("cookies", [])
|
||||
priority_names = set(IM_TOKEN_COOKIES)
|
||||
|
||||
def pick_best_cookie(name: str) -> str:
|
||||
matches = [
|
||||
c for c in cookie_items
|
||||
if c.get("name") == name and c.get("value")
|
||||
]
|
||||
if not matches:
|
||||
return ""
|
||||
matches.sort(
|
||||
key=lambda c: (
|
||||
0 if ".douyin.com" in (c.get("domain") or "") else 1,
|
||||
-len(c.get("value") or ""),
|
||||
)
|
||||
)
|
||||
return matches[0].get("value") or ""
|
||||
|
||||
for item in cookie_items:
|
||||
name = item.get("name")
|
||||
if not name or any(c in name for c in "()[]{}'\"\n \t\\"):
|
||||
continue
|
||||
val = item.get("value") or ""
|
||||
if name in priority_names:
|
||||
best = pick_best_cookie(name)
|
||||
if best:
|
||||
cookies[name] = best
|
||||
elif name not in cookies or len(val) > len(cookies.get(name, "")):
|
||||
cookies[name] = val
|
||||
|
||||
device_id = extra.get("device_id") or cookies.get("device_id") or ""
|
||||
web_id = extra.get("web_id") or ""
|
||||
|
||||
keys_str = extra.get("keys_str") or ""
|
||||
web_protect_str = extra.get("web_protect_str") or ""
|
||||
|
||||
# my_uid 优先级:浏览器实时采集(extra) > storage_state 顶层(凭证采集工具手填/抓取)。
|
||||
# 顶层 my_uid 让导入的明文数字 UID 直接生效,避免后端用加密 uid_tt 解析失败而回退联网查询。
|
||||
def _as_uid(v) -> int:
|
||||
try:
|
||||
return int(str(v).strip())
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
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()
|
||||
|
||||
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", []):
|
||||
name = entry.get("name", "")
|
||||
value = entry.get("value", "")
|
||||
if not value:
|
||||
continue
|
||||
if name == "security-sdk/s_sdk_crypt_sdk" and not keys_str:
|
||||
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:
|
||||
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)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not my_uid:
|
||||
for item in data.get("cookies", []):
|
||||
cname = item.get("name") or ""
|
||||
if cname in ("uid_tt", "uid_tt_ss") and item.get("value"):
|
||||
try:
|
||||
my_uid = int(item.get("value"))
|
||||
break
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
if not device_id and my_uid:
|
||||
device_id = str(my_uid)
|
||||
elif not device_id and web_id:
|
||||
device_id = web_id
|
||||
|
||||
ws_urls = list(extra.get("ws_urls") or [])
|
||||
|
||||
# 方案 A:凭证采集工具可携带浏览器抓到的真实 frontier 连接(含 token/sdk_cert/ts_sign)。
|
||||
frontier_ws_url = str(
|
||||
extra.get("frontier_ws_url") or data.get("frontier_ws_url") or ""
|
||||
).strip()
|
||||
sdk_cert = str(extra.get("sdk_cert") or data.get("sdk_cert") or "").strip()
|
||||
frontier_ts_sign = str(
|
||||
extra.get("frontier_ts_sign")
|
||||
or data.get("frontier_ts_sign")
|
||||
or data.get("ts_sign")
|
||||
or ""
|
||||
).strip()
|
||||
if is_frontier_ws_url(frontier_ws_url):
|
||||
# 真实抓包 URL 优先,放在最前面
|
||||
ws_urls = [frontier_ws_url] + [u for u in ws_urls if u != frontier_ws_url]
|
||||
# 从真实 URL 里补抽 sdk_cert / ts_sign(用户只贴了 URL 时)。
|
||||
# 注意:不能用 parse_qs(它会把 + 解成空格,毁掉 base64 证书),用 unquote。
|
||||
def _q(url: str, key: str) -> str:
|
||||
m = re.search(rf"[?&]{re.escape(key)}=([^&\s]+)", url)
|
||||
return unquote(m.group(1)) if m else ""
|
||||
if not sdk_cert:
|
||||
sdk_cert = _q(frontier_ws_url, "sdk_cert")
|
||||
if not frontier_ts_sign:
|
||||
frontier_ts_sign = _q(frontier_ws_url, "ts_sign")
|
||||
|
||||
return cls(
|
||||
cookies=cookies,
|
||||
ws_urls=ws_urls,
|
||||
device_id=str(device_id or ""),
|
||||
web_id=str(web_id or ""),
|
||||
keys_str=keys_str,
|
||||
web_protect_str=web_protect_str,
|
||||
my_uid=my_uid,
|
||||
user_agent=user_agent if user_agent else cls.user_agent,
|
||||
sdk_cert=sdk_cert,
|
||||
frontier_ts_sign=frontier_ts_sign,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"cookies": self.cookies,
|
||||
"ws_urls": self.ws_urls,
|
||||
"device_id": self.device_id,
|
||||
"web_id": self.web_id,
|
||||
"user_agent": self.user_agent,
|
||||
"keys_str": self.keys_str,
|
||||
"web_protect_str": self.web_protect_str,
|
||||
"my_uid": self.my_uid,
|
||||
"conv_meta": self.conv_meta,
|
||||
"sdk_cert": self.sdk_cert,
|
||||
"frontier_ts_sign": self.frontier_ts_sign,
|
||||
"saved_at": time.time(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "DouyinImSession":
|
||||
if not data:
|
||||
return cls()
|
||||
return cls(
|
||||
cookies=data.get("cookies") or {},
|
||||
ws_urls=data.get("ws_urls") or [],
|
||||
device_id=str(data.get("device_id") or ""),
|
||||
web_id=str(data.get("web_id") or ""),
|
||||
user_agent=data.get("user_agent") or cls.user_agent,
|
||||
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),
|
||||
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 ""),
|
||||
)
|
||||
|
||||
def cookie_header(self) -> str:
|
||||
parts = []
|
||||
for name, value in self.cookies.items():
|
||||
if name and value is not None:
|
||||
parts.append(f"{name}={value}")
|
||||
return "; ".join(parts)
|
||||
|
||||
def has_login(self) -> bool:
|
||||
login_keys = {
|
||||
"sessionid",
|
||||
"sessionid_ss",
|
||||
"sid_tt",
|
||||
"sid_guard",
|
||||
"passport_auth_status",
|
||||
"odin_tt",
|
||||
}
|
||||
return any(self.cookies.get(k) for k in login_keys)
|
||||
|
||||
def can_direct_im(self) -> bool:
|
||||
"""是否具备 Cookie 直连 IM 的最低条件(无需浏览器)"""
|
||||
return self.has_login() and bool(
|
||||
self.cookies.get("sessionid") or self.cookies.get("sessionid_ss")
|
||||
)
|
||||
|
||||
def frontier_ws_url(self) -> Optional[str]:
|
||||
"""仅返回 IM frontier 地址,忽略浏览器抓到的 bytelink 等无关 WS。"""
|
||||
for url in self.ws_urls:
|
||||
if is_frontier_ws_url(url):
|
||||
return url
|
||||
return None
|
||||
|
||||
def sanitize_ws_urls(self) -> None:
|
||||
self.ws_urls = [url for url in self.ws_urls if is_frontier_ws_url(url)]
|
||||
|
||||
def common_params(self) -> dict[str, str]:
|
||||
return {
|
||||
"aid": "6383",
|
||||
"app_name": "douyin_web",
|
||||
"device_platform": "webapp",
|
||||
"channel": "channel_pc_web",
|
||||
"pc_client_type": "1",
|
||||
"version_code": "170400",
|
||||
"version_name": "17.4.0",
|
||||
"device_id": self.device_id or self.web_id or "",
|
||||
"webid": self.web_id or self.device_id or "",
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# NO CHECKED-IN PROTOBUF GENCODE
|
||||
# source: Request.proto
|
||||
# Protobuf Python Version: 5.27.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import runtime_version as _runtime_version
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||
_runtime_version.Domain.PUBLIC,
|
||||
5,
|
||||
27,
|
||||
1,
|
||||
'',
|
||||
'Request.proto'
|
||||
)
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\rRequest.proto\"&\n\x08\x45xtValue\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\x94\x04\n\x07Request\x12\x0b\n\x03\x63md\x18\x01 \x01(\x05\x12\x13\n\x0bsequence_id\x18\x02 \x01(\x03\x12\x13\n\x0bsdk_version\x18\x03 \x01(\t\x12\r\n\x05token\x18\x04 \x01(\t\x12\r\n\x05refer\x18\x05 \x01(\x05\x12\x12\n\ninbox_type\x18\x06 \x01(\x03\x12\x14\n\x0c\x62uild_number\x18\x07 \x01(\t\x12\x1a\n\x04\x62ody\x18\x08 \x01(\x0b\x32\x0c.RequestBody\x12\x11\n\tdevice_id\x18\t \x01(\t\x12\x0f\n\x07\x63hannel\x18\n \x01(\t\x12\x17\n\x0f\x64\x65vice_platform\x18\x0b \x01(\t\x12\x13\n\x0b\x64\x65vice_type\x18\x0c \x01(\t\x12\x12\n\nos_version\x18\r \x01(\t\x12\x14\n\x0cversion_code\x18\x0e \x01(\t\x12&\n\x07headers\x18\x0f \x03(\x0b\x32\x15.Request.HeadersEntry\x12\x11\n\tconfig_id\x18\x10 \x01(\x05\x12\x1e\n\ntoken_info\x18\x11 \x01(\x0b\x32\n.TokenInfo\x12\x11\n\tauth_type\x18\x12 \x01(\x05\x12\x0b\n\x03\x62iz\x18\x15 \x01(\t\x12\x0e\n\x06\x61\x63\x63\x65ss\x18\x16 \x01(\t\x12\x0f\n\x07ts_sign\x18\x17 \x01(\t\x12\x10\n\x08sdk_cert\x18\x18 \x01(\t\x12\x14\n\x0creuqest_sign\x18\x19 \x01(\t\x1a.\n\x0cHeadersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xeb\x01\n\x0bRequestBody\x12\x34\n\x11send_message_body\x18\x64 \x01(\x0b\x32\x17.SendMessageRequestBodyH\x00\x12H\n\x1b\x63reate_conversation_v2_body\x18\xe1\x04 \x01(\x0b\x32 .CreateConversationV2RequestBodyH\x00\x12T\n\"get_conversation_info_list_v2_body\x18\xe2\x04 \x01(\x0b\x32%.GetConversationInfoListV2RequestBodyH\x00\x42\x06\n\x04\x62ody\"\xb8\x02\n\x16SendMessageRequestBody\x12\x17\n\x0f\x63onversation_id\x18\x01 \x01(\t\x12\x19\n\x11\x63onversation_type\x18\x02 \x01(\x05\x12\x1d\n\x15\x63onversation_short_id\x18\x03 \x01(\x03\x12\x0f\n\x07\x63ontent\x18\x04 \x01(\t\x12\x16\n\x03\x65xt\x18\x05 \x03(\x0b\x32\t.ExtValue\x12\x14\n\x0cmessage_type\x18\x06 \x01(\x05\x12\x0e\n\x06ticket\x18\x07 \x01(\t\x12\x19\n\x11\x63lient_message_id\x18\x08 \x01(\t\x12\x17\n\x0fmentioned_users\x18\t \x03(\x03\x12\x1a\n\x12ignore_badge_count\x18\n \x01(\x08\x12,\n\x0cref_msg_info\x18\x0b \x01(\x0b\x32\x16.ReferencedMessageInfo\"y\n\x15ReferencedMessageInfo\x12\x1b\n\x13original_message_id\x18\x01 \x01(\x03\x12\x1f\n\x17original_message_sender\x18\x02 \x01(\t\x12\"\n\x1aoriginal_message_timestamp\x18\x03 \x01(\x03\"^\n\tTokenInfo\x12\x0f\n\x07mark_id\x18\x01 \x01(\x05\x12\x0c\n\x04type\x18\x02 \x01(\x05\x12\x0e\n\x06\x61pp_id\x18\x03 \x01(\x05\x12\x0f\n\x07user_id\x18\x04 \x01(\x03\x12\x11\n\ttimestamp\x18\x05 \x01(\x03\"\xa2\x02\n\x1f\x43reateConversationV2RequestBody\x12\x19\n\x11\x63onversation_type\x18\x01 \x01(\x05\x12\x14\n\x0cparticipants\x18\x02 \x03(\x03\x12\x12\n\npersistent\x18\x03 \x01(\x08\x12\x15\n\ridempotent_id\x18\x04 \x01(\t\x12\x0c\n\x04name\x18\x05 \x01(\t\x12\x12\n\navatar_url\x18\x06 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x07 \x01(\t\x12=\n\x07\x62iz_ext\x18\x08 \x03(\x0b\x32,.CreateConversationV2RequestBody.BizExtEntry\x1a-\n\x0b\x42izExtEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"`\n$GetConversationInfoListV2RequestBody\x12\x38\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32*.GetConversationInfoListV2ResponseBodyData\"~\n)GetConversationInfoListV2ResponseBodyData\x12\x17\n\x0f\x63onversation_id\x18\x01 \x01(\t\x12\x1d\n\x15\x63onversation_short_id\x18\x02 \x01(\x03\x12\x19\n\x11\x63onversation_type\x18\x03 \x01(\x05\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'Request_pb2', _globals)
|
||||
if not _descriptor._USE_C_DESCRIPTORS:
|
||||
DESCRIPTOR._loaded_options = None
|
||||
_globals['_REQUEST_HEADERSENTRY']._loaded_options = None
|
||||
_globals['_REQUEST_HEADERSENTRY']._serialized_options = b'8\001'
|
||||
_globals['_CREATECONVERSATIONV2REQUESTBODY_BIZEXTENTRY']._loaded_options = None
|
||||
_globals['_CREATECONVERSATIONV2REQUESTBODY_BIZEXTENTRY']._serialized_options = b'8\001'
|
||||
_globals['_EXTVALUE']._serialized_start=17
|
||||
_globals['_EXTVALUE']._serialized_end=55
|
||||
_globals['_REQUEST']._serialized_start=58
|
||||
_globals['_REQUEST']._serialized_end=590
|
||||
_globals['_REQUEST_HEADERSENTRY']._serialized_start=544
|
||||
_globals['_REQUEST_HEADERSENTRY']._serialized_end=590
|
||||
_globals['_REQUESTBODY']._serialized_start=593
|
||||
_globals['_REQUESTBODY']._serialized_end=828
|
||||
_globals['_SENDMESSAGEREQUESTBODY']._serialized_start=831
|
||||
_globals['_SENDMESSAGEREQUESTBODY']._serialized_end=1143
|
||||
_globals['_REFERENCEDMESSAGEINFO']._serialized_start=1145
|
||||
_globals['_REFERENCEDMESSAGEINFO']._serialized_end=1266
|
||||
_globals['_TOKENINFO']._serialized_start=1268
|
||||
_globals['_TOKENINFO']._serialized_end=1362
|
||||
_globals['_CREATECONVERSATIONV2REQUESTBODY']._serialized_start=1365
|
||||
_globals['_CREATECONVERSATIONV2REQUESTBODY']._serialized_end=1655
|
||||
_globals['_CREATECONVERSATIONV2REQUESTBODY_BIZEXTENTRY']._serialized_start=1610
|
||||
_globals['_CREATECONVERSATIONV2REQUESTBODY_BIZEXTENTRY']._serialized_end=1655
|
||||
_globals['_GETCONVERSATIONINFOLISTV2REQUESTBODY']._serialized_start=1657
|
||||
_globals['_GETCONVERSATIONINFOLISTV2REQUESTBODY']._serialized_end=1753
|
||||
_globals['_GETCONVERSATIONINFOLISTV2RESPONSEBODYDATA']._serialized_start=1755
|
||||
_globals['_GETCONVERSATIONINFOLISTV2RESPONSEBODYDATA']._serialized_end=1881
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
@@ -0,0 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# NO CHECKED-IN PROTOBUF GENCODE
|
||||
# source: Response.proto
|
||||
# Protobuf Python Version: 5.27.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import runtime_version as _runtime_version
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||
_runtime_version.Domain.PUBLIC,
|
||||
5,
|
||||
27,
|
||||
1,
|
||||
'',
|
||||
'Response.proto'
|
||||
)
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0eResponse.proto\"\x82\x01\n\x08Response\x12\x0b\n\x03\x63md\x18\x01 \x01(\x05\x12\x13\n\x0bsequence_id\x18\x02 \x01(\x03\x12\x12\n\nerror_desc\x18\x03 \x01(\t\x12\x0f\n\x07message\x18\x04 \x01(\t\x12\x12\n\ninbox_type\x18\x05 \x01(\x03\x12\x1b\n\x04\x62ody\x18\x06 \x01(\x0b\x32\r.ResponseBody\"\xf8\x01\n\x0cResponseBody\x12\x30\n\x12new_message_notify\x18\xf4\x03 \x01(\x0b\x32\x11.NewMessageNotifyH\x00\x12N\n\x1b\x63reate_conversation_v2_body\x18\xe1\x04 \x01(\x0b\x32&.GetConversationInfoListV2ResponseBodyH\x00\x12^\n+get_conversation_info_list_v2_response_body\x18\xe2\x04 \x01(\x0b\x32&.GetConversationInfoListV2ResponseBodyH\x00\x42\x06\n\x04\x62ody\"z\n\x10NewMessageNotify\x12\x17\n\x0f\x63onversation_id\x18\x02 \x01(\t\x12\x19\n\x11\x63onversation_type\x18\x03 \x01(\x05\x12\x13\n\x0bnotify_type\x18\x04 \x01(\x05\x12\x1d\n\x07message\x18\x05 \x01(\x0b\x32\x0c.MessageBody\"\xd1\x01\n\x0bMessageBody\x12\x17\n\x0f\x63onversation_id\x18\x01 \x01(\t\x12\x19\n\x11\x63onversation_type\x18\x02 \x01(\x05\x12\x19\n\x11server_message_id\x18\x03 \x01(\x03\x12\x1d\n\x15index_in_conversation\x18\x04 \x01(\x03\x12\x1d\n\x15\x63onversation_short_id\x18\x05 \x01(\x03\x12\x14\n\x0cmessage_type\x18\x06 \x01(\x05\x12\x0e\n\x06sender\x18\x07 \x01(\x03\x12\x0f\n\x07\x63ontent\x18\x08 \x01(\t\"g\n%GetConversationInfoListV2ResponseBody\x12>\n\x16\x63onversation_info_list\x18\x01 \x03(\x0b\x32\x1e.GetConversationInfoV2Response\"\x82\x01\n\x1dGetConversationInfoV2Response\x12\x17\n\x0f\x63onversation_id\x18\x01 \x01(\t\x12\x1d\n\x15\x63onversation_short_id\x18\x02 \x01(\x03\x12\x19\n\x11\x63onversation_type\x18\x03 \x01(\x05\x12\x0e\n\x06ticket\x18\x04 \x01(\tb\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'Response_pb2', _globals)
|
||||
if not _descriptor._USE_C_DESCRIPTORS:
|
||||
DESCRIPTOR._loaded_options = None
|
||||
_globals['_RESPONSE']._serialized_start=19
|
||||
_globals['_RESPONSE']._serialized_end=149
|
||||
_globals['_RESPONSEBODY']._serialized_start=152
|
||||
_globals['_RESPONSEBODY']._serialized_end=400
|
||||
_globals['_NEWMESSAGENOTIFY']._serialized_start=402
|
||||
_globals['_NEWMESSAGENOTIFY']._serialized_end=524
|
||||
_globals['_MESSAGEBODY']._serialized_start=527
|
||||
_globals['_MESSAGEBODY']._serialized_end=736
|
||||
_globals['_GETCONVERSATIONINFOLISTV2RESPONSEBODY']._serialized_start=738
|
||||
_globals['_GETCONVERSATIONINFOLISTV2RESPONSEBODY']._serialized_end=841
|
||||
_globals['_GETCONVERSATIONINFOV2RESPONSE']._serialized_start=844
|
||||
_globals['_GETCONVERSATIONINFOV2RESPONSE']._serialized_end=974
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,167 @@
|
||||
ea = function() {
|
||||
return (ea = Object.assign || function(e) {
|
||||
for (var t, n = 1, r = arguments.length; n < r; n++)
|
||||
for (var o in t = arguments[n])
|
||||
Object.prototype.hasOwnProperty.call(t, o) && (e[o] = t[o]);
|
||||
return e
|
||||
}
|
||||
).apply(this, arguments)
|
||||
}
|
||||
var H = function(e) {
|
||||
for (var t, n = e.toString(), r = [], o = 0; o < n.length; o++)
|
||||
0 <= (t = n.charCodeAt(o)) && t <= 127 ? r.push(t) : 128 <= t && t <= 2047 ? (r.push(192 | 31 & t >> 6),
|
||||
r.push(128 | 63 & t)) : (2048 <= t && t <= 55295 || 57344 <= t && t <= 65535) && (r.push(224 | 15 & t >> 12),
|
||||
r.push(128 | 63 & t >> 6),
|
||||
r.push(128 | 63 & t));
|
||||
for (var i = 0; i < r.length; i++)
|
||||
r[i] &= 255;
|
||||
return r
|
||||
}
|
||||
V = function(e) {
|
||||
var t = []
|
||||
, n = [];
|
||||
if (void 0 === e)
|
||||
return "";
|
||||
n = H(e);
|
||||
for (var r = 0, o = n.length; r < o; ++r)
|
||||
t.push((5 ^ n[r]).toString(16));
|
||||
return t.join("")
|
||||
}
|
||||
function generate_account_sdk_source_info(){
|
||||
let browserInfo = {
|
||||
"hardwareConcurrency": 20,
|
||||
"webdriver": false,
|
||||
"chromedriver": false,
|
||||
"shelldriver": false,
|
||||
"plugins": 5,
|
||||
"permissions": [
|
||||
{
|
||||
"name": "notifications",
|
||||
"state": "prompt"
|
||||
}
|
||||
],
|
||||
"innerHeight": 1442,
|
||||
"innerWidth": 1166,
|
||||
"outerHeight": 1552,
|
||||
"outerWidth": 2560,
|
||||
"stoargeStatus": {
|
||||
"indexedDB": {
|
||||
"idb": "object",
|
||||
"open": "function",
|
||||
"indexedDB": "object",
|
||||
"IDBKeyRange": "function",
|
||||
"openDatabase": "undefined",
|
||||
"isSafari": false,
|
||||
"hasFetch": false
|
||||
},
|
||||
"localStorage": {
|
||||
"isSupportLStorage": true,
|
||||
"size": 46382,
|
||||
"write": true
|
||||
},
|
||||
"storageQuotaStatus": {
|
||||
"usage": 149822,
|
||||
"quota": 128849645568,
|
||||
"isPrivate": false
|
||||
}
|
||||
},
|
||||
"notificationPermission": "default",
|
||||
"performance": {
|
||||
// "timeOrigin": 1723036093298,
|
||||
"timeOrigin": new Date().getTime(),
|
||||
"usedJSHeapSize": 137509473,
|
||||
"navigationTiming": {
|
||||
"decodedBodySize": 633258,
|
||||
"entryType": "navigation",
|
||||
"initiatorType": "navigation",
|
||||
"name": "https://www.douyin.com/?recommend=1",
|
||||
"renderBlockingStatus": "non-blocking",
|
||||
"serverTiming": "inner,tt_agw,cdn-cache,edge,origin",
|
||||
"guleStart": 1496.2999999523163,
|
||||
"guleDuration": 14.799999952316284
|
||||
}
|
||||
}
|
||||
}
|
||||
let data = {
|
||||
request_host: "www.douyin.com",
|
||||
request_pathname: '/'
|
||||
}
|
||||
return V(JSON.stringify(ea(ea({}, browserInfo || {}), data)))
|
||||
}
|
||||
// console.log(generate_account_sdk_source_info())
|
||||
|
||||
|
||||
eD = function(e, t) {
|
||||
var n, r = 0, o = 0;
|
||||
if ("object" != typeof e || !t || t.length <= 0)
|
||||
return e;
|
||||
for (var i = ej({
|
||||
mix_mode: r
|
||||
}, e), a = 0, c = t.length; a < c; ++a)
|
||||
void 0 !== (n = i[t[a]]) && (r |= 1,
|
||||
o |= 1,
|
||||
i[t[a]] = eN(n));
|
||||
return i.mix_mode = r,
|
||||
i.fixed_mix_mode = o,
|
||||
i
|
||||
}
|
||||
var ek = function(e) {
|
||||
for (var t, n = e.toString(), r = [], o = 0; o < n.length; o++)
|
||||
0 <= (t = n.charCodeAt(o)) && t <= 127 ? r.push(t) : 128 <= t && t <= 2047 ? (r.push(192 | 31 & t >> 6),
|
||||
r.push(128 | 63 & t)) : (2048 <= t && t <= 55295 || 57344 <= t && t <= 65535) && (r.push(224 | 15 & t >> 12),
|
||||
r.push(128 | 63 & t >> 6),
|
||||
r.push(128 | 63 & t));
|
||||
for (var i = 0; i < r.length; i++)
|
||||
r[i] &= 255;
|
||||
return r
|
||||
}
|
||||
eN = function(e) {
|
||||
var t = []
|
||||
, n = [];
|
||||
if (void 0 === e)
|
||||
return "";
|
||||
n = ek(e);
|
||||
for (var r = 0, o = n.length; r < o; ++r)
|
||||
t.push((5 ^ n[r]).toString(16));
|
||||
return t.join("")
|
||||
}
|
||||
ej = function() {
|
||||
return (ej = Object.assign || function(e) {
|
||||
for (var t, n = 1, r = arguments.length; n < r; n++)
|
||||
for (var o in t = arguments[n])
|
||||
Object.prototype.hasOwnProperty.call(t, o) && (e[o] = t[o]);
|
||||
return e
|
||||
}
|
||||
).apply(this, arguments)
|
||||
};
|
||||
function generateSecretPhoneNum(phoneNum) {
|
||||
return eD({
|
||||
"mobile": "+86 " + phoneNum,
|
||||
"type": 24,
|
||||
"is6Digits": 1
|
||||
}, ["mobile", "type"])
|
||||
}
|
||||
// let res = eD({
|
||||
// "mobile": "+86 15751076989",
|
||||
// "type": 24,
|
||||
// "is6Digits": 1
|
||||
// }, ["mobile", "type"])
|
||||
//
|
||||
// console.log(res)
|
||||
nx = function() {
|
||||
return (nx = Object.assign || function(e) {
|
||||
for (var t, n = 1, r = arguments.length; n < r; n++)
|
||||
for (var o in t = arguments[n])
|
||||
Object.prototype.hasOwnProperty.call(t, o) && (e[o] = t[o]);
|
||||
return e
|
||||
}
|
||||
).apply(this, arguments)
|
||||
}
|
||||
function generateSecretCode(phoneNum, code) {
|
||||
return eD({
|
||||
"mobile": "+86 " + phoneNum,
|
||||
"code": code,
|
||||
"service": "https://www.douyin.com"
|
||||
}, ["mobile", "code", "password"])
|
||||
}
|
||||
// console.log(generateSecretCode("15751076989", "090625"))
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "static",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"jsrsasign": "^11.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jsrsasign": {
|
||||
"version": "11.1.3",
|
||||
"resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-11.1.3.tgz",
|
||||
"integrity": "sha512-nPnK5D/4lv0Dwr7TlzrKtAd8JlLZwFTqTUUB3NQCbtdobcRcohGFxjbPySDVh74iWUudcCsapYT6OxoyhJLhhA==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"jsrsasign": "^11.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
"""Process-wide traffic control for hosted Douyin accounts.
|
||||
|
||||
The backend normally runs one asyncio event loop. A controller is kept per
|
||||
event loop so unit tests that create a fresh loop for every test do not reuse
|
||||
asyncio primitives bound to an older loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import weakref
|
||||
from collections import deque
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Awaitable, Callable, Optional, TypeVar
|
||||
|
||||
|
||||
logger = logging.getLogger("douyin_im.traffic")
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def _env_float(name: str, default: float, minimum: float = 0.0) -> float:
|
||||
try:
|
||||
return max(minimum, float(os.getenv(name, str(default))))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _env_int(name: str, default: int, minimum: int = 1) -> int:
|
||||
try:
|
||||
return max(minimum, int(os.getenv(name, str(default))))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class _SendJob:
|
||||
account_id: int
|
||||
operation: Callable[[], Awaitable[Any]]
|
||||
future: asyncio.Future
|
||||
description: str
|
||||
queued_at: float
|
||||
cancelled: bool = False
|
||||
operation_task: Optional[asyncio.Task] = None
|
||||
|
||||
|
||||
class GlobalSendQueue:
|
||||
"""One paced, account-fair dispatcher for every outbound IM write.
|
||||
|
||||
Each account owns a FIFO. After one job is selected, that account goes to
|
||||
the back of the rotation. A busy account therefore cannot starve smaller
|
||||
accounts, while messages within one account retain their original order.
|
||||
"""
|
||||
|
||||
def __init__(self, interval_seconds: float = 1.0) -> None:
|
||||
self.interval_seconds = max(0.0, float(interval_seconds or 0.0))
|
||||
self._queues: dict[int, deque[_SendJob]] = {}
|
||||
self._rotation: deque[int] = deque()
|
||||
self._in_rotation: set[int] = set()
|
||||
self._active_job: Optional[_SendJob] = None
|
||||
self._active_account: Optional[int] = None
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._running = False
|
||||
self._state_lock = asyncio.Lock()
|
||||
self._wake = asyncio.Event()
|
||||
self._next_allowed_at = 0.0
|
||||
|
||||
@property
|
||||
def queued_count(self) -> int:
|
||||
return sum(len(queue) for queue in self._queues.values())
|
||||
|
||||
@property
|
||||
def pending_count(self) -> int:
|
||||
return self.queued_count + (1 if self._active_job is not None else 0)
|
||||
|
||||
async def start(self) -> None:
|
||||
async with self._state_lock:
|
||||
if self._task and not self._task.done():
|
||||
return
|
||||
self._running = True
|
||||
self._wake.clear()
|
||||
self._task = asyncio.create_task(
|
||||
self._run(),
|
||||
name="douyin-global-send-queue",
|
||||
)
|
||||
|
||||
async def submit(
|
||||
self,
|
||||
account_id: int,
|
||||
operation: Callable[[], Awaitable[T]],
|
||||
description: str = "",
|
||||
) -> T:
|
||||
await self.start()
|
||||
loop = asyncio.get_running_loop()
|
||||
account_key = int(account_id or 0)
|
||||
future: asyncio.Future = loop.create_future()
|
||||
job = _SendJob(
|
||||
account_id=account_key,
|
||||
operation=operation,
|
||||
future=future,
|
||||
description=str(description or "send"),
|
||||
queued_at=time.time(),
|
||||
)
|
||||
|
||||
async with self._state_lock:
|
||||
queue = self._queues.setdefault(account_key, deque())
|
||||
queue.append(job)
|
||||
if account_key != self._active_account and account_key not in self._in_rotation:
|
||||
self._rotation.append(account_key)
|
||||
self._in_rotation.add(account_key)
|
||||
position = self.pending_count
|
||||
self._wake.set()
|
||||
|
||||
if position > 1:
|
||||
logger.info(
|
||||
"Outbound queued account=%s position=%s pending=%s (%s)",
|
||||
account_key,
|
||||
position,
|
||||
position,
|
||||
job.description,
|
||||
)
|
||||
|
||||
try:
|
||||
# Shield lets us remove/cancel the exact queued job when its caller
|
||||
# is cancelled instead of asyncio cancelling only the result future.
|
||||
return await asyncio.shield(future)
|
||||
except asyncio.CancelledError:
|
||||
active = await self._cancel_job(job)
|
||||
if active:
|
||||
# Once the network operation has begun, return its real result
|
||||
# instead of reporting a timeout and then delivering later.
|
||||
# This also keeps the single lane occupied until a to_thread
|
||||
# upload has truly finished.
|
||||
return await asyncio.shield(future)
|
||||
raise
|
||||
|
||||
async def _cancel_job(self, job: _SendJob) -> bool:
|
||||
async with self._state_lock:
|
||||
if job.cancelled:
|
||||
return bool(job.operation_task and not job.operation_task.done())
|
||||
if (
|
||||
self._active_job is job
|
||||
and job.operation_task is not None
|
||||
):
|
||||
# An active image upload may be running in asyncio.to_thread().
|
||||
# Cancelling its awaiter does not stop the thread and would let
|
||||
# the next send overlap it. Let the active job drain and make
|
||||
# the cancelled caller wait for its authoritative result.
|
||||
return True
|
||||
|
||||
job.cancelled = True
|
||||
queue = self._queues.get(job.account_id)
|
||||
if queue:
|
||||
try:
|
||||
queue.remove(job)
|
||||
except ValueError:
|
||||
pass
|
||||
if not queue:
|
||||
self._queues.pop(job.account_id, None)
|
||||
self._remove_from_rotation(job.account_id)
|
||||
if not job.future.done():
|
||||
job.future.cancel()
|
||||
self._wake.set()
|
||||
return False
|
||||
|
||||
def _remove_from_rotation(self, account_id: int) -> None:
|
||||
if account_id not in self._in_rotation:
|
||||
return
|
||||
self._in_rotation.discard(account_id)
|
||||
try:
|
||||
self._rotation.remove(account_id)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _pop_next_locked(self) -> Optional[_SendJob]:
|
||||
while self._rotation:
|
||||
account_id = self._rotation.popleft()
|
||||
self._in_rotation.discard(account_id)
|
||||
queue = self._queues.get(account_id)
|
||||
if not queue:
|
||||
self._queues.pop(account_id, None)
|
||||
continue
|
||||
|
||||
while queue and (queue[0].cancelled or queue[0].future.cancelled()):
|
||||
queue.popleft()
|
||||
if not queue:
|
||||
self._queues.pop(account_id, None)
|
||||
continue
|
||||
|
||||
job = queue.popleft()
|
||||
if not queue:
|
||||
self._queues.pop(account_id, None)
|
||||
self._active_job = job
|
||||
self._active_account = account_id
|
||||
return job
|
||||
return None
|
||||
|
||||
async def _finish_job(self, job: _SendJob) -> None:
|
||||
async with self._state_lock:
|
||||
if self._active_job is job:
|
||||
self._active_job = None
|
||||
self._active_account = None
|
||||
|
||||
queue = self._queues.get(job.account_id)
|
||||
while queue and (queue[0].cancelled or queue[0].future.cancelled()):
|
||||
queue.popleft()
|
||||
if queue:
|
||||
if job.account_id not in self._in_rotation:
|
||||
# Reinsert only after the active operation finishes. New
|
||||
# accounts that arrived while it ran get their turn first.
|
||||
self._rotation.append(job.account_id)
|
||||
self._in_rotation.add(job.account_id)
|
||||
else:
|
||||
self._queues.pop(job.account_id, None)
|
||||
self._remove_from_rotation(job.account_id)
|
||||
self._wake.set()
|
||||
|
||||
async def _run(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
async with self._state_lock:
|
||||
if not self._running:
|
||||
return
|
||||
job = self._pop_next_locked()
|
||||
if job is None:
|
||||
self._wake.clear()
|
||||
|
||||
if job is None:
|
||||
await self._wake.wait()
|
||||
continue
|
||||
|
||||
try:
|
||||
remaining = self._next_allowed_at - asyncio.get_running_loop().time()
|
||||
if remaining > 0:
|
||||
await asyncio.sleep(remaining)
|
||||
|
||||
if job.cancelled or job.future.cancelled():
|
||||
continue
|
||||
|
||||
operation_task = asyncio.create_task(job.operation())
|
||||
async with self._state_lock:
|
||||
job.operation_task = operation_task
|
||||
cancelled_before_start = job.cancelled or job.future.cancelled()
|
||||
if cancelled_before_start:
|
||||
operation_task.cancel()
|
||||
|
||||
try:
|
||||
result = await operation_task
|
||||
except asyncio.CancelledError:
|
||||
if not job.future.done():
|
||||
job.future.cancel()
|
||||
if not self._running:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if not job.future.done():
|
||||
job.future.set_exception(exc)
|
||||
else:
|
||||
if not job.future.done():
|
||||
job.future.set_result(result)
|
||||
finally:
|
||||
self._next_allowed_at = (
|
||||
asyncio.get_running_loop().time() + self.interval_seconds
|
||||
)
|
||||
await self._finish_job(job)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
async def snapshot(self) -> dict[str, Any]:
|
||||
async with self._state_lock:
|
||||
per_account = {
|
||||
account_id: len(queue)
|
||||
for account_id, queue in self._queues.items()
|
||||
if queue
|
||||
}
|
||||
return {
|
||||
"running": bool(self._task and not self._task.done()),
|
||||
"pending_count": sum(per_account.values())
|
||||
+ (1 if self._active_job is not None else 0),
|
||||
"queued_count": sum(per_account.values()),
|
||||
"active_account_id": self._active_account,
|
||||
"active_description": (
|
||||
self._active_job.description if self._active_job else ""
|
||||
),
|
||||
"interval_seconds": self.interval_seconds,
|
||||
"per_account": per_account,
|
||||
}
|
||||
|
||||
async def cancel_account(self, account_id: int) -> int:
|
||||
"""Cancel callers for one stopped account without overlapping traffic.
|
||||
|
||||
Waiting jobs are removed. An already-running operation is allowed to
|
||||
drain inside the dispatcher (notably, Python cannot stop a running
|
||||
to_thread image upload), but its caller is released immediately and no
|
||||
later job can start until that drain finishes.
|
||||
"""
|
||||
account_key = int(account_id or 0)
|
||||
cancelled = 0
|
||||
async with self._state_lock:
|
||||
queue = self._queues.pop(account_key, deque())
|
||||
self._remove_from_rotation(account_key)
|
||||
for job in queue:
|
||||
job.cancelled = True
|
||||
if not job.future.done():
|
||||
job.future.cancel()
|
||||
cancelled += 1
|
||||
|
||||
if self._active_job and self._active_job.account_id == account_key:
|
||||
self._active_job.cancelled = True
|
||||
if not self._active_job.future.done():
|
||||
self._active_job.future.cancel()
|
||||
cancelled += 1
|
||||
self._wake.set()
|
||||
return cancelled
|
||||
|
||||
async def stop(self) -> None:
|
||||
drain_timeout = _env_float("KEFU_SEND_SHUTDOWN_DRAIN_SECONDS", 30.0)
|
||||
async with self._state_lock:
|
||||
self._running = False
|
||||
task = self._task
|
||||
self._task = None
|
||||
active_job = self._active_job
|
||||
active_task = active_job.operation_task if active_job else None
|
||||
futures = [
|
||||
job.future
|
||||
for queue in self._queues.values()
|
||||
for job in queue
|
||||
]
|
||||
for queue in self._queues.values():
|
||||
for job in queue:
|
||||
job.cancelled = True
|
||||
if active_job:
|
||||
active_job.cancelled = True
|
||||
futures.append(active_job.future)
|
||||
self._queues.clear()
|
||||
self._rotation.clear()
|
||||
self._in_rotation.clear()
|
||||
self._wake.set()
|
||||
|
||||
for future in futures:
|
||||
if not future.done():
|
||||
future.cancel()
|
||||
|
||||
if active_task and not active_task.done() and drain_timeout > 0:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(active_task),
|
||||
timeout=drain_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"Active outbound task did not drain within %.1fs during shutdown (%s)",
|
||||
drain_timeout,
|
||||
active_job.description if active_job else "send",
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
# The dispatcher owns normal operation error reporting/results.
|
||||
pass
|
||||
|
||||
if active_task and not active_task.done():
|
||||
active_task.cancel()
|
||||
if task and task is not asyncio.current_task():
|
||||
if not task.done():
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(task), timeout=1.0)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||
task.cancel()
|
||||
if not task.done():
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
async with self._state_lock:
|
||||
self._active_job = None
|
||||
self._active_account = None
|
||||
self._next_allowed_at = 0.0
|
||||
self._wake.clear()
|
||||
|
||||
|
||||
class TrafficController:
|
||||
"""Independent lanes for message writes and lower-priority network work."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.send_queue = GlobalSendQueue(
|
||||
interval_seconds=_env_float(
|
||||
"KEFU_GLOBAL_SEND_INTERVAL_SECONDS",
|
||||
1.0,
|
||||
)
|
||||
)
|
||||
self._background = asyncio.Semaphore(
|
||||
_env_int("KEFU_BACKGROUND_NETWORK_CONCURRENCY", 2)
|
||||
)
|
||||
self._browser = asyncio.Semaphore(
|
||||
_env_int("KEFU_BROWSER_START_CONCURRENCY", 1)
|
||||
)
|
||||
self._media_proxy = asyncio.Semaphore(
|
||||
_env_int("KEFU_MEDIA_PROXY_CONCURRENCY", 4)
|
||||
)
|
||||
self._background_owner: contextvars.ContextVar[tuple[Optional[asyncio.Task], int]] = (
|
||||
contextvars.ContextVar(
|
||||
f"douyin_background_owner_{id(self)}",
|
||||
default=(None, 0),
|
||||
)
|
||||
)
|
||||
self.background_waiting = 0
|
||||
self.background_active = 0
|
||||
self.browser_waiting = 0
|
||||
self.browser_active = 0
|
||||
self.media_proxy_active = 0
|
||||
|
||||
@asynccontextmanager
|
||||
async def background_slot(self, account_id: int = 0, description: str = "request"):
|
||||
current_task = asyncio.current_task()
|
||||
owner_task, depth = self._background_owner.get()
|
||||
if owner_task is current_task and depth > 0:
|
||||
token = self._background_owner.set((current_task, depth + 1))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._background_owner.reset(token)
|
||||
return
|
||||
|
||||
started = asyncio.get_running_loop().time()
|
||||
self.background_waiting += 1
|
||||
try:
|
||||
await self._background.acquire()
|
||||
except BaseException:
|
||||
self.background_waiting -= 1
|
||||
raise
|
||||
self.background_waiting -= 1
|
||||
self.background_active += 1
|
||||
token = self._background_owner.set((current_task, 1))
|
||||
waited = asyncio.get_running_loop().time() - started
|
||||
if waited >= 1.0:
|
||||
logger.info(
|
||||
"Background request dequeued account=%s waited=%.2fs (%s)",
|
||||
account_id,
|
||||
waited,
|
||||
description,
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._background_owner.reset(token)
|
||||
self.background_active -= 1
|
||||
self._background.release()
|
||||
|
||||
@asynccontextmanager
|
||||
async def browser_slot(self, account_id: int = 0, description: str = "browser-login"):
|
||||
started = asyncio.get_running_loop().time()
|
||||
self.browser_waiting += 1
|
||||
try:
|
||||
await self._browser.acquire()
|
||||
except BaseException:
|
||||
self.browser_waiting -= 1
|
||||
raise
|
||||
self.browser_waiting -= 1
|
||||
self.browser_active += 1
|
||||
waited = asyncio.get_running_loop().time() - started
|
||||
if waited >= 1.0:
|
||||
logger.info(
|
||||
"Browser task dequeued account=%s waited=%.2fs (%s)",
|
||||
account_id,
|
||||
waited,
|
||||
description,
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.browser_active -= 1
|
||||
self._browser.release()
|
||||
|
||||
@asynccontextmanager
|
||||
async def media_proxy_slot(self):
|
||||
await self._media_proxy.acquire()
|
||||
self.media_proxy_active += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.media_proxy_active -= 1
|
||||
self._media_proxy.release()
|
||||
|
||||
async def snapshot(self) -> dict[str, Any]:
|
||||
return {
|
||||
"send": await self.send_queue.snapshot(),
|
||||
"background": {
|
||||
"active": self.background_active,
|
||||
"waiting": self.background_waiting,
|
||||
},
|
||||
"browser": {
|
||||
"active": self.browser_active,
|
||||
"waiting": self.browser_waiting,
|
||||
},
|
||||
"media_proxy": {"active": self.media_proxy_active},
|
||||
}
|
||||
|
||||
async def stop(self) -> None:
|
||||
await self.send_queue.stop()
|
||||
|
||||
|
||||
_CONTROLLERS: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, TrafficController]" = (
|
||||
weakref.WeakKeyDictionary()
|
||||
)
|
||||
|
||||
|
||||
def get_traffic_controller() -> TrafficController:
|
||||
loop = asyncio.get_running_loop()
|
||||
controller = _CONTROLLERS.get(loop)
|
||||
if controller is None:
|
||||
controller = TrafficController()
|
||||
_CONTROLLERS[loop] = controller
|
||||
return controller
|
||||
|
||||
|
||||
async def submit_outbound(
|
||||
account_id: int,
|
||||
operation: Callable[[], Awaitable[T]],
|
||||
description: str = "",
|
||||
) -> T:
|
||||
return await get_traffic_controller().send_queue.submit(
|
||||
account_id,
|
||||
operation,
|
||||
description=description,
|
||||
)
|
||||
|
||||
|
||||
async def shutdown_traffic_controller() -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
controller = _CONTROLLERS.pop(loop, None)
|
||||
if controller is not None:
|
||||
await controller.stop()
|
||||
@@ -0,0 +1,230 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
from websocket import WebSocketApp
|
||||
|
||||
from utils import system_logger
|
||||
from .protocol import parse_ws_payload
|
||||
from .session import DouyinImSession
|
||||
|
||||
logger = logging.getLogger("douyin_im.ws")
|
||||
|
||||
MessageHandler = Callable[[dict], Awaitable[None]]
|
||||
|
||||
|
||||
class DouyinImWsClient:
|
||||
"""直连 frontier-im WebSocket(websocket-client,与 DouYin_Spider 一致)"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: DouyinImSession,
|
||||
on_message: MessageHandler,
|
||||
account_id: int | None = None,
|
||||
):
|
||||
self.session = session
|
||||
self.on_message = on_message
|
||||
self.account_id = account_id
|
||||
self._running = False
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._ws_app: Optional[WebSocketApp] = None
|
||||
self._ws_lock = threading.Lock()
|
||||
|
||||
async def start(self):
|
||||
url = self.session.frontier_ws_url()
|
||||
if not url:
|
||||
logger.warning("No frontier WebSocket URL captured; WS listener disabled")
|
||||
system_logger.record(
|
||||
"实时接收未启用:未获取到 frontier WebSocket 地址",
|
||||
detail="缺少有效的 device_id 或 sessionid,无法建立实时私信通道,将仅依赖 HTTP 轮询。",
|
||||
level="warning",
|
||||
category="ws",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
return
|
||||
self._running = True
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._task = asyncio.create_task(self._run_loop(url))
|
||||
|
||||
async def stop(self):
|
||||
self._running = False
|
||||
with self._ws_lock:
|
||||
if self._ws_app:
|
||||
try:
|
||||
self._ws_app.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._ws_app = None
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
async def _run_ws_thread(self, url: str):
|
||||
"""在独立守护线程中跑 run_forever,直到连接断开/关闭。
|
||||
|
||||
不能用共享默认线程池(run_in_executor(None)/asyncio.to_thread):
|
||||
WS 长连接会永久占用一个池线程,账号数超过池大小(默认 64)后,
|
||||
所有账号的签名/轮询任务被饿死,表现为“启动几十个账号后全部卡死超时”。
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
done = asyncio.Event()
|
||||
error: list[BaseException] = []
|
||||
|
||||
def _runner():
|
||||
try:
|
||||
self._connect_sync(url)
|
||||
except BaseException as e:
|
||||
error.append(e)
|
||||
finally:
|
||||
try:
|
||||
loop.call_soon_threadsafe(done.set)
|
||||
except RuntimeError:
|
||||
pass # 事件循环已关闭
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_runner,
|
||||
name=f"im-ws-{self.account_id or 'na'}",
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
try:
|
||||
await done.wait()
|
||||
except asyncio.CancelledError:
|
||||
# stop() 会 close ws_app 使 run_forever 退出,线程随之结束
|
||||
raise
|
||||
if error:
|
||||
raise error[0]
|
||||
|
||||
async def _run_loop(self, url: str):
|
||||
retry = 0
|
||||
while self._running:
|
||||
from .frontier import ensure_frontier_ws
|
||||
from .traffic_control import get_traffic_controller
|
||||
|
||||
# ensure_frontier_ws 可能触发签名/HTTP(阻塞),放线程池避免卡事件循环
|
||||
controller = get_traffic_controller()
|
||||
async with controller.background_slot(self.account_id or 0, "websocket prepare"):
|
||||
await asyncio.to_thread(ensure_frontier_ws, self.session)
|
||||
connect_url = self.session.frontier_ws_url() or url
|
||||
try:
|
||||
logger.info(f"Connecting IM WebSocket: {connect_url[:100]}...")
|
||||
await self._run_ws_thread(connect_url)
|
||||
retry = 0
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"IM WebSocket error: {e}")
|
||||
system_logger.record(
|
||||
"实时接收连接异常",
|
||||
detail=f"建立 frontier WebSocket 失败:{e}",
|
||||
level="error",
|
||||
category="ws",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
|
||||
if not self._running:
|
||||
break
|
||||
retry += 1
|
||||
# Stable per-account jitter prevents every hosted account from
|
||||
# reconnecting in the same second after a shared network outage.
|
||||
jitter = ((int(self.account_id or 0) * 2654435761) % 5000) / 1000.0
|
||||
wait = min(30.0, 2.0 * retry) + jitter
|
||||
logger.info(f"IM WebSocket reconnect in {wait:.1f}s...")
|
||||
system_logger.record(
|
||||
f"实时接收断开,{wait:.1f}s 后重连",
|
||||
detail="frontier WebSocket 连接已断开,正在自动重连。",
|
||||
level="warning",
|
||||
category="ws",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
|
||||
def _connect_sync(self, url: str):
|
||||
if not self._loop:
|
||||
return
|
||||
|
||||
def on_open(_ws):
|
||||
logger.info("IM WebSocket connected")
|
||||
system_logger.record(
|
||||
"实时接收通道已连接",
|
||||
detail="frontier WebSocket 已建立,可实时接收私信。",
|
||||
level="success",
|
||||
category="ws",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
|
||||
def on_message(_ws, message):
|
||||
asyncio.run_coroutine_threadsafe(self._dispatch(message), self._loop)
|
||||
|
||||
def on_error(_ws, error):
|
||||
if self._running:
|
||||
logger.warning(f"IM WebSocket error: {error}")
|
||||
system_logger.record(
|
||||
"实时接收通道报错",
|
||||
detail=f"{error}",
|
||||
level="error",
|
||||
category="ws",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
|
||||
def on_close(_ws, code, msg):
|
||||
logger.info(f"IM WebSocket closed: code={code}, msg={msg}")
|
||||
if self._running:
|
||||
system_logger.record(
|
||||
"实时接收通道关闭",
|
||||
detail=f"code={code}, msg={msg}",
|
||||
level="warning",
|
||||
category="ws",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
|
||||
headers = {
|
||||
"User-Agent": self.session.user_agent,
|
||||
"Pragma": "no-cache",
|
||||
"Cache-Control": "no-cache",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Sec-WebSocket-Protocol": "binary, base64, pbbp2",
|
||||
"Sec-WebSocket-Extensions": "permessage-deflate; client_max_window_bits",
|
||||
}
|
||||
ws_app = WebSocketApp(
|
||||
url,
|
||||
header=headers,
|
||||
cookie=self.session.cookie_header(),
|
||||
on_open=on_open,
|
||||
on_message=on_message,
|
||||
on_error=on_error,
|
||||
on_close=on_close,
|
||||
)
|
||||
with self._ws_lock:
|
||||
self._ws_app = ws_app
|
||||
try:
|
||||
ws_app.run_forever(origin="https://www.douyin.com", ping_interval=20, ping_timeout=10)
|
||||
finally:
|
||||
with self._ws_lock:
|
||||
if self._ws_app is ws_app:
|
||||
self._ws_app = None
|
||||
|
||||
async def _dispatch(self, raw):
|
||||
if isinstance(raw, str):
|
||||
payload = raw.encode("utf-8", errors="ignore")
|
||||
else:
|
||||
payload = raw
|
||||
items = parse_ws_payload(payload)
|
||||
for item in items:
|
||||
try:
|
||||
await self.on_message(item)
|
||||
except Exception as e:
|
||||
logger.debug(f"WS message handler error: {e}")
|
||||
system_logger.record(
|
||||
"实时消息处理失败",
|
||||
detail=f"处理收到的私信时出错:{e}",
|
||||
level="error",
|
||||
category="recv",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,129 @@
|
||||
"""运行时环境配置:住宅代理 + 浏览器显示。
|
||||
|
||||
用于解决「部署到云服务器后」两类常见问题:
|
||||
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)
|
||||
Reference in New Issue
Block a user