1582 lines
77 KiB
Python
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"};}})()"""
|