douyin-release-gate / verify (push) Failing after 18m54s
Add Douyin notification polling, event details, and manual multi-account private messaging. Refine environment memory settings, account operations, login collection recovery, and message UI; update tests and documentation.
1372 lines
75 KiB
Python
1372 lines
75 KiB
Python
"""Douyin browser control built on the shared restricted CDP contract."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
import http.client
|
|
import json
|
|
import logging
|
|
import math
|
|
import re
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timezone
|
|
from urllib.parse import quote, urlsplit
|
|
|
|
import websocket
|
|
|
|
from ..browser.cdp import (
|
|
CONTROL_TIMEOUT,
|
|
RESOLVE_TIMEOUT,
|
|
BrowserError,
|
|
CDPConnection,
|
|
)
|
|
from ..browser.response import (
|
|
BrowserLoginQRResponse,
|
|
BrowserMediaResponse,
|
|
BrowserResponse,
|
|
detect_challenge,
|
|
)
|
|
from ..runtime import ALIAS_RE
|
|
|
|
LOG = logging.getLogger("creatorhub.douyin")
|
|
ORIGIN = "https://www.douyin.com"
|
|
ORIGIN_URL = ORIGIN + "/"
|
|
# 扫码登录默认打开个人中心页:未登录访问会引导登录,登录后即停在账号个人中心。
|
|
LOGIN_PAGE_URL = ORIGIN + "/user/self"
|
|
CHAT_PAGE_URL = LOGIN_PAGE_URL
|
|
CHAT_PANEL_SCRIPT = r'''(()=>{
|
|
if(location.origin!=="https://www.douyin.com")throw Error("private-message origin changed");
|
|
const panel=document.querySelector("#imSaasContainerId");const rect=panel?.getBoundingClientRect();
|
|
return {ready:rect?.width>0&&rect?.height>0};
|
|
})()'''
|
|
CHAT_ENTRY_SCRIPT = r'''(()=>{
|
|
if(location.origin!=="https://www.douyin.com")throw Error("private-message origin changed");
|
|
const ready=()=>{const rect=document.querySelector("#imSaasContainerId")?.getBoundingClientRect();return rect?.width>0&&rect?.height>0};
|
|
if(ready())return {ready:true};
|
|
const entry=document.querySelector('[data-e2e="im-entry"] [data-e2e="something-button"]');
|
|
if(entry)entry.click();
|
|
return {ready:ready(),clicked:!!entry};
|
|
})()'''
|
|
CHAT_PANEL_TIMEOUT = 6.0
|
|
CHAT_PAGE_NAME = "__creatorhub_private_messages"
|
|
LOGIN_ORIGINS = frozenset(
|
|
{ORIGIN, "https://sso.douyin.com", "https://verify.snssdk.com", "https://verify.bytedance.com"}
|
|
)
|
|
LOGIN_SCREENSHOT_LIMIT = 8 << 20
|
|
LOGIN_RENDER_TIMEOUT = 12.0
|
|
LOGIN_RENDER_MIN_BYTES = 20 << 10
|
|
ORIGIN_NAVIGATE_TIMEOUT = 20.0
|
|
IDENTITY_URL = (
|
|
ORIGIN + "/aweme/v1/web/user/profile/self/?aid=6383&device_platform=webapp"
|
|
)
|
|
WORKS_PATH = "/aweme/v1/web/aweme/post/"
|
|
COMMENTS_PATH = "/aweme/v1/web/comment/list/"
|
|
# 作品/评论响应带播放信息等大字段,单页常超 1MB;media 走独立管线不在此限。
|
|
RESPONSE_LIMIT = 8 << 20
|
|
MEDIA_RESPONSE_LIMIT = 32 << 20
|
|
MEDIA_SOURCE_WAIT_MS = 15000
|
|
# 封面/头像图片体积小,限额远低于视频。
|
|
IMAGE_RESPONSE_LIMIT = 4 << 20
|
|
# 抖音图片 CDN 主机(封面、头像等静态资源)。
|
|
IMAGE_HOSTS = frozenset(
|
|
{
|
|
"p3-pc-sign.douyinpic.com",
|
|
"p9-pc-sign.douyinpic.com",
|
|
"p26-pc-sign.douyinpic.com",
|
|
"p3-pc.douyinpic.com",
|
|
"p9-pc.douyinpic.com",
|
|
"p26-pc.douyinpic.com",
|
|
"p3.douyinpic.com",
|
|
"p9.douyinpic.com",
|
|
"p26.douyinpic.com",
|
|
"p3-sign.douyinpic.com",
|
|
"p9-sign.douyinpic.com",
|
|
"p26-sign.douyinpic.com",
|
|
}
|
|
)
|
|
UID_RE = re.compile(r"^[1-9][0-9]{0,19}$")
|
|
ID_RE = re.compile(r"^[1-9][0-9]{0,63}$")
|
|
ACCOUNT_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$")
|
|
ACTIONS = frozenset(
|
|
{"follow", "dm", "reply_comment", "like_comment", "like_work", "repost"}
|
|
)
|
|
|
|
|
|
DouyinError = BrowserError
|
|
|
|
|
|
class DouyinLoginPending(BrowserError):
|
|
"""The browser is healthy but the user has not completed login yet."""
|
|
|
|
|
|
LISTENER_ERRORS = (
|
|
DouyinError,
|
|
OSError,
|
|
TypeError,
|
|
ValueError,
|
|
websocket.WebSocketException,
|
|
)
|
|
LISTENER_START_TIMEOUT = 30.0
|
|
|
|
|
|
class DouyinBrowser:
|
|
def __init__(
|
|
self,
|
|
endpoint: Callable[[str], str] | None = None,
|
|
*,
|
|
origin: str = ORIGIN,
|
|
url_validator: Callable[[object], bool] | None = None,
|
|
media_validator: Callable[[object], bool] | None = None,
|
|
media_selector: str = "video",
|
|
image_validator: Callable[[object], bool] | None = None,
|
|
target_id: str = "",
|
|
) -> None:
|
|
self.endpoint = endpoint
|
|
self.origin = origin
|
|
self.url_validator = url_validator or is_douyin_url
|
|
self.media_validator = media_validator or is_douyin_media_url
|
|
self.media_selector = media_selector
|
|
self.image_validator = image_validator or is_douyin_image_url
|
|
self.target_id = target_id.strip()
|
|
self._chat_targets: dict[str, str] = {}
|
|
self._chat_lock = threading.Lock()
|
|
|
|
@contextmanager
|
|
def connection(self, alias: str):
|
|
connection = self._connect(alias)
|
|
try:
|
|
yield connection
|
|
finally:
|
|
connection.close()
|
|
|
|
def _target_list(self, alias: str):
|
|
if not ALIAS_RE.fullmatch(alias):
|
|
raise DouyinError("browser alias is invalid")
|
|
if self.endpoint is None:
|
|
raise DouyinError("browser endpoint is not configured")
|
|
base = self.endpoint(alias).rstrip("/")
|
|
try:
|
|
parsed = urlsplit(base)
|
|
port = parsed.port
|
|
except (TypeError, ValueError) as exc:
|
|
raise DouyinError("restricted browser endpoint is invalid") from exc
|
|
if (
|
|
parsed.scheme != "http"
|
|
or not parsed.hostname
|
|
or not port
|
|
or parsed.username
|
|
or parsed.password
|
|
or parsed.query
|
|
or parsed.fragment
|
|
):
|
|
raise DouyinError("restricted browser endpoint is invalid")
|
|
http_connection = http.client.HTTPConnection(
|
|
parsed.hostname, port, timeout=CONTROL_TIMEOUT
|
|
)
|
|
try:
|
|
http_connection.request(
|
|
"GET", "/json/list", headers={"Accept": "application/json"}
|
|
)
|
|
response = http_connection.getresponse()
|
|
if response.status != 200:
|
|
raise DouyinError("browser target discovery failed")
|
|
payload = response.read(64 * 1024 + 1)
|
|
except (OSError, http.client.HTTPException) as exc:
|
|
raise DouyinError("restricted browser unavailable") from exc
|
|
finally:
|
|
http_connection.close()
|
|
if len(payload) > 64 * 1024:
|
|
raise DouyinError("browser target discovery response is too large")
|
|
try:
|
|
targets = json.loads(payload)
|
|
except json.JSONDecodeError as exc:
|
|
raise DouyinError("browser target discovery response is invalid") from exc
|
|
if not isinstance(targets, list):
|
|
raise DouyinError("browser target discovery response is invalid")
|
|
return parsed, port, targets
|
|
|
|
def _connect(self, alias: str) -> CDPConnection:
|
|
parsed, port, targets = self._target_list(alias)
|
|
page_targets = [
|
|
target
|
|
for target in targets
|
|
if isinstance(target, dict) and target.get("type") == "page"
|
|
]
|
|
if not self.target_id:
|
|
page_targets = [
|
|
target for target in page_targets
|
|
if not self._is_chat_target(alias, target)
|
|
]
|
|
if self.target_id:
|
|
matching_targets = [
|
|
target for target in page_targets if target.get("id") == self.target_id
|
|
]
|
|
if len(matching_targets) != 1:
|
|
raise DouyinError("configured browser page target is unavailable")
|
|
else:
|
|
matching_targets = [
|
|
target
|
|
for target in page_targets
|
|
if self.url_validator(target.get("url", ""))
|
|
]
|
|
if not matching_targets and len(page_targets) > 1:
|
|
LOG.warning(
|
|
"browser page selection failed alias=%s eligible_pages=%s",
|
|
alias, len(page_targets),
|
|
)
|
|
raise DouyinError("browser page target is unavailable; open Douyin in this browser")
|
|
# Tabs in one profile share login cookies. Match notification polling's
|
|
# stable selection rather than treating ordinary extra tabs as an error.
|
|
target = (
|
|
min(matching_targets, key=lambda target: (
|
|
target.get("url", "").split("?", 1)[0] != self.origin + "/user/self",
|
|
target.get("id", ""),
|
|
))
|
|
if matching_targets
|
|
else page_targets[0]
|
|
if page_targets
|
|
else None
|
|
)
|
|
if target is None:
|
|
raise DouyinError("browser page target is unavailable")
|
|
LOG.info("browser request target selected alias=%s target=%s pinned=%s", alias, target.get("id", ""), bool(self.target_id))
|
|
websocket_url = target.get("webSocketDebuggerUrl")
|
|
try:
|
|
parsed_ws = urlsplit(
|
|
websocket_url if isinstance(websocket_url, str) else ""
|
|
)
|
|
websocket_port = parsed_ws.port or port
|
|
except (TypeError, ValueError) as exc:
|
|
raise DouyinError("browser target websocket is invalid") from exc
|
|
if (
|
|
parsed_ws.scheme != "ws"
|
|
or not parsed_ws.hostname
|
|
or not websocket_port
|
|
or not parsed_ws.path.startswith("/devtools/page/")
|
|
):
|
|
raise DouyinError("browser target websocket is invalid")
|
|
if parsed_ws.hostname in ("localhost", "127.0.0.1", "::1"):
|
|
parsed_ws = parsed_ws._replace(netloc=f"{parsed.hostname}:{websocket_port}")
|
|
if parsed_ws.hostname != parsed.hostname or (
|
|
parsed_ws.port is not None and parsed_ws.port != port
|
|
):
|
|
raise DouyinError("browser target websocket host is invalid")
|
|
page_target = parsed_ws.geturl()
|
|
LOG.info(
|
|
"browser page selected alias=%s target=%s eligible_pages=%s matching_pages=%s pinned=%s",
|
|
alias, target.get("id"), len(page_targets), len(matching_targets), bool(self.target_id),
|
|
)
|
|
try:
|
|
socket = websocket.create_connection(
|
|
page_target,
|
|
timeout=CONTROL_TIMEOUT,
|
|
suppress_origin=True,
|
|
enable_multithread=True,
|
|
)
|
|
except (OSError, websocket.WebSocketException) as exc:
|
|
raise DouyinError("browser CDP connection failed") from exc
|
|
return CDPConnection(socket)
|
|
|
|
def _notification_browser(self, alias: str):
|
|
# Every page belongs to this alias's profile. Pin one authenticated-origin
|
|
# page so multiple ordinary tabs cannot make polling ambiguous.
|
|
_, _, targets = self._target_list(alias)
|
|
candidates = [t for t in targets if isinstance(t, dict)
|
|
and t.get("type") == "page"
|
|
and isinstance(t.get("id"), str)
|
|
and isinstance(t.get("url"), str)
|
|
and self.url_validator(t["url"])]
|
|
if not candidates:
|
|
raise DouyinError("notification page is unavailable; open Douyin in this browser")
|
|
chosen = min(candidates, key=lambda t: (t["url"].split("?", 1)[0] != self.origin + "/user/self", t["id"]))
|
|
LOG.info("notification page selected alias=%s target=%s", alias, chosen["id"])
|
|
return self._browser_for_target(chosen["id"])
|
|
|
|
def _browser_for_target(self, target_id: str):
|
|
return DouyinBrowser(self.endpoint, origin=self.origin, url_validator=self.url_validator, target_id=target_id)
|
|
|
|
def _is_chat_target(self, alias: str, target: dict) -> bool:
|
|
target_id = target.get("id", "")
|
|
if target_id in self._chat_targets.values():
|
|
return True
|
|
if target.get("url", "") != CHAT_PAGE_URL + "#" + CHAT_PAGE_NAME:
|
|
return False
|
|
with self._browser_for_target(target_id).connection(alias) as cdp:
|
|
return cdp.evaluate("window.name") == CHAT_PAGE_NAME
|
|
|
|
def _create_chat_target(self, alias: str, parsed, port: int) -> str:
|
|
connection = http.client.HTTPConnection(parsed.hostname, port, timeout=CONTROL_TIMEOUT)
|
|
try:
|
|
connection.request("PUT", "/json/new?" + quote(CHAT_PAGE_URL + "#" + CHAT_PAGE_NAME, safe=""))
|
|
response = connection.getresponse()
|
|
if response.status != 200:
|
|
raise DouyinError("private-message page could not be created")
|
|
target = json.loads(response.read(64 * 1024 + 1))
|
|
except (OSError, http.client.HTTPException, json.JSONDecodeError) as exc:
|
|
raise DouyinError("private-message page could not be created") from exc
|
|
finally:
|
|
connection.close()
|
|
target_id = target.get("id") if isinstance(target, dict) else None
|
|
if not isinstance(target_id, str) or not target_id:
|
|
raise DouyinError("private-message page target is invalid")
|
|
# Reserve the role before initialization so ordinary collection cannot
|
|
# accidentally select this new page while it is still loading.
|
|
self._chat_targets[alias] = target_id
|
|
with self._browser_for_target(target_id).connection(alias) as cdp:
|
|
cdp.command("Runtime.runIfWaitingForDebugger")
|
|
deadline = time.monotonic() + LOGIN_RENDER_TIMEOUT
|
|
while cdp.evaluate("location.origin") != self.origin:
|
|
if time.monotonic() >= deadline:
|
|
raise DouyinError("private-message page did not finish loading")
|
|
time.sleep(0.2)
|
|
cdp.evaluate(f"(() => {{ window.name = {json.dumps(CHAT_PAGE_NAME)}; return true; }})()")
|
|
LOG.info("private-message page created alias=%s target=%s", alias, target_id)
|
|
return target_id
|
|
|
|
def _chat_browser(self, alias: str):
|
|
with self._chat_lock:
|
|
parsed, port, targets = self._target_list(alias)
|
|
pages = [target for target in targets if isinstance(target, dict) and target.get("type") == "page"]
|
|
target_id = self._chat_targets.get(alias)
|
|
if target_id not in {target.get("id") for target in pages}:
|
|
marked = [target for target in pages if self._is_chat_target(alias, target)]
|
|
if len(marked) > 1:
|
|
raise DouyinError("browser has multiple private-message pages")
|
|
target_id = marked[0]["id"] if marked else self._create_chat_target(alias, parsed, port)
|
|
self._chat_targets[alias] = target_id
|
|
browser = self._browser_for_target(target_id)
|
|
with browser.connection(alias) as cdp:
|
|
# Only our marked tab is normalized; the user's/collector's main
|
|
# page is never navigated or used as a private-message fallback.
|
|
if cdp.evaluate("location.pathname") != "/user/self":
|
|
cdp.command("Page.enable")
|
|
cdp.command("Page.navigate", {"url": CHAT_PAGE_URL})
|
|
cdp.wait_event("Page.domContentEventFired", lambda params: True, timeout=5)
|
|
# Background tabs throttle JS timers. Poll short, synchronous CDP
|
|
# reads from Python instead of keeping Runtime.evaluate pending.
|
|
for attempt in range(2):
|
|
deadline = time.monotonic() + CHAT_PANEL_TIMEOUT
|
|
clicked = False
|
|
while time.monotonic() < deadline:
|
|
prepared = cdp.evaluate(CHAT_PANEL_SCRIPT if clicked else CHAT_ENTRY_SCRIPT)
|
|
if not isinstance(prepared, dict) or not isinstance(prepared.get("ready"), bool):
|
|
raise DouyinError("private-message entry response is invalid")
|
|
if prepared.get("ready") is True:
|
|
if attempt:
|
|
LOG.info("private-message panel recovered alias=%s target=%s", alias, target_id)
|
|
return browser
|
|
clicked = clicked or prepared.get("clicked") is True
|
|
time.sleep(0.2)
|
|
LOG.warning("private-message panel unavailable alias=%s target=%s clicked=%s refreshed=%s", alias, target_id, clicked, bool(attempt))
|
|
if attempt == 0:
|
|
# A tab opened before login can retain a guest UI even
|
|
# after the shared profile is authenticated. Reload only
|
|
# our chat tab; SDK identity is still verified afterward.
|
|
cdp.command("Page.enable")
|
|
cdp.command("Page.reload")
|
|
cdp.wait_event("Page.domContentEventFired", lambda params: True, timeout=5)
|
|
raise DouyinError("private-message panel did not open after refreshing the chat page; confirm the browser account is logged in")
|
|
|
|
def _navigate_to_origin(self, cdp: CDPConnection) -> None:
|
|
"""把浏览器带到抖音首页。
|
|
|
|
匿名浏览器从 about:blank 启动(origin 为 null),必须先导航到抖音才能在
|
|
该 origin 下 fetch API。首页 JS 会在落地后异步写入访客 cookie(ttwid 等)
|
|
并完成风控环境初始化:过早发起 API fetch 只会拿到空/截断响应
|
|
(实测 <3s 必空,~3s 后才有全量数据)。等 ttwid 出现且后续 cookie
|
|
不再变化后才返回;超时或连接异常不抛错,放行 fetch 让它给出真实错误。
|
|
"""
|
|
cdp.command("Page.enable")
|
|
cdp.notify("Page.navigate", {"url": ORIGIN_URL})
|
|
deadline = time.monotonic() + ORIGIN_NAVIGATE_TIMEOUT
|
|
# 阶段一:等页面到达抖音 origin。
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
if cdp.evaluate("location.origin") == self.origin:
|
|
break
|
|
except DouyinError:
|
|
return
|
|
time.sleep(0.5)
|
|
else:
|
|
return
|
|
# 阶段二:等访客 cookie 稳定(连续 1s 不再变化)。
|
|
settled_since = None
|
|
last_cookies = None
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
cookies = cdp.evaluate("document.cookie") or ""
|
|
except DouyinError:
|
|
return
|
|
if "ttwid=" not in cookies:
|
|
settled_since, last_cookies = None, None
|
|
time.sleep(0.5)
|
|
continue
|
|
now = time.monotonic()
|
|
if cookies != last_cookies:
|
|
last_cookies, settled_since = cookies, now
|
|
elif now - settled_since >= 1.0:
|
|
return
|
|
time.sleep(0.5)
|
|
|
|
def get(self, alias: str, target: str) -> BrowserResponse:
|
|
with self.connection(alias) as cdp:
|
|
if cdp.evaluate("location.origin") != self.origin:
|
|
# about:blank(匿名浏览器)或其它页面:先导航到抖音首页再 fetch。
|
|
self._navigate_to_origin(cdp)
|
|
expression = f"""(async()=>{{
|
|
try {{
|
|
// 抖音部分 API(如 aweme/post)要求 a_bogus 签名;首页加载的 byted_acrawler 提供签名器,
|
|
// 不存在或失败时退化为原请求(由服务端返回真实错误)。
|
|
let requestUrl = {json.dumps(target)};
|
|
try {{
|
|
const signer = window.byted_acrawler && window.byted_acrawler.frontierSign;
|
|
if (typeof signer === 'function') {{
|
|
const query = requestUrl.split('?')[1] || '';
|
|
const sign = signer(query);
|
|
const extra = new URLSearchParams();
|
|
if (sign && typeof sign === 'object') for (const key in sign) extra.set(key, String(sign[key]));
|
|
const signed = extra.toString();
|
|
if (signed) requestUrl = requestUrl + (requestUrl.includes('?') ? '&' : '?') + signed;
|
|
}}
|
|
}} catch (signError) {{}}
|
|
const r=await fetch(requestUrl,{{credentials:'include',redirect:'error'}});
|
|
if(!r.body)return {{status:r.status,body:'__empty_response__ signer='+(typeof window.byted_acrawler)+' hasFrontier='+(String(typeof (window.byted_acrawler&&window.byted_acrawler.frontierSign))),too_large:false}};
|
|
const reader=r.body.getReader(), decoder=new TextDecoder(); let size=0, body='';
|
|
for(;;){{const item=await reader.read();if(item.done)break;
|
|
if(size+item.value.byteLength>={RESPONSE_LIMIT}){{await reader.cancel();return {{too_large:true}};}}
|
|
size+=item.value.byteLength;body+=decoder.decode(item.value,{{stream:true}});
|
|
}}
|
|
body+=decoder.decode();return {{status:r.status,body,too_large:false}};
|
|
}} catch(e) {{
|
|
// fetch 网络异常/被页面拦截时必须透出原因,否则 CDP 层只剩 undefined,无法定位。
|
|
return {{status:0,body:'__fetch_error__:'+String((e&&e.message)||e),too_large:false}};
|
|
}}
|
|
}})()"""
|
|
result = None
|
|
for attempt in range(2):
|
|
result = cdp.evaluate(expression)
|
|
if (
|
|
not isinstance(result, dict)
|
|
or not isinstance(result.get("status"), int)
|
|
or result.get("status") not in {401, 403}
|
|
or attempt == 1
|
|
):
|
|
break
|
|
time.sleep(2)
|
|
if (
|
|
not isinstance(result, dict)
|
|
or result.get("too_large")
|
|
or not isinstance(result.get("status"), int)
|
|
):
|
|
LOG.warning("restricted browser fetch returned unexpected value: %r", result)
|
|
raise DouyinError("restricted browser fetch failed")
|
|
status = result["status"]
|
|
if 300 <= status < 400:
|
|
raise DouyinError("restricted browser fetch redirected")
|
|
body = result.get("body")
|
|
if not isinstance(body, str):
|
|
raise DouyinError("restricted browser fetch returned invalid body")
|
|
return BrowserResponse(status, body, detect_challenge(status, body))
|
|
|
|
def resolve(self, alias: str, target: str) -> str:
|
|
if not is_douyin_share_url(target):
|
|
raise DouyinError("restricted Douyin share URL is invalid")
|
|
with self.connection(alias) as cdp:
|
|
if cdp.evaluate("location.origin") not in {"null", self.origin}:
|
|
raise DouyinError("restricted browser origin changed")
|
|
try:
|
|
cdp.command("Page.enable")
|
|
cdp.notify("Page.navigate", {"url": target})
|
|
except (OSError, websocket.WebSocketException) as exc:
|
|
raise DouyinError("Douyin share URL navigation failed") from exc
|
|
deadline = time.monotonic() + RESOLVE_TIMEOUT
|
|
final_url = ""
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
event = cdp.wait_event(
|
|
"Page.frameNavigated",
|
|
lambda params: isinstance(params.get("frame"), dict)
|
|
and not params["frame"].get("parentId"),
|
|
timeout=max(0.01, deadline - time.monotonic()),
|
|
)
|
|
except BrowserError:
|
|
break
|
|
frame = event.get("params", {}).get("frame", {})
|
|
current_url = frame.get("url") if isinstance(frame, dict) else None
|
|
if isinstance(current_url, str) and is_douyin_content_url(current_url):
|
|
final_url = current_url
|
|
break
|
|
if not final_url:
|
|
raise DouyinError("Douyin share URL did not resolve to a supported page")
|
|
return final_url
|
|
|
|
def _wait_for_login_render(self, cdp: CDPConnection) -> None:
|
|
# Page.navigate acknowledges navigation before a document/view exists.
|
|
# Wait for the new DOM before requesting its first screenshot.
|
|
cdp.wait_event("Page.domContentEventFired", lambda params: True, timeout=LOGIN_RENDER_TIMEOUT)
|
|
deadline = time.monotonic() + LOGIN_RENDER_TIMEOUT
|
|
while True:
|
|
screenshot = cdp.command(
|
|
"Page.captureScreenshot", {"format": "png", "fromSurface": False}
|
|
)
|
|
if (
|
|
isinstance(screenshot, dict)
|
|
and isinstance(screenshot.get("data"), str)
|
|
and len(screenshot["data"]) >= LOGIN_RENDER_MIN_BYTES
|
|
):
|
|
return
|
|
if time.monotonic() >= deadline:
|
|
raise DouyinError("Douyin login page did not finish rendering")
|
|
time.sleep(0.5)
|
|
|
|
def login_qr(self, alias: str) -> BrowserLoginQRResponse:
|
|
with self.connection(alias) as cdp:
|
|
cdp.command("Page.enable")
|
|
navigation = cdp.command("Page.navigate", {"url": LOGIN_PAGE_URL})
|
|
if (
|
|
not isinstance(navigation, dict)
|
|
or not isinstance(navigation.get("frameId"), str)
|
|
or navigation.get("errorText")
|
|
):
|
|
raise DouyinError("Douyin login page navigation failed")
|
|
self._wait_for_login_render(cdp)
|
|
return self._capture_login_qr(alias)
|
|
|
|
def _capture_login_qr(self, alias: str) -> BrowserLoginQRResponse:
|
|
with self.connection(alias) as cdp:
|
|
opened = cdp.evaluate(
|
|
"""(() => {
|
|
const text = value => String(value || '').replace(/\\s+/g, '');
|
|
const candidate = [...document.querySelectorAll('button,a,[role="button"]')]
|
|
.find(element => /登录|扫码登录/.test(text(element.innerText || element.getAttribute('aria-label'))));
|
|
if (!candidate) return false;
|
|
candidate.click();
|
|
return true;
|
|
})()"""
|
|
)
|
|
if isinstance(opened, bool) and opened:
|
|
time.sleep(0.5)
|
|
qr_expression = """(() => {
|
|
const visible = element => {
|
|
const rect = element.getBoundingClientRect();
|
|
const style = getComputedStyle(element);
|
|
return rect.width >= 120 && rect.height >= 120 &&
|
|
style.visibility !== 'hidden' && style.display !== 'none';
|
|
};
|
|
const qrElement = [...document.querySelectorAll('img,canvas')].find(element => {
|
|
if (!visible(element)) return false;
|
|
if (element.tagName === 'IMG' && (!element.complete || element.naturalWidth === 0)) return false;
|
|
const rect = element.getBoundingClientRect();
|
|
const label = `${element.alt || ''} ${element.title || ''} ${element.getAttribute('aria-label') || ''}`;
|
|
return /二维码|qr.?code/i.test(label) ||
|
|
(element.tagName === 'CANVAS' && Math.abs(rect.width - rect.height) < 24);
|
|
});
|
|
if (!qrElement) return {origin: location.origin, qr_detected: false, clip: null};
|
|
const rect = qrElement.getBoundingClientRect();
|
|
const padding = 16;
|
|
const x = Math.max(0, Math.min(rect.x - padding, innerWidth - 1));
|
|
const y = Math.max(0, Math.min(rect.y - padding, innerHeight - 1));
|
|
return {
|
|
origin: location.origin,
|
|
qr_detected: true,
|
|
clip: {
|
|
x, y,
|
|
width: Math.min(innerWidth - x, rect.width + padding * 2),
|
|
height: Math.min(innerHeight - y, rect.height + padding * 2),
|
|
scale: 1,
|
|
},
|
|
};
|
|
})()"""
|
|
deadline = time.monotonic() + LOGIN_RENDER_TIMEOUT
|
|
while True:
|
|
page = cdp.evaluate(qr_expression)
|
|
if (
|
|
not isinstance(page, dict)
|
|
or page.get("origin") not in LOGIN_ORIGINS
|
|
or not isinstance(page.get("qr_detected"), bool)
|
|
):
|
|
raise DouyinError("Douyin login page origin is not allowed")
|
|
if page["qr_detected"]:
|
|
if not isinstance(page.get("clip"), dict):
|
|
raise DouyinError("Douyin login QR code bounds are invalid")
|
|
break
|
|
if time.monotonic() >= deadline:
|
|
raise DouyinError("Douyin login QR code did not appear; check the browser login page")
|
|
time.sleep(0.2)
|
|
screenshot_params = {"format": "png", "fromSurface": True, "clip": page["clip"]}
|
|
screenshot = cdp.command("Page.captureScreenshot", screenshot_params)
|
|
if not isinstance(screenshot, dict):
|
|
raise DouyinError("Douyin login screenshot is invalid")
|
|
body = screenshot.get("data")
|
|
if not isinstance(body, str) or not body:
|
|
raise DouyinError("Douyin login screenshot is invalid")
|
|
try:
|
|
decoded_size = len(base64.b64decode(body, validate=True))
|
|
except (ValueError, binascii.Error) as exc:
|
|
raise DouyinError("Douyin login screenshot is invalid") from exc
|
|
if decoded_size > LOGIN_SCREENSHOT_LIMIT:
|
|
raise DouyinError("Douyin login screenshot is too large")
|
|
return BrowserLoginQRResponse("image/png", body, bool(page["qr_detected"]))
|
|
|
|
def get_media(self, alias: str, target: str) -> BrowserMediaResponse:
|
|
if not self.media_validator(target):
|
|
raise DouyinError("restricted browser media target is invalid")
|
|
with self.connection(alias) as cdp:
|
|
navigation = cdp.command("Page.navigate", {"url": target})
|
|
frame_id = navigation.get("frameId")
|
|
if not isinstance(frame_id, str) or navigation.get("errorText"):
|
|
raise DouyinError("Douyin media page navigation failed")
|
|
target_path = urlsplit(target).path
|
|
deadline = time.monotonic() + CONTROL_TIMEOUT
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
page = cdp.evaluate(
|
|
"({url: location.href, readyState: document.readyState})"
|
|
)
|
|
except LISTENER_ERRORS:
|
|
time.sleep(0.1)
|
|
continue
|
|
if (
|
|
isinstance(page, dict)
|
|
and self.url_validator(page.get("url", ""))
|
|
and urlsplit(page["url"]).path == target_path
|
|
and page.get("readyState") in {"interactive", "complete"}
|
|
):
|
|
break
|
|
time.sleep(0.1)
|
|
else:
|
|
raise DouyinError("Douyin media page did not load")
|
|
result = cdp.evaluate(
|
|
f"""(async()=>{{
|
|
const deadline=Date.now()+{MEDIA_SOURCE_WAIT_MS};let source='';
|
|
while(Date.now()<deadline&&!source){{
|
|
source=[...document.querySelectorAll({json.dumps(self.media_selector)})]
|
|
.map(media=>media.currentSrc||media.src||'')
|
|
.find(value=>value&&!value.includes('/obj/douyin-pc-web/uuu_265.mp4'))||'';
|
|
if(!source)await new Promise(resolve=>setTimeout(resolve,100));
|
|
}}
|
|
if(!source)return {{error:'media_source_unavailable'}};
|
|
const r=await fetch(source,{{credentials:'include',redirect:'error'}});
|
|
if(!r.body)return {{status:r.status,content_type:r.headers.get('content-type')||'',body:''}};
|
|
const reader=r.body.getReader(), chunks=[]; let size=0;
|
|
for(;;){{const item=await reader.read();if(item.done)break;
|
|
if(size+item.value.byteLength>{MEDIA_RESPONSE_LIMIT}){{await reader.cancel();return {{too_large:true}};}}
|
|
size+=item.value.byteLength;chunks.push(item.value);
|
|
}}
|
|
const bytes=new Uint8Array(size); let offset=0;
|
|
for(const chunk of chunks){{bytes.set(chunk,offset);offset+=chunk.length;}}
|
|
let binary='';
|
|
for(let offset=0;offset<bytes.length;offset+=0x8000)binary+=String.fromCharCode(...bytes.subarray(offset,Math.min(offset+0x8000,bytes.length)));
|
|
return {{status:r.status,content_type:r.headers.get('content-type')||'',body:btoa(binary)}};
|
|
}})()"""
|
|
)
|
|
if not isinstance(result, dict) or result.get("too_large"):
|
|
raise DouyinError("Douyin media download failed")
|
|
if result.get("error"):
|
|
raise DouyinError(f"Douyin media download failed: {result['error']}")
|
|
status = result.get("status")
|
|
content_type = result.get("content_type")
|
|
body = result.get("body")
|
|
if (
|
|
not isinstance(status, int)
|
|
or not isinstance(content_type, str)
|
|
or not isinstance(body, str)
|
|
):
|
|
raise DouyinError("Douyin media response is invalid")
|
|
if status < 200 or status >= 300:
|
|
raise DouyinError("Douyin media response was not successful")
|
|
try:
|
|
decoded_size = len(base64.b64decode(body, validate=True))
|
|
except (ValueError, binascii.Error) as exc:
|
|
raise DouyinError("Douyin media response is invalid") from exc
|
|
if decoded_size > MEDIA_RESPONSE_LIMIT:
|
|
raise DouyinError("Douyin media response is too large")
|
|
return BrowserMediaResponse(status, content_type, body)
|
|
|
|
def get_image(self, alias: str, target: str) -> BrowserMediaResponse:
|
|
"""浏览器内 fetch 图片资源;与 get_media 分离,避免视频校验语义混用。"""
|
|
return fetch_douyin_image(self, alias, target)
|
|
|
|
def identity(self, alias: str, expected_uid: str | None = None) -> dict:
|
|
response = self.get(alias, IDENTITY_URL)
|
|
try:
|
|
payload = json.loads(response.body)
|
|
except json.JSONDecodeError as exc:
|
|
raise DouyinError("Douyin identity response is invalid") from exc
|
|
user = payload.get("user") if isinstance(payload, dict) else None
|
|
uid = str(user.get("uid", "")) if isinstance(user, dict) else ""
|
|
LOG.info("browser identity response alias=%s target=%s http_status=%s status_code=%s uid_present=%s expected_uid_set=%s", alias, self.target_id or "auto", response.status, payload.get("status_code") if isinstance(payload, dict) else "invalid", bool(uid), bool(expected_uid))
|
|
if response.status == 200 and isinstance(payload, dict) and (
|
|
payload.get("status_code") == 8
|
|
or (payload.get("status_code") == 0 and isinstance(user, dict) and uid == "0")
|
|
):
|
|
raise DouyinLoginPending("Douyin login is awaiting user confirmation")
|
|
if (
|
|
response.status != 200
|
|
or not isinstance(payload, dict)
|
|
or payload.get("status_code") != 0
|
|
or not UID_RE.fullmatch(uid)
|
|
):
|
|
raise DouyinError("Douyin login is not valid")
|
|
sec_uid = str(user.get("sec_uid", "")) if isinstance(user, dict) else ""
|
|
unique_id = str(user.get("unique_id", "")) if isinstance(user, dict) else ""
|
|
if not ACCOUNT_KEY_RE.fullmatch(sec_uid) or (
|
|
unique_id and not ACCOUNT_KEY_RE.fullmatch(unique_id)
|
|
):
|
|
raise DouyinError("Douyin identity response is invalid")
|
|
if expected_uid and uid != expected_uid:
|
|
raise DouyinError("Douyin identity does not match the expected account")
|
|
result = {
|
|
"uid": uid,
|
|
"sec_uid": sec_uid,
|
|
"unique_id": unique_id,
|
|
"nickname": user.get("nickname", "") if isinstance(user, dict) else "",
|
|
"short_id": user.get("short_id", "") if isinstance(user, dict) else "",
|
|
"avatar_url": next(iter((user.get("avatar_larger") or user.get("avatar_medium") or user.get("avatar_thumb") or {}).get("url_list") or []), ""),
|
|
"douyin_number": unique_id or str(user.get("short_id") or ""),
|
|
}
|
|
extra = payload.get("extra") if isinstance(payload, dict) else None
|
|
server_now = extra.get("now") if isinstance(extra, dict) else None
|
|
if isinstance(server_now, (int, float)) and not isinstance(server_now, bool):
|
|
try:
|
|
server_now_float = float(server_now)
|
|
if not math.isfinite(server_now_float) or server_now_float <= 0:
|
|
raise ValueError("server clock is not finite")
|
|
result["platform_now"] = datetime.fromtimestamp(
|
|
server_now_float / 1000, timezone.utc
|
|
).isoformat()
|
|
except (OverflowError, OSError, ValueError, TypeError) as exc:
|
|
raise DouyinError("Douyin platform clock is invalid") from exc
|
|
return result
|
|
|
|
def action_ownership(self, alias: str) -> dict | None:
|
|
value = self._evaluate(
|
|
alias,
|
|
"(() => { const raw = localStorage.getItem('__creatorhub_action_ownership_v1'); return raw === null ? null : JSON.parse(raw); })()",
|
|
)
|
|
if value is None:
|
|
return None
|
|
if not isinstance(value, dict):
|
|
raise DouyinError("browser action ownership marker is invalid")
|
|
return value
|
|
|
|
def set_action_ownership(self, alias: str, marker: dict) -> None:
|
|
if not isinstance(marker, dict):
|
|
raise DouyinError("browser action ownership marker is invalid")
|
|
expression = f"(() => {{ localStorage.setItem('__creatorhub_action_ownership_v1', {json.dumps(json.dumps(marker, separators=(',', ':')))}); return true; }})()"
|
|
self._evaluate(alias, expression)
|
|
|
|
def clear_action_ownership(self, alias: str, operation_id: str = "") -> None:
|
|
expected = json.dumps(operation_id)
|
|
expression = f"(() => {{ const key='__creatorhub_action_ownership_v1'; const raw=localStorage.getItem(key); if ({expected} === '' || raw === null || JSON.parse(raw).operation_id === {expected}) localStorage.removeItem(key); return true; }})()"
|
|
self._evaluate(alias, expression)
|
|
|
|
def action(
|
|
self,
|
|
alias: str,
|
|
expected_uid: str,
|
|
action: str,
|
|
target_uid: str = "",
|
|
comment_id: str = "",
|
|
work_id: str = "",
|
|
text: str = "",
|
|
confirm: bool = False,
|
|
) -> dict:
|
|
if not UID_RE.fullmatch(expected_uid):
|
|
raise DouyinError("expected account UID is invalid")
|
|
if target_uid and not UID_RE.fullmatch(target_uid):
|
|
raise DouyinError("target UID is invalid")
|
|
if work_id and not ID_RE.fullmatch(work_id):
|
|
raise DouyinError("work ID is invalid")
|
|
if comment_id and not ID_RE.fullmatch(comment_id):
|
|
raise DouyinError("comment ID is invalid")
|
|
if action not in ACTIONS:
|
|
raise DouyinError("Douyin action is invalid")
|
|
if action in {"follow", "dm"} and not target_uid:
|
|
raise DouyinError("target UID is required")
|
|
if action in {"like_work", "repost"} and not work_id:
|
|
raise DouyinError("work ID is required")
|
|
if action in {"reply_comment", "like_comment"} and (
|
|
not work_id or not comment_id or not target_uid
|
|
):
|
|
raise DouyinError("comment target is incomplete")
|
|
if action in {"dm", "reply_comment", "repost"} and (
|
|
not text.strip() or len(text) > 1000
|
|
):
|
|
raise DouyinError("action text is invalid")
|
|
action_browser = self._chat_browser(alias) if action == "dm" else self
|
|
if action == "dm":
|
|
identity = action_browser._evaluate(alias, private_identity_expression(expected_uid))
|
|
if not isinstance(identity, dict):
|
|
raise DouyinError("private-message identity response is invalid")
|
|
if identity.get("status") != "logged_in":
|
|
return {"status": "failed", "code": identity.get("code", "LOGIN_REQUIRED")}
|
|
else:
|
|
identity = action_browser.identity(alias, expected_uid)
|
|
if action == "follow":
|
|
params = {
|
|
"expected": expected_uid,
|
|
"target": target_uid,
|
|
"check": not confirm,
|
|
}
|
|
value = (
|
|
self._confirmed_evaluate(alias, follow_expression(params))
|
|
if confirm
|
|
else self._evaluate(alias, follow_expression(params))
|
|
)
|
|
elif action == "dm":
|
|
if not confirm:
|
|
return {
|
|
"action": "preview",
|
|
"sender_uid": identity["uid"],
|
|
"uid": target_uid,
|
|
"text": text,
|
|
}
|
|
params = {
|
|
"uid": target_uid,
|
|
"text": text,
|
|
"confirm": True,
|
|
"limit": 20,
|
|
"cursor": "9223372036854775807",
|
|
"action": "send",
|
|
}
|
|
value = action_browser._confirmed_evaluate(alias, im_expression(params, expected_uid))
|
|
else:
|
|
params = {
|
|
"action": action,
|
|
"expected": expected_uid,
|
|
"target": target_uid,
|
|
"work": work_id,
|
|
"comment": comment_id,
|
|
"text": text,
|
|
"confirm": confirm,
|
|
}
|
|
if not confirm:
|
|
return {
|
|
"action": "preview",
|
|
"sender_uid": identity["uid"],
|
|
"target_uid": target_uid,
|
|
"target_work_id": work_id,
|
|
"target_comment_id": comment_id,
|
|
"text": text,
|
|
}
|
|
value = self._confirmed_evaluate(alias, action_expression(params))
|
|
if not isinstance(value, dict):
|
|
raise DouyinError("Douyin action response is invalid")
|
|
return value
|
|
|
|
def _evaluate(self, alias: str, expression: str) -> object:
|
|
with self.connection(alias) as cdp:
|
|
if cdp.evaluate("location.origin") != self.origin:
|
|
raise DouyinError("restricted browser origin changed")
|
|
return cdp.evaluate(expression)
|
|
|
|
def _confirmed_evaluate(self, alias: str, expression: str) -> object:
|
|
try:
|
|
return self._evaluate(alias, expression)
|
|
except DouyinError as exc:
|
|
exc.uncertain = True
|
|
raise
|
|
|
|
def inbox(self, alias: str, expected_uid: str, checkpoints: dict | None = None) -> dict:
|
|
if not UID_RE.fullmatch(expected_uid):
|
|
raise DouyinError("inbox UID is invalid")
|
|
value = self._chat_browser(alias)._evaluate(alias, inbox_expression(expected_uid, checkpoints))
|
|
if not isinstance(value, dict):
|
|
raise DouyinError("Douyin inbox response is invalid")
|
|
return value
|
|
|
|
def message_history(
|
|
self,
|
|
alias: str,
|
|
expected_uid: str,
|
|
target_uid: str,
|
|
limit: int = 100,
|
|
cursor: str = "",
|
|
) -> dict:
|
|
if not UID_RE.fullmatch(expected_uid) or not UID_RE.fullmatch(target_uid):
|
|
raise DouyinError("message history UID is invalid")
|
|
if not 1 <= limit <= 200 or not isinstance(cursor, str) or len(cursor) > 500:
|
|
raise DouyinError("message history request is invalid")
|
|
self.identity(alias, expected_uid)
|
|
value = self._evaluate(
|
|
alias,
|
|
message_history_expression(
|
|
{"uid": target_uid, "cursor": cursor, "limit": limit}, expected_uid
|
|
),
|
|
)
|
|
if not isinstance(value, dict):
|
|
raise DouyinError("Douyin message history response is invalid")
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _notice_id(value: object) -> str:
|
|
if type(value) is int and value > 0:
|
|
return str(value)
|
|
if isinstance(value, str) and ID_RE.fullmatch(value):
|
|
return value
|
|
return ""
|
|
|
|
|
|
|
|
|
|
def _notice_display_details(users: list, work: dict, uid: str, work_id: str) -> dict:
|
|
def text(value, label):
|
|
if value is None:
|
|
return ""
|
|
if not isinstance(value, str) or len(value) > 8192:
|
|
raise DouyinError(f"notification {label} is invalid")
|
|
return value
|
|
|
|
user = next((u for u in users if isinstance(u, dict) and uid and
|
|
_notice_id(u.get("uid") or u.get("user_id")) == uid), {})
|
|
video = work.get("video") or {}
|
|
if not isinstance(video, dict):
|
|
raise DouyinError("notification video is invalid")
|
|
cover = video.get("cover") or {}
|
|
if not isinstance(cover, dict):
|
|
raise DouyinError("notification cover is invalid")
|
|
urls = cover.get("url_list") or []
|
|
images = work.get("images") or []
|
|
if not isinstance(images, list):
|
|
raise DouyinError("notification images are invalid")
|
|
if not urls and images:
|
|
image = images[0]
|
|
if not isinstance(image, dict):
|
|
raise DouyinError("notification image is invalid")
|
|
display = image.get("display_image") or image
|
|
if not isinstance(display, dict):
|
|
raise DouyinError("notification image is invalid")
|
|
urls = display.get("url_list") or []
|
|
if not isinstance(urls, list):
|
|
raise DouyinError("notification cover URLs are invalid")
|
|
author = work.get("author") or {}
|
|
if not isinstance(author, dict):
|
|
raise DouyinError("notification work author is invalid")
|
|
route = "note" if work.get("aweme_type") == 68 else "video"
|
|
return {
|
|
"interactor_name": text(user.get("nickname"), "user nickname"),
|
|
"interactor_sec_uid": text(user.get("sec_uid"), "user secUID"),
|
|
"work_author_uid": _notice_id(author.get("uid") or author.get("user_id")),
|
|
"work_cover_url": text(urls[0], "cover URL") if urls else "",
|
|
"work_url": f"{ORIGIN_URL.rstrip('/')}/{route}/{work_id}" if work_id else "",
|
|
}
|
|
|
|
|
|
def normalize_notice(
|
|
notice: object, gateway_received_at: str | None = None
|
|
) -> dict | None:
|
|
if not isinstance(notice, dict):
|
|
raise DouyinError("notification detail is invalid")
|
|
if notice.get("comment"):
|
|
kind, event_type = "comment", "comment"
|
|
elif notice.get("follow"):
|
|
kind, event_type = "follow", "follow"
|
|
elif notice.get("digg"):
|
|
kind, event_type = "digg", "like"
|
|
elif notice.get("share"):
|
|
kind, event_type = "share", "repost"
|
|
else:
|
|
return None
|
|
detail = notice.get(kind)
|
|
if not isinstance(detail, dict):
|
|
raise DouyinError("notification detail payload is invalid")
|
|
users = detail.get("from_user") or []
|
|
if isinstance(users, dict):
|
|
users = [users]
|
|
if not isinstance(users, list):
|
|
raise DouyinError("notification users are invalid")
|
|
comment = detail.get("comment") or {}
|
|
if not isinstance(comment, dict):
|
|
raise DouyinError("notification comment is invalid")
|
|
if not users and comment.get("user"):
|
|
users = [comment["user"]]
|
|
uids = {
|
|
uid
|
|
for user in users
|
|
if isinstance(user, dict)
|
|
for uid in [_notice_id(user.get("uid") or user.get("user_id"))]
|
|
if uid
|
|
}
|
|
event_key = _notice_id(notice.get("nid_str") or notice.get("nid"))
|
|
if not event_key:
|
|
raise DouyinError("notification ID is invalid")
|
|
work = detail.get("aweme") or notice.get("aweme") or {}
|
|
if not isinstance(work, dict):
|
|
work = {}
|
|
comment_id = next(
|
|
(
|
|
value
|
|
for value in (
|
|
notice.get("comment_id"),
|
|
detail.get("comment_id"),
|
|
comment.get("cid_str"),
|
|
comment.get("cid"),
|
|
)
|
|
if _notice_id(value)
|
|
),
|
|
"",
|
|
)
|
|
work_id = next(
|
|
(
|
|
value
|
|
for value in (
|
|
notice.get("aweme_id"),
|
|
detail.get("aweme_id"),
|
|
work.get("aweme_id"),
|
|
work.get("id"),
|
|
)
|
|
if _notice_id(value)
|
|
),
|
|
"",
|
|
)
|
|
result = {
|
|
"event_key": event_key,
|
|
"event_type": event_type,
|
|
"interactor_uid": next(iter(uids), "") if len(uids) == 1 else "",
|
|
"comment_id": _notice_id(comment_id),
|
|
"work_id": _notice_id(work_id),
|
|
}
|
|
result.update(_notice_display_details(users, work, result["interactor_uid"], result["work_id"]))
|
|
if event_type == "comment":
|
|
comment_text = comment.get("text", "")
|
|
if not isinstance(comment_text, str) or len(comment_text) > 100000:
|
|
raise DouyinError("notification comment text is invalid")
|
|
if comment_text:
|
|
result["message_text"] = comment_text
|
|
result["message_type"] = "text"
|
|
create_time = notice.get("create_time")
|
|
if isinstance(create_time, (int, float)) and not isinstance(create_time, bool):
|
|
try:
|
|
timestamp = float(create_time)
|
|
if math.isfinite(timestamp) and timestamp > 0:
|
|
result["platform_event_at"] = datetime.fromtimestamp(
|
|
timestamp, timezone.utc
|
|
).isoformat()
|
|
except (OverflowError, OSError, ValueError) as exc:
|
|
raise DouyinError("notification timestamp is invalid") from exc
|
|
if isinstance(gateway_received_at, str) and gateway_received_at:
|
|
result["gateway_received_at"] = gateway_received_at
|
|
return result
|
|
|
|
|
|
def is_douyin_url(value: object) -> bool:
|
|
if not isinstance(value, str):
|
|
return False
|
|
try:
|
|
parsed = urlsplit(value)
|
|
return (
|
|
parsed.scheme == "https"
|
|
and parsed.hostname == "www.douyin.com"
|
|
and parsed.port is None
|
|
and parsed.username is None
|
|
and parsed.password is None
|
|
)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
|
|
def is_douyin_content_url(value: object) -> bool:
|
|
if not isinstance(value, str):
|
|
return False
|
|
try:
|
|
parsed = urlsplit(value)
|
|
parts = parsed.path.strip("/").split("/")
|
|
return (
|
|
parsed.scheme == "https"
|
|
and parsed.hostname == "www.douyin.com"
|
|
and parsed.port is None
|
|
and parsed.username is None
|
|
and parsed.password is None
|
|
and not parsed.fragment
|
|
and len(parts) == 2
|
|
and parts[0] == "video"
|
|
and bool(ID_RE.fullmatch(parts[1]))
|
|
)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
|
|
def is_douyin_share_url(value: object) -> bool:
|
|
if not isinstance(value, str):
|
|
return False
|
|
try:
|
|
parsed = urlsplit(value)
|
|
if (
|
|
parsed.scheme != "https"
|
|
or parsed.port is not None
|
|
or parsed.username is not None
|
|
or parsed.password is not None
|
|
or parsed.fragment
|
|
):
|
|
return False
|
|
if parsed.hostname == "v.douyin.com":
|
|
return bool(parsed.path.strip("/")) and len(value) <= 2048
|
|
return parsed.hostname == "www.douyin.com" and is_douyin_content_url(value)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
|
|
def is_douyin_media_url(value: object) -> bool:
|
|
if not isinstance(value, str):
|
|
return False
|
|
try:
|
|
parsed = urlsplit(value)
|
|
return (
|
|
parsed.scheme == "https"
|
|
and parsed.hostname == "www.douyin.com"
|
|
and parsed.port is None
|
|
and parsed.username is None
|
|
and parsed.password is None
|
|
and parsed.path.startswith("/video/")
|
|
)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
|
|
def is_douyin_image_url(value: object) -> bool:
|
|
if not isinstance(value, str):
|
|
return False
|
|
try:
|
|
parsed = urlsplit(value)
|
|
return (
|
|
parsed.scheme == "https"
|
|
and parsed.hostname in IMAGE_HOSTS
|
|
and parsed.port is None
|
|
and parsed.username is None
|
|
and parsed.password is None
|
|
and len(value) <= 2048
|
|
)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
|
|
def fetch_douyin_image(self: object, alias: str, target: str) -> BrowserMediaResponse:
|
|
"""在浏览器内 fetch 图片(封面/头像),返回 base64 字节;复用 media 的响应结构。"""
|
|
if not self.image_validator(target): # type: ignore[attr-defined]
|
|
raise DouyinError("restricted browser image target is invalid")
|
|
with self.connection(alias) as cdp: # type: ignore[attr-defined]
|
|
result = cdp.evaluate(
|
|
f"""(async()=>{{
|
|
try {{
|
|
// douyinpic CDN 返回 Access-Control-Allow-Origin:'*',跨站 fetch 不能带凭据,
|
|
// credentials:'include' 会直接 TypeError Failed to fetch;公共图片无需 cookies。
|
|
const r=await fetch({json.dumps(target)},{{credentials:'omit',redirect:'error'}});
|
|
if(!r.body)return {{status:r.status,content_type:r.headers.get('content-type')||'',body:'',too_large:false}};
|
|
const reader=r.body.getReader(), chunks=[]; let size=0;
|
|
for(;;){{const item=await reader.read();if(item.done)break;
|
|
if(size+item.value.byteLength>{IMAGE_RESPONSE_LIMIT}){{await reader.cancel();return {{too_large:true}};}}
|
|
size+=item.value.byteLength;chunks.push(item.value);
|
|
}}
|
|
const bytes=new Uint8Array(size); let offset=0;
|
|
for(const chunk of chunks){{bytes.set(chunk,offset);offset+=chunk.length;}}
|
|
let binary='';
|
|
for(let offset=0;offset<bytes.length;offset+=0x8000)binary+=String.fromCharCode(...bytes.subarray(offset,Math.min(offset+0x8000,bytes.length)));
|
|
return {{status:r.status,content_type:r.headers.get('content-type')||'',body:btoa(binary),too_large:false}};
|
|
}} catch(e) {{
|
|
return {{status:0,content_type:'',body:'',error:String((e&&e.message)||e)}};
|
|
}}
|
|
}})()"""
|
|
)
|
|
if not isinstance(result, dict) or result.get("too_large"):
|
|
raise DouyinError("Douyin image download failed")
|
|
if result.get("error"):
|
|
raise DouyinError(f"Douyin image download failed: {result['error']}")
|
|
status = result.get("status")
|
|
content_type = result.get("content_type")
|
|
body = result.get("body")
|
|
if (
|
|
not isinstance(status, int)
|
|
or not isinstance(content_type, str)
|
|
or not isinstance(body, str)
|
|
):
|
|
raise DouyinError("Douyin image response is invalid")
|
|
if status < 200 or status >= 300:
|
|
raise DouyinError("Douyin image response was not successful")
|
|
try:
|
|
decoded_size = len(base64.b64decode(body, validate=True))
|
|
except (ValueError, binascii.Error) as exc:
|
|
raise DouyinError("Douyin image response is invalid") from exc
|
|
if decoded_size > IMAGE_RESPONSE_LIMIT:
|
|
raise DouyinError("Douyin image response is too large")
|
|
if not content_type.startswith("image/"):
|
|
raise DouyinError("Douyin image response is not an image")
|
|
return BrowserMediaResponse(status, content_type, body)
|
|
|
|
|
|
def notice_ids(event: dict) -> list[str]:
|
|
try:
|
|
payload = json.loads(event["payload"])
|
|
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
raise DouyinError("notification push payload is invalid") from exc
|
|
if not isinstance(payload, dict):
|
|
raise DouyinError("notification push payload is invalid")
|
|
if event.get("service") == 20313:
|
|
notices = payload.get("notices", [])
|
|
if not isinstance(notices, list):
|
|
raise DouyinError("notification push list is invalid")
|
|
selected = [
|
|
notice
|
|
for notice in notices
|
|
if isinstance(notice, dict)
|
|
and {str(group) for group in notice.get("effect_groups", [])}
|
|
& {"960", "961"}
|
|
]
|
|
elif event.get("service") == 20003 and payload.get("notice_type") in {
|
|
45,
|
|
31,
|
|
9009,
|
|
9002,
|
|
514,
|
|
9067,
|
|
}:
|
|
selected = [payload]
|
|
else:
|
|
return []
|
|
ids: list[str] = []
|
|
for notice in selected:
|
|
value = notice.get("notice_id_str")
|
|
if not isinstance(value, str) or not value.isascii() or not value.isdecimal():
|
|
raise DouyinError("notification ID is invalid")
|
|
ids.append(value)
|
|
return list(dict.fromkeys(ids))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def dispose_expression(key: str) -> str:
|
|
return f"(() => {{ const key={json.dumps(key)}; window[key]?.dispose(); delete window[key]; return true; }})()"
|
|
|
|
|
|
|
|
|
|
def retry_expression(key: str, ids: list[str]) -> str:
|
|
if (
|
|
not isinstance(key, str)
|
|
or not key
|
|
or not ids
|
|
or any(not isinstance(value, str) or not value for value in ids)
|
|
):
|
|
raise ValueError("notification delivery IDs are invalid")
|
|
return RETRY_SCRIPT.replace("KEY_VALUE", json.dumps(key)).replace(
|
|
"IDS_VALUE", json.dumps(ids)
|
|
)
|
|
|
|
|
|
def details_expression(ids: list[str]) -> str:
|
|
if not ids or any(
|
|
not isinstance(value, str) or not value.isascii() or not value.isdecimal()
|
|
for value in ids
|
|
):
|
|
raise ValueError("notification IDs are invalid")
|
|
return DETAILS_SCRIPT.replace("IDS_VALUE", json.dumps(ids))
|
|
|
|
|
|
def follow_expression(params: dict) -> str:
|
|
return FOLLOW_SCRIPT.replace("PARAMS_VALUE", json.dumps(params, ensure_ascii=True))
|
|
|
|
|
|
def im_expression(params: dict, expected_uid: str) -> str:
|
|
return IM_SCRIPT.replace("EXPECTED_UID_VALUE", json.dumps(expected_uid)).replace(
|
|
"PARAMS_VALUE", json.dumps(params, ensure_ascii=True)
|
|
)
|
|
|
|
|
|
def inbox_expression(expected_uid: str, checkpoints: dict | None = None) -> str:
|
|
return INBOX_SCRIPT.replace("EXPECTED_UID_VALUE", json.dumps(expected_uid)).replace("CHECKPOINTS_VALUE", json.dumps(checkpoints or {})).replace("INBOX_MODE_VALUE", '"inbox"')
|
|
|
|
|
|
def private_identity_expression(expected_uid: str) -> str:
|
|
return INBOX_SCRIPT.replace("EXPECTED_UID_VALUE", json.dumps(expected_uid)).replace("CHECKPOINTS_VALUE", "{}").replace("INBOX_MODE_VALUE", '"identity"')
|
|
|
|
|
|
def message_history_expression(params: dict, expected_uid: str) -> str:
|
|
return MESSAGE_HISTORY_SCRIPT.replace(
|
|
"EXPECTED_UID_VALUE", json.dumps(expected_uid)
|
|
).replace("PARAMS_VALUE", json.dumps(params, ensure_ascii=True))
|
|
|
|
|
|
def action_expression(params: dict) -> str:
|
|
return ACTION_SCRIPT.replace("PARAMS_VALUE", json.dumps(params, ensure_ascii=True))
|
|
|
|
|
|
|
|
|
|
WAIT_SCRIPT = r"""(async()=>{const s=window[KEY_VALUE];if(!s||s.C.frontierInstance!==s.f||String(s.f._options.deviceID)!==s.uid)throw Error("LISTENER_INVALID");
|
|
const ready=()=>s.queue.filter(e=>!e.delivered);if(!ready().length)await new Promise(resolve=>{const timer=setTimeout(done,2000);function done(){clearTimeout(timer);s.wake=null;resolve();}s.wake=done;});
|
|
const result=ready();result.forEach(e=>{e.delivered=true;});return JSON.stringify(result);})()"""
|
|
|
|
|
|
ACK_SCRIPT = r"""((ids)=>{const s=window[KEY_VALUE];if(!s||!Array.isArray(ids))throw Error("LISTENER_INVALID");const wanted=new Set(ids);s.queue=s.queue.filter(e=>!wanted.has(e?.delivery_id));return true;})(IDS_VALUE)"""
|
|
|
|
|
|
RETRY_SCRIPT = r"""((ids)=>{const s=window[KEY_VALUE];if(!s||!Array.isArray(ids))throw Error("LISTENER_INVALID");const wanted=new Set(ids);s.queue.forEach(e=>{if(wanted.has(e?.delivery_id))e.delivered=false;});if(s.wake)s.wake();return true;})(IDS_VALUE)"""
|
|
|
|
|
|
DETAILS_SCRIPT = r"""(async ids=>{if(location.origin!=="https://www.douyin.com")throw Error("WRONG_ORIGIN");const chunks=window.webpackChunkdouyin_web;if(!chunks)throw Error("RUNTIME_MISSING");let req;chunks.push([["creatorhub-detail-"+Date.now()],{},r=>{req=r;}]);chunks.pop();const entry=Object.entries(req.m||{}).find(([,f])=>/getNoticeDetail\s*:/.test(String(f)));const sdk=entry&&req(entry[0]),client=window.axiosInstance;if(typeof sdk?.getNoticeDetail!=="function"||!client?.interceptors?.response)throw Error("SDK_NOT_READY");const params={id_list:JSON.stringify(ids.map(n=>({notice_id_str:n,type:0}))),is_mark_read:0};let raw,timer;const observer=client.interceptors.response.use(response=>{const config=response.config||{},url=new URL(config.url||"",location.origin),xhr=response.request;if(config.params?.id_list===params.id_list&&config.params.is_mark_read===0&&url.origin===location.origin&&url.pathname==="/aweme/v1/web/notice/detail/"&&xhr&&(!xhr.responseType||xhr.responseType==="text")&&typeof xhr.responseText==="string")raw={status:response.status,body:xhr.responseText};return response;});try{await Promise.race([sdk.getNoticeDetail(params),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error("TIMEOUT")),20000);})]);}finally{clearTimeout(timer);client.interceptors.response.eject(observer);}if(!raw)throw Error("RAW_RESPONSE_MISSING");return JSON.stringify(raw);})(IDS_VALUE)"""
|
|
|
|
|
|
FOLLOW_SCRIPT = r"""(async()=>{const p=PARAMS_VALUE;let sent=false;try{if(location.origin!=="https://www.douyin.com")throw Error("WRONG_ORIGIN");const get=async path=>{const r=await fetch(path,{credentials:"include",redirect:"error",signal:AbortSignal.timeout(15000)}),v=await r.json();if(!r.ok||v.status_code!==0)throw Error("READ_FAILED");return v;};const prefix="?device_platform=webapp&aid=6383&channel=channel_pc_web";const self=await get("/aweme/v1/web/user/profile/self/"+prefix);if(String(self.user?.uid)!==p.expected)throw Error("IDENTITY_MISMATCH");if(p.target===p.expected)throw Error("SELF_TARGET");const path="/aweme/v1/web/user/profile/other/"+prefix+"&user_id="+encodeURIComponent(p.target),profile=(await get(path)).user;if(String(profile?.uid)!==p.target)throw Error("TARGET_MISMATCH");if(![0,1,2].includes(profile.follow_status))throw Error("UNKNOWN_FOLLOW_STATE");if(profile.follow_status!==0||p.check)return {status:"succeeded",action:p.check?"checked":"already_following",follow_status:profile.follow_status,evidence:{target_uid:p.target,follow_status:String(profile.follow_status)}};sent=true;let result;try{const r=await fetch("/aweme/v1/web/commit/follow/user/"+prefix,{method:"POST",credentials:"include",redirect:"error",headers:{"Content-Type":"application/x-www-form-urlencoded;charset=UTF-8"},body:new URLSearchParams({user_id:p.target,type:"1"}),signal:AbortSignal.timeout(10000)});result=await r.json();if(!r.ok)throw Error("POST_UNCERTAIN");}catch(e){if(e.message==="POST_UNCERTAIN")throw e;throw Error("POST_UNCERTAIN");}if(result.status_code!==0){const e=Error("BUSINESS_REJECTED");e.definitive=true;throw e;}if(![1,2].includes(result.follow_status))throw Error("POST_UNCONFIRMED");const verify=(await get(path)).user;return {status:String(verify?.uid)===p.target&&[1,2].includes(verify.follow_status)?"succeeded":"unknown",action:"followed",evidence:{target_uid:p.target,follow_status:String(verify?.follow_status??"")}};}catch(e){const code=["WRONG_ORIGIN","IDENTITY_MISMATCH","SELF_TARGET","TARGET_MISMATCH","UNKNOWN_FOLLOW_STATE"].includes(e.message)?e.message:(e.message==="BUSINESS_REJECTED"?e.message:"REQUEST_FAILED");return {status:sent?(e.definitive?"failed":"unknown"):"failed",code};}})()"""
|
|
|
|
|
|
ACTION_SCRIPT = r"""(async()=>{const p=PARAMS_VALUE;let sent=false;const fail=(code,definitive=false)=>{const e=Error(code);e.definitive=definitive;throw e;};try{if(location.origin!=="https://www.douyin.com")fail("WRONG_ORIGIN");const qs="device_platform=webapp&aid=6383&channel=channel_pc_web";const get=async path=>{const r=await fetch(path,{credentials:"include",redirect:"error",signal:AbortSignal.timeout(15000)});let v;try{v=await r.json();}catch(_){fail("INVALID_RESPONSE");}if(!r.ok||v.status_code!==0)fail("READ_FAILED");return v;};const post=async(path,data)=>{const r=await fetch(path,{method:"POST",credentials:"include",redirect:"error",headers:{"Content-Type":"application/x-www-form-urlencoded;charset=UTF-8"},body:new URLSearchParams(data),signal:AbortSignal.timeout(10000)});let v;try{v=await r.json();}catch(_){fail(r.ok?"INVALID_RESPONSE":"POST_UNCERTAIN");}if(!r.ok)fail("POST_UNCERTAIN");if(v.status_code===undefined)fail("POST_UNCONFIRMED");if(v.status_code!==0)fail("BUSINESS_REJECTED",true);return v;};const self=await get("/aweme/v1/web/user/profile/self/?"+qs);if(String(self.user?.uid)!==p.expected)fail("IDENTITY_MISMATCH");if((p.action==="follow"||p.action==="dm")&&p.target===p.expected)fail("SELF_TARGET");const detail=async()=>get("/aweme/v1/web/aweme/detail/?"+qs+"&aweme_id="+encodeURIComponent(p.work));const findComment=async()=>{let cursor=0;for(let page=0;page<100;page++){const response=await get("/aweme/v1/web/comment/list/?"+qs+"&aweme_id="+encodeURIComponent(p.work)+"&cursor="+cursor+"&count=50");if(!Array.isArray(response.comments)||typeof response.has_more!=="boolean")fail("READ_FAILED");const list=response.comments;const comment=list.find(c=>String(c?.cid||c?.comment_id||"")===p.comment);if(comment){if(String(comment.aweme_id||comment.item_id||p.work)!==p.work||String(comment.user?.uid||comment.user_id||"")!==p.target)fail("TARGET_MISMATCH");return comment;}if(!response.has_more)break;const next=Number(response.cursor);if(!Number.isSafeInteger(next)||next<=cursor)fail("PAGINATION_INVALID");cursor=next;}fail("TARGET_NOT_FOUND");};if(p.action==="like_work"){const before=(await detail()).aweme_detail;if(String(before?.aweme_id)!==p.work)fail("TARGET_MISMATCH");if(Number(before.user_digged)===1)return {status:"succeeded",action:"already_liked",evidence:{work_id:p.work,user_digged:"1"}};sent=true;const result=await post("/aweme/v1/web/commit/item/digg/?"+qs,{aweme_id:p.work,type:"1",item_type:"0"});if(Number(result.is_digg)!==1)fail("POST_UNCONFIRMED");const after=(await detail()).aweme_detail;return {status:Number(after?.user_digged)===1?"succeeded":"unknown",action:"liked",evidence:{work_id:p.work,user_digged:String(after?.user_digged??"")}};}if(p.action==="like_comment"){const comment=await findComment();if(Number(comment.user_digged)===1)return {status:"succeeded",action:"already_liked",evidence:"comment.user_digged"};sent=true;await post("/aweme/v1/web/comment/digg?"+qs,{cid:p.comment,aweme_id:p.work,digg_type:"1",channel_id:"0",app_name:"aweme",item_type:"0",level:"1"});const after=await findComment();return {status:Number(after.user_digged)===1?"succeeded":"unknown",action:"liked_comment",evidence:{comment_id:p.comment,work_id:p.work,user_digged:String(after.user_digged??"")}};}if(p.action==="reply_comment"){await findComment();sent=true;const result=await post("/aweme/v1/web/comment/publish?"+qs,{app_name:"aweme",enter_from:"pc_web",previous_page:"video",reply_id:p.comment,reply_to_reply_id:"0",aweme_id:p.work,text:p.text,text_extra:"[]",comment_send_celltime:"0",comment_video_celltime:"0"});const posted=result.comment||result.comment_info||result.data?.comment;const postedID=String(posted?.cid||posted?.comment_id||"");const postedWork=String(posted?.aweme_id||posted?.item_id||"");const postedAuthor=String(posted?.user?.uid||posted?.user_id||"");const postedText=String(posted?.text??posted?.content??"");if(!posted||!postedID||postedWork!==p.work||postedAuthor!==p.expected||postedText!==p.text)fail("UNCONFIRMED");return {status:"succeeded",action:"replied",evidence:{comment_id:postedID,work_id:postedWork,author_uid:postedAuthor,text:postedText}};}if(p.action==="repost"){if(!p.text)fail("TEXT_REQUIRED");fail("REPOST_TEXT_UNSUPPORTED");}fail("ACTION_NOT_IMPLEMENTED");}catch(e){const code=String(e.message||"REQUEST_FAILED");return {status:sent?(e.definitive?"failed":"unknown"):"failed",code};}})()"""
|
|
|
|
|
|
MESSAGE_HISTORY_SCRIPT = r"""(async()=>{const p=PARAMS_VALUE;const fail=code=>{throw Error(code);};try{if(location.origin!=="https://www.douyin.com")fail("WRONG_ORIGIN");const response=await fetch("/aweme/v1/web/user/profile/self/?device_platform=webapp&aid=6383",{credentials:"include",signal:AbortSignal.timeout(15000)});if(!response.ok)fail("LOGIN_CHECK_FAILED");const profile=await response.json();if(profile.status_code!==0||String(profile.user?.uid)!==EXPECTED_UID_VALUE)fail("IDENTITY_MISMATCH");if(String(profile.user.uid)===p.uid)fail("SELF_TARGET");let service;for(const key of Object.keys(window).filter(k=>k.startsWith("@pc-im/im:"))){const chunks=window[key];if(!Array.isArray(chunks))continue;let req;chunks.push([["creatorhub-history-"+Date.now()],{},r=>{req=r;}]);chunks.pop();for(const [id,module] of Object.entries(req?.c||{})){if(!String(req.m?.[id]||"").includes("getOrCreatePrivateConversationByUid"))continue;for(const exported of Object.values(module.exports||{}))if(exported?.instance?.imSdkService)service=exported.instance.imSdkService;}if(service)break;}if(!service)fail("IM_SDK_NOT_READY");const sdk=service.imSdkManager.getImSdkInstance();if(!sdk)fail("IM_SDK_NOT_READY");const conversation=sdk.getConversationList().find(c=>c.type===1&&String(c.toParticipantUserId)===p.uid);if(!conversation)fail("CONVERSATION_NOT_FOUND");const meta=c=>({id:String(c.id),short_id:String(c.shortId),uid:String(c.toParticipantUserId),type:c.type});let cursor=p.cursor?String(p.cursor):undefined,previousCursor=cursor??"",hasMore=false;for(let page=0;page<20;page++){const request={conversation,limit:Math.min(50,p.limit)};if(cursor!==undefined)request.cursor=cursor;const result=await sdk.getMessagesByConversation(request);if(!result||!Array.isArray(result.messages))fail("MESSAGE_HISTORY_INVALID");hasMore=Boolean(result.hasMore);if(!hasMore)break;const next=result.cursor,key=String(next?.toString?.()??next??"");if(!key||key===previousCursor)fail("MESSAGE_HISTORY_CURSOR_INVALID");previousCursor=key;cursor=next;}const messages=conversation.getMessageList();if(!Array.isArray(messages))fail("MESSAGE_HISTORY_INVALID");const pack=m=>{const ext=m.ext||{};const rawTime=ext["s:server_message_create_time"]||"";return {server_id:String(m.serverId||""),sender_uid:String(m.sender||""),message_type:String(m.type??""),content:typeof m.content==="string"?m.content.slice(0,100000):m.content,created_at:/^[0-9]+$/.test(String(rawTime))?String(rawTime):null,server_status:m.serverStatus??null};};return {status:"succeeded",action:"history",history_source:"im_sdk_pull",history_cursor:hasMore?String(cursor??""):"",history_has_more:hasMore,account_uid:String(profile.user.uid),conversation:meta(conversation),messages:messages.slice(-p.limit).map(pack)};}catch(e){const known=["WRONG_ORIGIN","LOGIN_CHECK_FAILED","IDENTITY_MISMATCH","SELF_TARGET","IM_SDK_NOT_READY","CONVERSATION_NOT_FOUND","MESSAGE_HISTORY_INVALID","MESSAGE_HISTORY_CURSOR_INVALID"];return {status:"failed",code:known.includes(e.message)?e.message:"SDK_REQUEST_FAILED"};}})()"""
|
|
|
|
INBOX_SCRIPT = r"""(async()=>{
|
|
const expected=EXPECTED_UID_VALUE, checkpoints=CHECKPOINTS_VALUE, mode=INBOX_MODE_VALUE;
|
|
const fail=code=>{throw Error(code);};
|
|
try {
|
|
if(location.origin!=="https://www.douyin.com")fail("WRONG_ORIGIN");
|
|
let service;
|
|
for(const key of Object.keys(window).filter(k=>k.startsWith("@pc-im/im:"))){
|
|
const chunks=window[key];if(!Array.isArray(chunks))continue;let req;
|
|
chunks.push([["creatorhub-inbox-"+crypto.randomUUID()],{},r=>{req=r;}]);chunks.pop();
|
|
for(const [id,module] of Object.entries(req?.c||{})){
|
|
if(!String(req.m?.[id]||"").includes("getOrCreatePrivateConversationByUid"))continue;
|
|
for(const exported of Object.values(module.exports||{}))if(exported?.instance?.imSdkService)service=exported.instance.imSdkService;
|
|
}
|
|
if(service)break;
|
|
}
|
|
const sdk=service?.imSdkManager?.getImSdkInstance();if(!sdk||sdk.disposed)fail("IM_SDK_NOT_READY");
|
|
if(String(sdk.ctx?.option?.userId||"")!==expected)fail("ACCOUNT_IDENTITY_MISMATCH");
|
|
if(mode==="identity")return {status:"logged_in",uid:expected};
|
|
const conversations=sdk.getConversationList().filter(c=>c.type===1);
|
|
if(conversations.length>100)fail("IM_INBOX_LIMIT_EXCEEDED");
|
|
const messages=[],next={};
|
|
for(const conversation of conversations){
|
|
const peer=String(conversation.toParticipantUserId||"");
|
|
if(!/^[1-9][0-9]{0,19}$/.test(peer)||peer===expected)fail("CONVERSATION_IDENTITY_INVALID");
|
|
const latest=String(conversation.lastMessage?.serverId||"");next[peer]=latest;
|
|
if(latest&&checkpoints[peer]===latest)continue;
|
|
const list=conversation.getMessageList();if(!Array.isArray(list))fail("MESSAGE_HISTORY_INVALID");
|
|
for(const m of list.slice(-50)){
|
|
if(m.type===1||m.type===2)continue;
|
|
const server=String(m.serverId||"");if(!/^[1-9][0-9]{0,19}$/.test(server))continue;
|
|
const sender=String(m.sender||m.ext?.["s:sender_uid"]||"");
|
|
if(sender!==peer&&sender!==expected)fail("MESSAGE_IDENTITY_INVALID");
|
|
const rawTime=m.ext?.["s:server_message_create_time"];
|
|
messages.push({peer_uid:peer,peer_name:String(conversation.coreInfo?.name||""),server_id:server,client_id:String(m.clientId||""),sender_uid:sender,message_type:String(m.type),content:m.content,created_at:/^[0-9]+$/.test(String(rawTime||""))?String(rawTime):null});
|
|
}
|
|
}
|
|
return {status:"succeeded",account_uid:expected,messages,checkpoints:next};
|
|
}catch(e){
|
|
if(mode==="identity"&&e.message==="IM_SDK_NOT_READY")return {status:"manual_login",reason:"awaiting_login"};
|
|
return {status:"failed",code:String(e.message||"IM_INBOX_FAILED")};
|
|
}
|
|
})()"""
|
|
|
|
IM_SCRIPT = r"""(async()=>{const p=PARAMS_VALUE;let sent=false;const fail=(code,definitive=false)=>{const e=Error(code);e.definitive=definitive;throw e;};try{if(location.origin!=="https://www.douyin.com")fail("WRONG_ORIGIN");let service;for(const key of Object.keys(window).filter(k=>k.startsWith("@pc-im/im:"))){const chunks=window[key];if(!Array.isArray(chunks))continue;let req;chunks.push([["creatorhub-im-"+Date.now()],{},r=>{req=r;}]);for(const [id,module] of Object.entries(req?.c||{})){if(!String(req.m[id]).includes("getOrCreatePrivateConversationByUid"))continue;for(const exported of Object.values(module.exports||{}))if(exported?.instance?.imSdkService)service=exported.instance.imSdkService;}}if(!service)fail("IM_SDK_NOT_READY");const sdk=service.imSdkManager.getImSdkInstance();if(!sdk||sdk.disposed)fail("IM_SDK_NOT_READY");const sender=String(sdk.ctx?.option?.userId||"");if(sender!==EXPECTED_UID_VALUE)fail("IDENTITY_MISMATCH");if(sender===p.uid)fail("SELF_TARGET");const meta=c=>({id:String(c.id),short_id:String(c.shortId),uid:String(c.toParticipantUserId),type:c.type});const pack=m=>({server_id:String(m.serverId||""),client_id:m.clientId||null,sender:String(m.sender),type:m.type,content:m.content,created_at:m.createdAt,server_status:m.serverStatus});let conversation=sdk.getConversationList().find(c=>c.type===1&&String(c.toParticipantUserId)===p.uid);if(!conversation&&p.action==="send")conversation=await service.conversationManager.getOrCreatePrivateConversationByUid(p.uid);if(!conversation)fail("CONVERSATION_NOT_FOUND");if(conversation.type!==1||String(conversation.toParticipantUserId)!==p.uid)fail("TARGET_MISMATCH");if(p.action!=="send"||!p.confirm)return {status:"preview",action:"preview",sender_uid:sender,uid:p.uid,text:p.text,conversation:meta(conversation)};const message=await sdk.createMessage({conversation,type:7,content:JSON.stringify({aweType:700,type:0,richTextInfos:[],text:p.text})});if(!message||typeof message.sendFunc!=="function")fail("MESSAGE_BUILD_FAILED");sent=true;const result=await Promise.race([sdk.sendMessage({message}),new Promise((_,reject)=>setTimeout(()=>reject(Error("MESSAGE_UNCONFIRMED")),10000))]);if(result?.success===false){const network=Number(result.statusCode)===1008;const definitive=!network&&result.checkCode!=null&&String(result.checkCode)!=="0"&&String(result.checkCode)!=="";const e=Error(network?"SDK_NETWORK_ERROR":definitive?"MESSAGE_REJECTED":"SDK_SEND_UNCONFIRMED");e.definitive=definitive;e.detail={status_code:result.statusCode??null,check_code:String(result.checkCode??""),check_message:String(result.checkMessage??result.msg??""),send_status:result.status??null};throw e;}if(result?.success!==true)fail("MESSAGE_UNCONFIRMED");const packed=pack(message);if(!packed.server_id&&!packed.client_id)fail("MESSAGE_UNCONFIRMED");return {status:"succeeded",action:"send",success:true,status_code:result?.statusCode??null,check_code:String(result?.checkCode??""),conversation:meta(conversation),message:packed,evidence:{conversation_id:String(conversation.id),message_server_id:packed.server_id,message_client_id:String(packed.client_id??"")}};}catch(e){const known=["WRONG_ORIGIN","LOGIN_CHECK_FAILED","IDENTITY_MISMATCH","SELF_TARGET","IM_SDK_NOT_READY","CONVERSATION_NOT_FOUND","TARGET_MISMATCH","MESSAGE_BUILD_FAILED","MESSAGE_REJECTED","SDK_NETWORK_ERROR","SDK_SEND_UNCONFIRMED","MESSAGE_UNCONFIRMED"];return {status:sent?(e.definitive?"failed":"unknown"):"failed",code:known.includes(e.message)?e.message:"SDK_REQUEST_FAILED",...(e.detail||{})};}})()"""
|