Files
creator-hub/browser_gateway/platform/douyin.py
T

1417 lines
78 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");
}
let cache=window.__creatorhubPrivatePeerProfiles;
if(!cache||cache.sdk!==sdk||cache.uid!==expected||cache.source!=='im_user_info'){
cache={sdk,uid:expected,source:'im_user_info',profiles:new Map()};window.__creatorhubPrivatePeerProfiles=cache;
}
const contacts=new Map(conversations.map(c=>{
const peer=String(c.toParticipantUserId),participants=c.firstPageParticipant?.participants;
const participant=Array.isArray(participants)?participants.find(p=>String(p.user_id)===peer):null;
return [peer,typeof participant?.sec_uid==='string'?participant.sec_uid:''];
}));
const saveProfile=(peer,secUid,name,error)=>{
const value={peer_uid:peer,peer_name:name,error:error?'私信昵称读取失败(UID '+peer+'):'+String(error):''};
cache.profiles.set(peer,{value,secUid,expiresAt:Date.now()+(error?30000:300000)});
};
const pending=[];
for(const [peer,secUid] of contacts){
const saved=cache.profiles.get(peer);if(saved&&saved.secUid===secUid&&saved.expiresAt>Date.now())continue;
if(!secUid)saveProfile(peer,secUid,'','会话缺少可信的对方 secUID');else pending.push({peer,secUid});
}
const batches=[];for(let i=0;i<pending.length;i+=30)batches.push(pending.slice(i,i+30));
await Promise.all(batches.map(async batch=>{
try{
const manager=service.context?.requestManager;if(typeof manager?.request!=='function')throw Error('私信资料读取接口不可用');
const response=await manager.request({method:'POST',url:'/aweme/v1/web/im/user/info/?version_code=170400',
headers:{'Content-Type':'application/x-www-form-urlencoded'},data:{sec_user_ids:JSON.stringify(batch.map(p=>p.secUid))},timeout:10000});
if(response?.status_code!==0)throw Error('昵称读取平台错误 '+response?.status_code);
if(!Array.isArray(response.data))throw Error('昵称响应缺少资料列表');
const users=new Map();
for(const user of response.data){
const uid=String(user?.uid||'');
if(!batch.some(p=>p.peer===uid&&p.secUid===user.sec_uid))throw Error('昵称资料 UID 或 secUID 与会话对方不一致');
if(users.has(uid))throw Error('昵称响应包含重复的用户资料');
users.set(uid,user);
}
for(const {peer,secUid} of batch){
const user=users.get(peer),name=typeof user?.nickname==='string'?user.nickname.trim():'';
saveProfile(peer,secUid,name,name?'':'聊天客户端未返回真实昵称');
}
}catch(error){for(const {peer,secUid} of batch)saveProfile(peer,secUid,'',error);}
}));
const peers=[...contacts.keys()].map(peer=>cache.profiles.get(peer).value);
if(String(sdk.ctx?.option?.userId||'')!==expected)fail('ACCOUNT_IDENTITY_MISMATCH');
const peerNames=new Map(peers.map(peer=>[peer.peer_uid,peer.peer_name]));
for(const conversation of conversations){
const peer=String(conversation.toParticipantUserId);
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:peerNames.get(peer),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,peers,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||{})};}})()"""