"""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 import deque from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager from datetime import datetime, timezone from urllib.parse import 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_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 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/" RESPONSE_LIMIT = 1 << 20 MEDIA_RESPONSE_LIMIT = 32 << 20 MEDIA_SOURCE_WAIT_MS = 15000 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 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", 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.target_id = target_id.strip() @contextmanager def connection(self, alias: str): connection = self._connect(alias) try: yield connection finally: connection.close() def _connect(self, alias: str) -> CDPConnection: 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") page_targets = [ target for target in targets if isinstance(target, dict) and target.get("type") == "page" ] 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 len(matching_targets) > 1 or ( not matching_targets and len(page_targets) > 1 ): raise DouyinError("browser has more than one page target") target = ( matching_targets[0] if matching_targets else page_targets[0] if page_targets else None ) if target is None: raise DouyinError("browser page target is unavailable") 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() 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 get(self, alias: str, target: str) -> BrowserResponse: with self.connection(alias) as cdp: if cdp.evaluate("location.origin") != self.origin: raise DouyinError("restricted browser origin changed") expression = f"""(async()=>{{ const r=await fetch({json.dumps(target)},{{credentials:'include',redirect:'error'}}); if(!r.body)return {{status:r.status,body:'',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}}; }})()""" 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) ): 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: 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: navigation = cdp.command("Page.navigate", {"url": ORIGIN_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) 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) page = cdp.evaluate( """(() => { 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; 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, }, }; })()""" ) 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") screenshot_params = {"format": "png", "fromSurface": False} if isinstance(page.get("clip"), dict): screenshot_params["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 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 "" 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 "", } 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=(',', ':')))})" 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") identity = self.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 = self._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 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 class DouyinSubscription: def __init__( self, browser: DouyinBrowser, alias: str, uid: str, pending: list[dict] | None = None, ) -> None: self.browser = browser self.alias = alias self.uid = uid # A stable key lets a fresh wrapper dispose a stale listener left on the # same page. Recovery disposes the old state before installing a new one. self.key = "__creatorhub_notice_sub_" + alias self._connection_lock = threading.RLock() self.queue: deque[dict] = deque(pending or []) self.condition = threading.Condition() self.stopped = threading.Event() self._epoch = 0 self._initial_boundary_pending = True self._browser_inflight: set[str] = set() self._browser_inflight_lock = threading.Lock() self._recovery_lock = threading.Lock() self._detail_pool = ThreadPoolExecutor( max_workers=4, thread_name_prefix=f"creatorhub-notice-details-{alias}" ) try: self.connection, boundary_at = self._open_listener_until_ready() except Exception: self._detail_pool.shutdown(wait=False, cancel_futures=True) raise # Events observed before the consumer establishes its baseline boundary # are explicitly classified as baseline and must never trigger writes. self._put( { "kind": "baseline", "reason": "listener_start", "uid": self.uid, "boundary_at": boundary_at, "boundary_source": "douyin_identity_extra_now", } ) self.thread = threading.Thread( target=self._run, name=f"creatorhub-notices-{alias}", daemon=True ) self.thread.start() def _open_listener_until_ready(self) -> tuple[CDPConnection, str]: deadline = time.monotonic() + LISTENER_START_TIMEOUT delay = 0.5 while True: try: return self._open_listener() except LISTENER_ERRORS as exc: if time.monotonic() >= deadline: raise LOG.info( "Douyin notification listener is not ready; retrying", extra={"alias": self.alias, "uid": self.uid, "reason": str(exc)}, ) if self.stopped.wait(delay): raise DouyinError("Douyin notification listener stopped") from exc delay = min(delay * 2, 5.0) def _open_listener(self) -> tuple[CDPConnection, str]: connection = self.browser._connect(self.alias) try: result = connection.evaluate(install_expression(self.key, self.uid)) if not isinstance(result, str): raise DouyinError("notification listener returned invalid state") state = json.loads(result) if not isinstance(state, dict) or not state.get("connected"): raise DouyinError("Douyin notification connection is not ready") identity = self.browser.identity(self.alias, self.uid) boundary_at = identity.get("platform_now") if not isinstance(boundary_at, str) or not boundary_at: raise DouyinError("Douyin platform event boundary is unavailable") return connection, boundary_at except LISTENER_ERRORS: try: connection.evaluate(dispose_expression(self.key)) except LISTENER_ERRORS: LOG.debug( "failed to dispose a partially installed Douyin listener", extra={"alias": self.alias, "uid": self.uid}, exc_info=True, ) finally: connection.close() raise def _get_connection(self) -> CDPConnection: with self._connection_lock: return self.connection def _run(self) -> None: wait = wait_expression(self.key) while not self.stopped.is_set(): try: raw = self._get_connection().evaluate(wait) events = json.loads(raw) if isinstance(raw, str) else raw if not isinstance(events, list): raise DouyinError("notification listener returned invalid events") initial_boundary = self._initial_boundary_pending self._initial_boundary_pending = False for event in events: if ( initial_boundary and isinstance(event, dict) and event.get("kind") == "push" ): event = dict(event) event["baseline"] = True self._handle(event) except ( DouyinError, OSError, TypeError, ValueError, websocket.WebSocketException, ) as exc: if self.stopped.is_set(): break self._put({"kind": "error", "reason": str(exc)}) self._request_recovery() self._dispose_current() def _request_recovery(self) -> None: if not self._recovery_lock.acquire(blocking=False): return try: self._recover() finally: self._recovery_lock.release() def _recover(self) -> None: # Dispose the old same-page handlers before installing a new state with # the stable key; disposing afterward would remove the fresh handlers. with self._connection_lock: old = self.connection try: old.evaluate(dispose_expression(self.key)) except (DouyinError, OSError, websocket.WebSocketException): LOG.debug( "old notification listener disposal was not available", exc_info=True, ) finally: old.close() self._initial_boundary_pending = True delay = 0.5 while not self.stopped.is_set(): if self.stopped.wait(delay): return try: connection, boundary_at = self._open_listener() except ( DouyinError, OSError, TypeError, ValueError, websocket.WebSocketException, ) as exc: self._put({"kind": "error", "reason": str(exc)}) delay = min(delay * 2, 30.0) continue with self._connection_lock: self.connection = connection self._epoch += 1 self._put({"kind": "reconnected", "uid": self.uid}) # The boundary is a separate event so the control plane never treats # a transport reconnect itself as proof that new notices are safe. self._put( { "kind": "baseline", "reason": "listener_reconnected", "uid": self.uid, "boundary_at": boundary_at, "boundary_source": "douyin_identity_extra_now", } ) return def _dispose_current(self) -> None: connection = self._get_connection() try: connection.evaluate(dispose_expression(self.key)) except (DouyinError, OSError, websocket.WebSocketException): LOG.debug("notification listener disposal was not available", exc_info=True) finally: connection.close() def _handle(self, event: object) -> None: if not isinstance(event, dict): raise DouyinError("notification event is invalid") kind = event.get("kind") delivery_id = event.get("delivery_id") if kind in {"open", "close"}: self._put({"kind": kind}) if delivery_id and hasattr(self, "_connection_lock"): self._ack_browser_event(delivery_id, self._get_connection()) if kind == "close": raise DouyinError("Douyin notification connection closed") return if kind == "error": self._put( { "kind": "error", "reason": str(event.get("reason", "notification continuity gap")), "continuity": "gap", } ) if delivery_id and hasattr(self, "_connection_lock"): self._ack_browser_event(delivery_id, self._get_connection()) return if kind != "push": raise DouyinError("notification event kind is invalid") event = dict(event) event.setdefault("gateway_received_at", datetime.now(timezone.utc).isoformat()) if not hasattr(self, "_browser_inflight_lock"): self._browser_inflight_lock = threading.Lock() if not hasattr(self, "_browser_inflight"): self._browser_inflight = set() if isinstance(delivery_id, str) and delivery_id: with self._browser_inflight_lock: if delivery_id in self._browser_inflight: return self._browser_inflight.add(delivery_id) epoch = getattr(self, "_epoch", 0) baseline = bool(event.get("baseline")) if hasattr(self, "_detail_pool"): self._detail_pool.submit(self._process_push_async, event, epoch, baseline) else: success = self._process_push(event, self._get_connection(), baseline) if delivery_id: if success: self._ack_browser_event(delivery_id, self._get_connection()) else: if not self._retry_browser_event( delivery_id, self._get_connection() ): self._request_recovery() if delivery_id: with self._browser_inflight_lock: self._browser_inflight.discard(delivery_id) def _handle_push_error( self, error: Exception, delivery_id: object, connection: CDPConnection | None, ) -> None: self._put({"kind": "error", "reason": str(error), "continuity": "gap"}) if not isinstance(delivery_id, str): return if connection is None or not self._retry_browser_event(delivery_id, connection): self._request_recovery() def _process_push( self, event: dict, connection: CDPConnection, baseline: bool ) -> bool: try: ids = notice_ids(event) except ( DouyinError, KeyError, TypeError, ValueError, websocket.WebSocketException, ) as exc: self._put({"kind": "error", "reason": str(exc), "continuity": "gap"}) return False # Detail lookup is isolated per platform notification. A malformed or # temporarily unavailable detail must not discard its siblings. complete = True for notice_id in ids: try: for notice in self._details([notice_id], connection): normalized = normalize_notice( notice, event.get("gateway_received_at") ) if normalized is not None: self._put( { "kind": "notice", "notice": normalized, "baseline": baseline, } ) except ( DouyinError, OSError, TypeError, ValueError, websocket.WebSocketException, ) as exc: self._put( { "kind": "error", "reason": str(exc), "event_key": notice_id, "continuity": "gap", } ) complete = False return complete def _process_push_async(self, event: dict, epoch: int, baseline: bool) -> None: connection: CDPConnection | None = None success = False delivery_id = event.get("delivery_id") try: connection = self.browser._connect(self.alias) success = self._process_push( event, connection, baseline or epoch != getattr(self, "_epoch", 0), ) if isinstance(delivery_id, str): if success: self._ack_browser_event(delivery_id, connection) else: # A malformed detail is a continuity gap, not a retryable # delivery. Drop the bad delivery, record the gap, and # establish a new baseline before accepting later events. self._ack_browser_event(delivery_id, connection) self._request_recovery() except LISTENER_ERRORS as exc: self._handle_push_error(exc, delivery_id, connection) finally: if connection is not None: connection.close() if isinstance(delivery_id, str): with self._browser_inflight_lock: self._browser_inflight.discard(delivery_id) def _ack_browser_event( self, delivery_id: object, connection: CDPConnection ) -> None: if not isinstance(delivery_id, str) or not delivery_id: return try: result = connection.evaluate(ack_expression(self.key, [delivery_id])) if result not in (True, "true"): raise DouyinError("notification acknowledgement failed") except ( DouyinError, OSError, TypeError, ValueError, websocket.WebSocketException, ): LOG.warning("notification acknowledgement failed", exc_info=True) def _retry_browser_event( self, delivery_id: object, connection: CDPConnection ) -> bool: if not isinstance(delivery_id, str) or not delivery_id: return False try: result = connection.evaluate(retry_expression(self.key, [delivery_id])) if result not in (True, "true"): raise DouyinError("notification retry acknowledgement failed") return True except ( DouyinError, OSError, TypeError, ValueError, websocket.WebSocketException, ): LOG.warning("notification retry marker failed", exc_info=True) return False def pending(self) -> list[dict]: with self.condition: return list(self.queue) def _details( self, ids: list[str], connection: CDPConnection | None = None ) -> list[dict]: expected = set(ids) connection = connection or self._get_connection() for attempt in range(2): raw = connection.evaluate(details_expression(ids)) if not isinstance(raw, str): raise DouyinError("notification details returned invalid data") try: response = json.loads(raw) body = json.loads(response["body"]) except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: raise DouyinError("notification details could not be parsed") from exc if ( not isinstance(response, dict) or not isinstance(body, dict) or response.get("status") != 200 or body.get("status_code") != 0 ): raise DouyinError("notification details request failed") notices = body.get("notice_list_v2") if not isinstance(notices, list): raise DouyinError("notification details list is invalid") normalized: list[dict] = [] found: set[str] = set() for notice in notices: if ( not isinstance(notice, dict) or str(notice.get("user_id")) != self.uid ): raise DouyinError("notification identity changed") nid = str(notice.get("nid_str") or notice.get("nid") or "") if not nid.isascii() or not nid.isdecimal(): raise DouyinError("notification ID is invalid") found.add(nid) normalized.append(notice) if found == expected: return normalized if attempt == 0: time.sleep(0.2) continue raise DouyinError("notification details are incomplete") raise DouyinError("notification details are incomplete") def _put(self, item: dict) -> None: item = dict(item) item.setdefault("delivery_id", uuid.uuid4().hex) if item.get("kind") == "notice": notice = item.get("notice") if isinstance(notice, dict): notice.setdefault( "gateway_received_at", datetime.now(timezone.utc).isoformat() ) with self.condition: if len(self.queue) >= 1000: # Keep the backlog visible; replace only one oldest item with a # gap marker instead of silently dropping the entire queue. dropped = getattr(self, "_overflow_count", 0) + 1 self._overflow_count = dropped self.queue[0] = { "kind": "error", "reason": "notification queue overflow", "continuity": "gap", "dropped": dropped, "delivery_id": uuid.uuid4().hex, } else: self.queue.append(item) self.condition.notify_all() def poll(self, limit: int, wait_seconds: float) -> list[dict]: deadline = time.monotonic() + wait_seconds with self.condition: while not self.queue and not self.stopped.is_set(): remaining = deadline - time.monotonic() if remaining <= 0: break self.condition.wait(remaining) return list(self.queue)[:limit] def ack(self, delivery_ids: list[str]) -> None: ids = {value for value in delivery_ids if isinstance(value, str) and value} if not ids: return with self.condition: self.queue = deque( item for item in self.queue if item.get("delivery_id") not in ids ) def stop(self) -> None: self.stopped.set() with self._connection_lock: connection = self.connection connection.close() with self.condition: self.condition.notify_all() self.thread.join(timeout=2.0) if self.thread.is_alive(): LOG.error( "notification listener did not stop within timeout", extra={"alias": self.alias}, ) pool = getattr(self, "_detail_pool", None) if pool is not None: pool.shutdown(wait=True, cancel_futures=True) class SubscriptionManager: def __init__(self, browser: DouyinBrowser) -> None: self.browser = browser self._lock = threading.RLock() self._items: dict[str, DouyinSubscription] = {} def start(self, alias: str, uid: str) -> dict: with self._lock: previous = self._items.pop(alias, None) pending = previous.pending() if previous else [] if previous: previous.stop() item = DouyinSubscription(self.browser, alias, uid, pending=pending) self._items[alias] = item return {"connected": True, "alias": alias, "uid": uid} def poll(self, alias: str, limit: int, wait_seconds: float) -> list[dict]: with self._lock: item = self._items.get(alias) if not item: raise DouyinError("notification listener is not running") return item.poll(limit, wait_seconds) def ack(self, alias: str, delivery_ids: list[str]) -> None: with self._lock: item = self._items.get(alias) if not item: raise DouyinError("notification listener is not running") item.ack(delivery_ids) def stop(self, alias: str) -> None: with self._lock: item = self._items.pop(alias, None) if item: item.stop() def close(self) -> None: with self._lock: aliases = list(self._items) for alias in aliases: self.stop(alias) 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 _direct_message_type(detail: dict, message: object) -> str: raw_type = ( detail.get("message_type") or detail.get("msg_type") or detail.get("type") ) if isinstance(message, dict): raw_type = raw_type or message.get("type") or message.get("message_type") if isinstance(raw_type, int) and not isinstance(raw_type, bool): raw_type = {1: "text", 2: "image", 3: "voice", 4: "video", 5: "sticker"}.get( raw_type ) if isinstance(raw_type, str): normalized = raw_type.strip().lower() aliases = { "text": "text", "txt": "text", "image": "image", "img": "image", "picture": "image", "voice": "voice", "audio": "voice", "video": "video", "sticker": "sticker", "emoji": "sticker", } if normalized in aliases: return aliases[normalized] if any(detail.get(key) for key in ("voice", "audio", "audio_url")): return "voice" if any(detail.get(key) for key in ("image", "picture", "image_url")): return "image" if any(detail.get(key) for key in ("video", "video_url")): return "video" if isinstance(message, str) and message: return "text" return "non_text" 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" elif ( notice.get("dm") or notice.get("message") or notice.get("im") or notice.get("chat") ): kind, event_type = next( (candidate, "dm") for candidate in ("dm", "message", "im", "chat") if notice.get(candidate) ) else: return None detail = notice.get(kind) if not isinstance(detail, dict): raise DouyinError("notification detail payload is invalid") if event_type == "dm": sender = ( detail.get("from_user") or detail.get("sender") or detail.get("user") or {} ) if isinstance(sender, list): sender = sender[0] if sender else {} if not isinstance(sender, dict): raise DouyinError("direct-message sender is invalid") message = ( detail.get("text") or detail.get("content") or detail.get("message") or notice.get("text") or "" ) message_type = _direct_message_type(detail, message) if isinstance(message, dict): message = message.get("text") or message.get("content") or "" if not isinstance(message, str) or len(message) > 100000: raise DouyinError("direct-message text is invalid") event_key = _notice_id( notice.get("message_id") or notice.get("msg_id") or detail.get("message_id") or detail.get("msg_id") or notice.get("nid_str") or notice.get("nid") ) if not event_key: raise DouyinError("direct-message ID is invalid") result = { "event_key": event_key, "event_type": "dm", "interactor_uid": _notice_id(sender.get("uid") or sender.get("user_id")), "comment_id": "", "work_id": "", "message_type": message_type, "message_text": message, } 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 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), } 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 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 install_expression(key: str, uid: str) -> str: return INSTALL_SCRIPT.replace("KEY_VALUE", json.dumps(key)).replace( "UID_VALUE", json.dumps(uid) ) def wait_expression(key: str) -> str: return WAIT_SCRIPT.replace("KEY_VALUE", json.dumps(key)) def dispose_expression(key: str) -> str: return f"(() => {{ const key={json.dumps(key)}; window[key]?.dispose(); delete window[key]; return true; }})()" def ack_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 ACK_SCRIPT.replace("KEY_VALUE", json.dumps(key)).replace( "IDS_VALUE", json.dumps(ids) ) 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 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)) INSTALL_SCRIPT = r"""(async()=>{try{ if(location.origin!=="https://www.douyin.com")throw Error("WRONG_ORIGIN"); const key=KEY_VALUE,uid=UID_VALUE,chunks=window.webpackChunkdouyin_web;if(!chunks)throw Error("RUNTIME_MISSING");const old=window[key],carry=Array.isArray(old?.queue)?old.queue.filter(e=>e&&e.delivery_id):[];old?.dispose?.();delete window[key];let req; chunks.push([["creatorhub-notice-"+Date.now()],{},r=>{req=r;}]);chunks.pop(); const entries=Object.entries(req.m||{});const entry=entries.find(([,f])=>String(f).includes("NOTICE_PUSH_EVENT_NAMES:function")); const codec=entries.find(([,f])=>{const s=String(f);return s.includes(".decodedFrame=")&&s.includes(".encodeFrame=");}); if(!entry||!codec)throw Error("SDK_CHANGED");const C=req(entry[0]).NoticeFrontier,decode=req(codec[0]).decodedFrame,f=C.frontierInstance; if(!f||String(f._options.deviceID)!==uid)throw Error("SOCKET_NOT_READY"); const state={queue:carry,wake:null,C,f,uid,dropped:0};const delivery=()=>"browser-"+crypto.randomUUID();const emit=e=>{const item={...e,delivered:false,delivery_id:delivery()};if(state.queue.length>=1000){state.queue.shift();state.dropped++;state.queue.push({kind:"error",reason:"QUEUE_OVERFLOW",continuity:"gap",dropped:state.dropped,delivered:false,delivery_id:delivery()});}else state.queue.push(item);if(state.wake)state.wake();}; const message=e=>{try{const frame=decode(new Uint8Array(e.data));if(frame.service===20313||frame.service===20003)emit({kind:"push",service:frame.service,payload:new TextDecoder().decode(frame.payload)});}catch(_){emit({kind:"error",reason:"FRAME_DECODE_FAILED",continuity:"gap"});}}; const open=()=>emit({kind:"open"}),close=()=>emit({kind:"close"});f.addEventListener("message",message);f.addEventListener("open",open);f.addEventListener("close",close); state.dispose=()=>{f.removeEventListener("message",message);f.removeEventListener("open",open);f.removeEventListener("close",close);if(state.wake)state.wake();};window[key]=state; return JSON.stringify({connected:f.readyState===f.OPEN}); }catch(e){return JSON.stringify({bridge_error:String(e.message||e)});}})()""" 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"};}})()""" 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");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-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)fail("IM_SDK_NOT_READY");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:String(profile.user.uid),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)fail("MESSAGE_REJECTED",true);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","MESSAGE_UNCONFIRMED"];return {status:sent?(e.definitive?"failed":"unknown"):"failed",code:known.includes(e.message)?e.message:"SDK_REQUEST_FAILED"};}})()"""