"""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()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= 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= 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||{})};}})()"""