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

1582 lines
77 KiB
Python

"""Douyin browser control built on the shared restricted CDP contract."""
from __future__ import annotations
import base64
import binascii
import http.client
import json
import logging
import math
import re
import threading
import time
import uuid
from collections 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()<deadline&&!source){{
source=[...document.querySelectorAll({json.dumps(self.media_selector)})]
.map(media=>media.currentSrc||media.src||'')
.find(value=>value&&!value.includes('/obj/douyin-pc-web/uuu_265.mp4'))||'';
if(!source)await new Promise(resolve=>setTimeout(resolve,100));
}}
if(!source)return {{error:'media_source_unavailable'}};
const r=await fetch(source,{{credentials:'include',redirect:'error'}});
if(!r.body)return {{status:r.status,content_type:r.headers.get('content-type')||'',body:''}};
const reader=r.body.getReader(), chunks=[]; let size=0;
for(;;){{const item=await reader.read();if(item.done)break;
if(size+item.value.byteLength>{MEDIA_RESPONSE_LIMIT}){{await reader.cancel();return {{too_large:true}};}}
size+=item.value.byteLength;chunks.push(item.value);
}}
const bytes=new Uint8Array(size); let offset=0;
for(const chunk of chunks){{bytes.set(chunk,offset);offset+=chunk.length;}}
let binary='';
for(let offset=0;offset<bytes.length;offset+=0x8000)binary+=String.fromCharCode(...bytes.subarray(offset,Math.min(offset+0x8000,bytes.length)));
return {{status:r.status,content_type:r.headers.get('content-type')||'',body:btoa(binary)}};
}})()"""
)
if not isinstance(result, dict) or result.get("too_large"):
raise DouyinError("Douyin media download failed")
if result.get("error"):
raise DouyinError(f"Douyin media download failed: {result['error']}")
status = result.get("status")
content_type = result.get("content_type")
body = result.get("body")
if (
not isinstance(status, int)
or not isinstance(content_type, str)
or not isinstance(body, str)
):
raise DouyinError("Douyin media response is invalid")
if status < 200 or status >= 300:
raise DouyinError("Douyin media response was not successful")
try:
decoded_size = len(base64.b64decode(body, validate=True))
except (ValueError, binascii.Error) as exc:
raise DouyinError("Douyin media response is invalid") from exc
if decoded_size > MEDIA_RESPONSE_LIMIT:
raise DouyinError("Douyin media response is too large")
return BrowserMediaResponse(status, content_type, body)
def 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"};}})()"""