Files
creator-hub/browser_gateway/test_gateway.py
T
rogee 221ed65e32 feat(creator): 竞品作品同步改用匿名浏览器并限制并发
- 竞品同步不再依赖自有采集账号:每次同步按名额(并发上限 2)创建匿名浏览器,
  导航至抖音首页等待访客 cookie 稳定后经网关受限 fetch 拉取作品
- 调度器对到期竞品并发同步,名额耗尽时排队等待
- 平台风控语义(实测,与公开爬虫项目一致):作品列表匿名仅能取第一页
  (16-20 条),后续页一律空 envelope;空 envelope 视为正常分页终止
- 评论解析适配真实响应:reply_id="0" 为顶层评论、作者取 user 对象、
  纯图片评论(text 空但带 image_list)记为「[图片]」
- fetch 签名与白名单:注入 byted_acrawler.frontierSign 的 a_bogus,
  网关放宽响应上限至 8MB 并允许签名参数
2026-09-27 15:43:03 +08:00

1739 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import base64
import io
import json
import socket
import threading
import unittest
from collections import deque
from contextlib import contextmanager, nullcontext
from importlib import import_module
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import Mock, patch
import websocket
from .platform.douyin import (
BrowserResponse,
CDPConnection,
DouyinBrowser,
DouyinError,
DouyinSubscription,
ORIGIN_URL,
SubscriptionManager,
ack_expression,
action_expression,
detect_challenge,
im_expression,
install_expression,
is_douyin_content_url,
is_douyin_share_url,
is_douyin_url,
normalize_notice,
notice_ids,
wait_expression,
)
from .proxy import (
MemoryProxy,
ProxyExit,
ProxyRegistry,
_copy_until_close,
_dial_http_proxy,
_dial_socks4,
_dial_socks5,
_is_ipv4,
_parse_request,
_read_request,
_read_status,
_recv_exact,
)
gateway_module = import_module(f"{__package__}.server.http")
douyin_module = import_module(f"{__package__}.platform.douyin")
proxy_module = import_module(f"{__package__}.proxy")
Gateway = gateway_module.Gateway
RequestError = gateway_module.RequestError
decode_generation = gateway_module.decode_generation
json_bytes = gateway_module.json_bytes
load_config = gateway_module.load_config
split_listen_address = gateway_module.split_listen_address
valid_douyin_url = gateway_module.valid_douyin_url
valid_douyin_source_url = gateway_module.valid_douyin_source_url
validate_create = gateway_module.validate_create
parse_proxy_exit = gateway_module.parse_proxy_exit
validate_proxy_exit = gateway_module.validate_proxy_exit
validate_proxy_restore = gateway_module.validate_proxy_restore
valid_douyin_generation = gateway_module.valid_douyin_generation
valid_account_key_query = gateway_module.valid_account_key_query
numeric_cursor = gateway_module.numeric_cursor
proxy_port = gateway_module.proxy_port
has_control = gateway_module.has_control
class FakeSocket:
def __init__(self, messages: list[object]) -> None:
self.messages = list(messages)
self.sent: list[bytes] = []
self.timeout = 0.0
def send(self, data: bytes) -> None:
self.sent.append(data)
def recv(self) -> str:
if not self.messages:
raise TimeoutError("no more messages")
return json.dumps(self.messages.pop(0))
def settimeout(self, value: float) -> None:
self.timeout = value
def close(self) -> None:
return None
class FakeConnection:
def __init__(self, values: list[object]) -> None:
self.values = list(values)
def evaluate(self, expression: str) -> object:
del expression
if not self.values:
raise DouyinError("no fake response")
return self.values.pop(0)
class GatewayValidationTests(unittest.TestCase):
def test_create_and_generation_validation(self) -> None:
value = {
"alias": "safe-account",
"name": "Safe account",
"profile_id": "safe-account",
"cmd": ["about:blank"],
"binding_version": 1,
"network_exit_id": "",
"network_exit": {},
"stopped": True,
}
validate_create(value)
self.assertEqual(value["network_exit"], ProxyExit("", "", 0))
self.assertEqual(value["network_exit_id"], "")
self.assertEqual(
decode_generation(
{
"binding_version": 1,
"runtime_id": "runtime-not-found",
"network_id": "native-" + "a" * 32,
},
False,
False,
)["runtime_id"],
"runtime-not-found",
)
with self.assertRaises(RequestError):
validate_create(
{**value, "cmd": ["--proxy-server=http://x", "about:blank"]}
)
with self.assertRaises(RequestError):
decode_generation(
{"binding_version": 1, "runtime_id": 4, "network_id": "n"}, True, True
)
def test_config_and_urls(self) -> None:
self.assertEqual(split_listen_address(":8081"), ("", 8081))
self.assertEqual(split_listen_address("[::1]:8081"), ("::1", 8081))
with self.assertRaises(ValueError):
split_listen_address("missing-port")
config = load_config(
{
"LISTEN_ADDR": ":8081",
"BROWSER_PATH": "/bin/true",
"GATEWAY_TOKEN": "0123456789abcdef",
}
)
self.assertEqual(config["listen"], ("", 8081))
self.assertEqual(config["browser_path"], "/bin/true")
self.assertEqual(config["ready_timeout"], 60.0)
self.assertIsNone(config["external_display"])
external = load_config(
{
"BROWSER_PATH": "/bin/true",
"GATEWAY_TOKEN": "0123456789abcdef",
"RUNTIME_EXTERNAL_DISPLAY": "99",
}
)
self.assertEqual(external["external_display"], 99)
with self.assertRaises(ValueError):
load_config(
{
"GATEWAY_TOKEN": "0123456789abcdef",
"BROWSER_PATH": "/not/a/browser",
}
)
self.assertTrue(
valid_douyin_url(
"https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383&device_platform=webapp"
)
)
self.assertTrue(
valid_douyin_url(
"https://www.douyin.com/aweme/v1/web/user/profile/other/?aid=6383&device_platform=webapp&user_id=2328120603967913"
)
)
self.assertTrue(
valid_douyin_url(
"https://www.douyin.com/aweme/v1/web/user/profile/other/?aid=6383&device_platform=webapp&sec_user_id=MS4wLjABAAAA9f_a7k0bzVizLYXlpC7R61EIaqJ8Ordug7yp7AB8fGKuuF8Fzqk5_DM-eutXnPIK"
)
)
self.assertFalse(
valid_douyin_url(
"https://www.douyin.com/aweme/v1/web/user/profile/other/?aid=6383&device_platform=webapp&user_id=1&count=20"
)
)
self.assertFalse(valid_douyin_url("https://www.douyin.com.evil/"))
self.assertTrue(is_douyin_url("https://www.douyin.com/video/123"))
self.assertFalse(is_douyin_url("https://www.douyin.com.evil/video/123"))
self.assertTrue(is_douyin_content_url("https://www.douyin.com/video/123"))
self.assertTrue(is_douyin_share_url("https://v.douyin.com/abc123/"))
self.assertTrue(valid_douyin_source_url("https://v.douyin.com/abc123/"))
self.assertFalse(is_douyin_share_url("https://v.douyin.com/abc123/#bad"))
self.assertFalse(is_douyin_share_url("https://www.douyin.com/user/123"))
self.assertTrue(
valid_douyin_url(
"https://www.douyin.com/aweme/v1/web/aweme/detail/?aid=6383&device_platform=webapp&aweme_id=123"
)
)
def test_http_routes_and_body_validation(self) -> None:
handler = gateway_module.GatewayHandler.__new__(gateway_module.GatewayHandler)
gateway = Mock()
gateway.list_browsers.return_value = []
gateway.douyin_identity.return_value = {"uid": "123"}
gateway.douyin_login_qr.return_value = {
"content_type": "image/png",
"body_base64": "cG5n",
"qr_detected": True,
}
gateway.douyin_action.return_value = {"status": "succeeded"}
gateway.douyin_message_history.return_value = {"status": "succeeded"}
gateway.poll_douyin_events.return_value = []
server = Mock()
server.gateway = gateway
cast(Any, handler).server = server
cast(Any, handler).server_as_gateway = lambda: server
self.assertEqual(handler._route("GET", "/v1/browsers", {}, {}), [])
self.assertEqual(
handler._route("POST", "/v1/browsers", {}, {}),
(201, gateway.create.return_value),
)
handler._route(
"DELETE",
"/v1/browsers/safe",
{},
{"binding_version": 1, "runtime_id": "a" * 64, "network_id": ""},
)
gateway.remove.assert_called_once_with(
"safe", {"binding_version": 1, "runtime_id": "a" * 64, "network_id": ""}
)
handler._route("POST", "/v1/browsers/safe/start", {}, {})
handler._route("POST", "/v1/browsers/safe/stop", {}, {})
handler._route("POST", "/v1/browsers/safe/proxy", {}, {})
with self.assertRaises(RequestError):
handler._route("POST", "/v1/browsers/safe/douyin/cookies", {}, {})
handler._route("POST", "/v1/browsers/safe/douyin/get", {}, {})
handler._route("POST", "/v1/browsers/safe/douyin/identity", {}, {})
self.assertEqual(
handler._route("POST", "/v1/browsers/safe/douyin/login-qr", {}, {}),
gateway.douyin_login_qr.return_value,
)
gateway.douyin_login_qr.assert_called_once_with("safe", {})
handler._route("POST", "/v1/browsers/safe/douyin/action", {}, {})
self.assertEqual(
handler._route("POST", "/v1/browsers/safe/douyin/messages", {}, {}),
{"status": "succeeded"},
)
gateway.douyin_message_history.assert_called_once_with("safe", {})
self.assertEqual(
handler._route("GET", "/v1/browsers/safe/douyin/events", {}, {}), []
)
handler._route("POST", "/v1/browsers/safe/douyin/events", {}, {})
handler._route("DELETE", "/v1/browsers/safe/douyin/events", {}, {})
with self.assertRaises(RequestError):
handler._route("GET", "/v1/unknown", {}, {})
cast(Any, handler).headers = {"Content-Length": "7"}
cast(Any, handler).rfile = io.BytesIO(b'{"x":1}')
self.assertEqual(handler._body(), {"x": 1})
cast(Any, handler).headers = {}
with self.assertRaises(RequestError):
handler._body()
test_token = "x" * 16
cast(Any, handler).headers = {"Authorization": f"Bearer {test_token}"}
server.gateway.token = test_token
self.assertTrue(handler._authorized())
handler._respond = Mock()
handler._handle_exception("/v1", RequestError("bad", 400))
handler._handle_exception("/v1", ValueError("bad"))
self.assertEqual(handler._respond.call_count, 2)
def test_get_event_route_reads_generation_body(self) -> None:
handler = gateway_module.GatewayHandler.__new__(gateway_module.GatewayHandler)
cast(Any, handler).path = "/v1/browsers/safe/douyin/events?wait=1"
cast(Any, handler).headers = {"Content-Length": "67"}
cast(Any, handler).rfile = io.BytesIO(
b'{"binding_version":1,"runtime_id":"runtime","network_id":"network"}'
)
cast(Any, handler)._authorized = lambda: True
cast(Any, handler)._route = Mock(return_value=[])
cast(Any, handler)._respond = Mock()
handler._dispatch("GET")
cast(Any, handler)._route.assert_called_once_with(
"GET",
"/v1/browsers/safe/douyin/events",
{"wait": ["1"]},
{"binding_version": 1, "runtime_id": "runtime", "network_id": "network"},
)
def test_validation_boundaries(self) -> None:
self.assertEqual(proxy_port("http://127.0.0.1:1234"), 1234)
self.assertTrue(has_control("bad\nvalue"))
exit_value = parse_proxy_exit(
{"protocol": "http", "host": "proxy", "port": 8080}
)
validate_proxy_exit(exit_value)
with self.assertRaises(RequestError):
validate_proxy_exit(
parse_proxy_exit({"protocol": "ftp", "host": "proxy", "port": 21})
)
with self.assertRaises(RequestError):
validate_proxy_exit(ProxyExit("http", "proxy", 0))
generation = {
"binding_version": 1,
"runtime_id": "a" * 64,
"network_id": "native-" + "b" * 32,
"network_exit_id": "exit",
}
self.assertTrue(valid_douyin_generation(generation))
self.assertFalse(
valid_douyin_generation({**generation, "binding_version": True})
)
self.assertTrue(valid_account_key_query({"account": ["account"]}, "account"))
self.assertFalse(valid_account_key_query({"account": ["bad key"]}, "account"))
self.assertTrue(numeric_cursor(["0"]))
self.assertFalse(numeric_cursor(["-1"]))
restore = {
"binding_version": 1,
"runtime_id": "a" * 64,
"network_id": "native-" + "b" * 32,
"network_exit_id": "exit",
"network_exit": {"protocol": "http", "host": "proxy", "port": 8080},
}
validate_proxy_restore(restore, "safe")
with self.assertRaises(RequestError):
validate_proxy_restore({**restore, "network_exit_id": ""}, "safe")
with self.assertRaises(RequestError):
validate_proxy_restore({"network_exit_id": ""}, "safe")
with self.assertRaises(RequestError):
validate_create({"alias": "safe", "unknown": True})
with self.assertRaises(ValueError):
load_config({"GATEWAY_TOKEN": "short"})
with self.assertRaises(ValueError):
load_config(
{
"BROWSER_PATH": "/bin/true",
"GATEWAY_TOKEN": "0123456789abcdef",
"RUNTIME_EXTERNAL_DISPLAY": "0",
}
)
self.assertFalse(valid_douyin_url("http://www.douyin.com/video/1"))
self.assertFalse(valid_douyin_url("https://www.douyin.com/unknown"))
with self.assertRaises(RequestError):
decode_generation(
{"binding_version": 1, "runtime_id": "a" * 64}, True, True
)
class GatewayBusinessMethodTests(unittest.TestCase):
def setUp(self) -> None:
self.runtime_id = "a" * 64
self.network_id = "native-" + "b" * 32
self.generation = {
"binding_version": 1,
"runtime_id": self.runtime_id,
"network_id": self.network_id,
"network_exit_id": "exit-1",
}
self.runtimes = Mock()
self.runtimes.proxies = Mock()
self.runtimes.browser_path = "/bin/true"
self.runtimes.alias_lock.return_value = nullcontext()
self.runtimes.require_generation.return_value = SimpleNamespace(
runtime_id=self.runtime_id, network_exit_id="exit-1"
)
self.runtimes.endpoint.return_value = "http://127.0.0.1:19001"
self.runtimes.list_public.return_value = []
self.browser = Mock()
self.browser.get.return_value = Mock(status=200, body="body", challenge="")
self.browser.resolve.return_value = "https://www.douyin.com/video/123"
self.browser.get_media.return_value = Mock(
status=200, content_type="video/mp4", body_base64="dm"
)
self.browser.identity.return_value = {
"uid": "12345678901234567890",
"sec_uid": "sec",
"unique_id": "name",
}
self.browser.login_qr.return_value = Mock(
content_type="image/png", body_base64="cG5n", qr_detected=True
)
self.browser.message_history.return_value = {"status": "succeeded"}
self.browser.action.return_value = {"status": "succeeded"}
self.browser.action_ownership.return_value = None
self.gateway = Gateway(
self.runtimes,
"gateway-token-123456",
"node-a",
browser=self.browser,
)
self.gateway.subscriptions = Mock()
self.gateway.subscriptions.start.return_value = {"status": "started"}
self.gateway.subscriptions.poll.return_value = []
def test_list_browsers_excludes_released_journal_rows(self) -> None:
self.runtimes.list_public.return_value = [
{"alias": "same", "state": "released"},
{"alias": "same", "state": "stopped"},
]
self.assertEqual(self.gateway.list_browsers(), [{"alias": "same", "state": "stopped"}])
def test_lifecycle_info_and_douyin_operations(self) -> None:
self.runtimes.create.return_value = {"state": "running"}
self.assertEqual(self.gateway.info()["node_id"], "node-a")
self.assertEqual(self.gateway.list_browsers(), [])
self.assertEqual(self.gateway.create({"alias": "safe"}), {"state": "running"})
runtime_generation = {key: self.generation[key] for key in ("binding_version", "runtime_id", "network_id")}
self.gateway.change_state("safe", "stop", runtime_generation)
self.gateway.remove("safe", {**runtime_generation, "purge_profile": True})
self.gateway.restore_proxy(
"safe",
{**self.generation, "network_exit": {"protocol": "http", "host": "proxy", "port": 8080}},
)
self.assertEqual(
self.gateway.get_douyin(
"safe",
{**self.generation, "url": "https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383&device_platform=webapp"},
)["status"],
200,
)
self.assertEqual(
self.gateway.resolve_douyin(
"safe", {**self.generation, "url": "https://v.douyin.com/abc123/"}
)["url"],
"https://www.douyin.com/video/123",
)
self.browser.resolve.reset_mock()
self.assertEqual(
self.gateway.resolve_douyin(
"safe", {**self.generation, "url": "https://www.douyin.com/video/123"}
)["url"],
"https://www.douyin.com/video/123",
)
self.browser.resolve.assert_called_once_with(
"safe", "https://www.douyin.com/video/123"
)
self.assertEqual(
self.gateway.get_douyin_media(
"safe", {**self.generation, "url": "https://cdn.example/video.mp4"}
)["content_type"],
"video/mp4",
)
self.assertEqual(
self.gateway.douyin_identity(
"safe", {**self.generation, "expected_account_key": "12345678901234567890"}
)["uid"],
"12345678901234567890",
)
self.assertTrue(
self.gateway.douyin_login_qr("safe", self.generation)["qr_detected"]
)
expected = "12345678901234567890"
target = "22345678901234567890"
self.assertEqual(
self.gateway.douyin_message_history(
"safe", {**self.generation, "expected_uid": expected, "target_uid": target}
)["status"],
"succeeded",
)
self.assertEqual(
self.gateway.douyin_action(
"safe",
{
**self.generation,
"expected_uid": expected,
"target_uid": target,
"action": "follow",
"operation_id": "operation-1",
"confirm": True,
},
)["status"],
"succeeded",
)
def test_event_operations(self) -> None:
self.assertEqual(
self.gateway.start_douyin_events(
"safe", {**self.generation, "expected_uid": "12345678901234567890"}
),
{"status": "started"},
)
self.assertEqual(
self.gateway.poll_douyin_events("safe", self.generation, {"limit": ["1"], "wait": ["0"]}),
[],
)
self.gateway.stop_douyin_events("safe", self.generation)
def test_http_info_route_and_action_error_ownership(self) -> None:
handler = gateway_module.GatewayHandler.__new__(gateway_module.GatewayHandler)
server = Mock()
server.gateway = self.gateway
cast(Any, handler).server = server
cast(Any, handler).server_as_gateway = lambda: server
self.assertEqual(handler._route("GET", "/v1/info", {}, {}), self.gateway.info())
cast(Any, handler).path = "/v1/info"
cast(Any, handler).headers = {"Authorization": "Bearer gateway-token-123456"}
cast(Any, handler)._respond = Mock()
handler._dispatch("GET")
self.assertEqual(handler._respond.call_args.args[0], 200)
self.gateway._handle_douyin_action_error("safe", "follow", DouyinError("timed out"), "operation-1")
self.assertEqual(self.gateway._uncertain_actions["safe"], float("inf"))
self.gateway._release_action_ownership("safe", "operation-1")
self.assertNotIn("safe", self.gateway._uncertain_actions)
class CDPTests(unittest.TestCase):
def test_connect_response_headers_are_drained(self) -> None:
connection = ChunkSocket(
[b"HTTP/1.1 200 Connection Established\r\n", b"X-Proxy: value\r\n\r\nTLS"]
)
self.assertEqual(_read_status(cast(socket.socket, connection)), 200)
def test_command_queues_events_for_wait_event(self) -> None:
socket_ = FakeSocket(
[
{
"method": "Page.frameNavigated",
"params": {"frame": {"id": "frame-1"}},
},
{"id": 1, "result": {}},
]
)
connection = CDPConnection(cast(websocket.WebSocket, socket_))
self.assertEqual(connection.command("Page.enable"), {})
event = connection.wait_event(
"Page.frameNavigated", lambda params: params["frame"]["id"] == "frame-1"
)
self.assertEqual(event["method"], "Page.frameNavigated")
self.assertTrue(socket_.sent)
def test_eof_status_is_a_failure(self) -> None:
class Closed:
def recv(self, size: int) -> bytes:
del size
return b""
with self.assertRaises(OSError):
_read_status(cast(socket.socket, Closed()))
def test_expression_markers_and_challenge(self) -> None:
expression = im_expression({"text": "hello EXPECTED_UID_VALUE"}, "123")
self.assertIn("hello EXPECTED_UID_VALUE", expression)
self.assertIn(
"https://www.douyin.com", action_expression({"action": "like_work"})
)
self.assertEqual(detect_challenge(429, "captcha"), "")
self.assertEqual(detect_challenge(412, ""), "captcha")
def test_notification_details_normalize_safe_targets(self) -> None:
notice = {
"nid_str": "9007199254740993",
"user_id": "99491952055",
"create_time": 1700000000,
"aweme_id": "123456",
"comment": {
"from_user": [{"uid": "7654321"}],
"comment": {"cid_str": "987654", "user": {"uid": "7654321"}},
},
}
self.assertEqual(
normalize_notice(notice),
{
"event_key": "9007199254740993",
"event_type": "comment",
"interactor_uid": "7654321",
"comment_id": "987654",
"work_id": "123456",
"platform_event_at": "2023-11-14T22:13:20+00:00",
},
)
favorite = {"nid_str": "7", "favorite": {"from_user": [{"uid": "1"}]}}
self.assertIsNone(normalize_notice(favorite))
def test_notification_detail_retries_partial_response(self) -> None:
details = [
{"nid_str": "1", "user_id": "123"},
{"nid_str": "2", "user_id": "123"},
]
payload = json.dumps({"status_code": 0, "notice_list_v2": details})
subscription = DouyinSubscription.__new__(DouyinSubscription)
subscription.uid = "123"
subscription.connection = cast(
CDPConnection,
FakeConnection(
[
json.dumps(
{
"status": 200,
"body": json.dumps(
{"status_code": 0, "notice_list_v2": details[:1]}
),
}
),
json.dumps({"status": 200, "body": payload}),
]
),
)
subscription._get_connection = lambda: subscription.connection
with patch.object(douyin_module.time, "sleep"):
self.assertEqual(len(subscription._details(["1", "2"])), 2)
def test_notification_detail_rejects_unexpected_id(self) -> None:
subscription = DouyinSubscription.__new__(DouyinSubscription)
subscription.uid = "123"
subscription.connection = cast(
CDPConnection,
FakeConnection(
[
json.dumps(
{
"status": 200,
"body": json.dumps(
{
"status_code": 0,
"notice_list_v2": [
{"nid_str": "9", "user_id": "123"}
],
}
),
}
)
]
),
)
subscription._get_connection = lambda: subscription.connection
with self.assertRaises(DouyinError):
subscription._details(["1"])
class BrowserCDP:
def __init__(self, values: list[object]) -> None:
self.values = list(values)
self.commands: list[tuple[str, dict | None]] = []
self.events: list[str] = []
self.expressions: list[str] = []
self.closed = False
def command(self, method: str, params: dict | None = None) -> dict:
self.commands.append((method, params))
if method == "Page.navigate":
return {"frameId": "frame-1"}
return {}
def notify(self, method: str, params: dict | None = None) -> None:
self.commands.append((method, params))
def wait_event(self, method: str, predicate: object, timeout: float = 15.0) -> dict:
del predicate, timeout
self.events.append(method)
return {"method": method}
def evaluate(self, expression: str) -> object:
self.expressions.append(expression)
if not self.values:
raise DouyinError("fake CDP value exhausted")
return self.values.pop(0)
def close(self) -> None:
self.closed = True
class FakeHTTPResponse:
def __init__(self, status: int, body: bytes) -> None:
self.status = status
self.body = body
def read(self, limit: int = -1) -> bytes:
del limit
return self.body
class FakeHTTPConnection:
def __init__(self, response: FakeHTTPResponse) -> None:
self.response = response
self.requested: list[tuple[str, str]] = []
self.closed = False
def request(
self,
method: str,
path: str,
body: bytes | None = None,
headers: dict[str, str] | None = None,
) -> None:
del body, headers
self.requested.append((method, path))
def getresponse(self) -> FakeHTTPResponse:
return self.response
def close(self) -> None:
self.closed = True
class BrowserTests(unittest.TestCase):
def _with_connection(self, browser: DouyinBrowser, connection: BrowserCDP) -> None:
@contextmanager
def bound(alias: str):
del alias
yield connection
cast(Any, browser).connection = bound
def test_resolve_initializes_anonymous_blank_runtime(self) -> None:
cdp = Mock()
cdp.evaluate.side_effect = ["null", True, "https://www.douyin.com/video/123"]
cdp.command.return_value = {"frameId": "frame-1"}
cdp.wait_event.return_value = {
"params": {"frame": {"url": "https://www.douyin.com/video/123"}}
}
browser = DouyinBrowser()
self._with_connection(browser, cast(BrowserCDP, cdp))
self.assertEqual(
browser.resolve("safe", "https://www.douyin.com/video/123"),
"https://www.douyin.com/video/123",
)
self.assertEqual(cdp.evaluate.call_count, 1)
self.assertEqual(cdp.evaluate.call_args_list[0].args, ("location.origin",))
self.assertEqual(cdp.command.call_args_list[0].args, ("Page.enable",))
self.assertEqual(cdp.notify.call_args_list[0].args, ("Page.navigate", {"url": "https://www.douyin.com/video/123"}))
def test_browser_fetch_uses_manually_logged_session(self) -> None:
cdp = BrowserCDP(
[
"https://www.douyin.com",
{"status": 200, "body": "{}", "too_large": False},
]
)
browser = DouyinBrowser()
self._with_connection(browser, cdp)
response = browser.get(
"safe", "https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383"
)
self.assertEqual(response.status, 200)
self.assertNotIn("Network.setCookies", [method for method, _ in cdp.commands])
def test_browser_fetch_navigates_blank_runtime_to_origin(self) -> None:
# 匿名浏览器从 about:blank 启动(origin 为 null):get 前必须先导航到抖音首页,
# 并等待访客 cookie(ttwid)就绪,否则 API fetch 返回空响应。
cdp = BrowserCDP(
[
"null",
"https://www.douyin.com",
"ttwid=1%7Cabc; s_v_web_id=x",
"ttwid=1%7Cabc; s_v_web_id=x",
"ttwid=1%7Cabc; s_v_web_id=x",
{"status": 200, "body": "{}", "too_large": False},
]
)
browser = DouyinBrowser()
self._with_connection(browser, cdp)
response = browser.get(
"safe", "https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383"
)
self.assertEqual(response.status, 200)
self.assertIn(("Page.navigate", {"url": ORIGIN_URL}), cdp.commands)
self.assertEqual(cdp.events, [])
def test_browser_fetch_retries_transient_auth_response(self) -> None:
cdp = BrowserCDP(
[
"https://www.douyin.com",
{"status": 403, "body": "challenge", "too_large": False},
{"status": 200, "body": "{}", "too_large": False},
]
)
browser = DouyinBrowser()
self._with_connection(browser, cdp)
with patch("browser_gateway.platform.douyin.time.sleep") as sleep:
response = browser.get(
"safe",
"https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383",
)
self.assertEqual(response.status, 200)
sleep.assert_called_once_with(2)
def test_login_qr_captures_a_browser_screen_without_credentials(self) -> None:
screenshot = base64.b64encode(b"png-bytes" * 3000).decode("ascii")
cdp = Mock()
cdp.evaluate.side_effect = [
True,
{"origin": "https://www.douyin.com", "qr_detected": True},
]
cdp.command.side_effect = [
{"frameId": "frame-1"},
{"data": screenshot},
{"data": screenshot},
]
browser = DouyinBrowser()
self._with_connection(browser, cast(BrowserCDP, cdp))
with patch.object(douyin_module.time, "sleep"):
response = browser.login_qr("safe")
self.assertEqual(response.content_type, "image/png")
self.assertEqual(response.body_base64, screenshot)
self.assertTrue(response.qr_detected)
self.assertEqual(
[call.args[0] for call in cdp.command.call_args_list],
[
"Page.navigate",
"Page.captureScreenshot",
"Page.captureScreenshot",
],
)
self.assertFalse(any("cookie" in expression.lower() for expression in cdp.evaluate.call_args.args))
def test_connect_selects_configured_target_from_large_mixed_list(self) -> None:
targets = [
{"type": "service", "url": "http://127.0.0.1:9222/json"} for _ in range(40)
]
targets.extend(
[
{
"id": "other",
"type": "page",
"url": "https://www.douyin.com/video/1",
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/1",
},
{
"id": "selected",
"type": "page",
"url": "https://www.douyin.com/user/self",
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/2",
},
]
)
http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(targets).encode()))
with (
patch.object(
douyin_module.http.client, "HTTPConnection", return_value=http
),
patch.object(
douyin_module.websocket,
"create_connection",
return_value=FakeSocket([]),
),
):
connection = DouyinBrowser(
lambda alias: "http://127.0.0.1:9222", target_id="selected"
)._connect("safe")
self.assertIsInstance(connection, CDPConnection)
def test_media_download_is_browser_mediated_and_bounded(self) -> None:
payload = base64.b64encode(b"video-bytes").decode("ascii")
cdp = BrowserCDP(
[
{"url": "https://www.douyin.com/video/123", "readyState": "complete"},
{"status": 200, "content_type": "video/mp4", "body": payload},
]
)
browser = DouyinBrowser()
self._with_connection(browser, cdp)
response = browser.get_media("safe", "https://www.douyin.com/video/123")
self.assertEqual(response.status, 200)
self.assertEqual(response.content_type, "video/mp4")
self.assertEqual(base64.b64decode(response.body_base64), b"video-bytes")
self.assertIn("Page.navigate", [method for method, _ in cdp.commands])
self.assertIn("querySelectorAll", cdp.expressions[-1])
self.assertIn("uuu_265.mp4", cdp.expressions[-1])
def test_connect_identity_and_actions(self) -> None:
target = [
{
"type": "page",
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/1",
}
]
http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(target).encode()))
with (
patch.object(
douyin_module.http.client, "HTTPConnection", return_value=http
),
patch.object(
douyin_module.websocket,
"create_connection",
return_value=FakeSocket([]),
),
):
connection = DouyinBrowser(lambda alias: "http://127.0.0.1:9222")._connect(
"safe"
)
self.assertIsInstance(connection, CDPConnection)
self.assertEqual(http.requested[0], ("GET", "/json/list"))
expression = install_expression("safe", "123")
self.assertIn("safe", expression)
self.assertIn("old?.dispose?.()", expression)
self.assertEqual(
notice_ids(
{
"service": 20313,
"payload": json.dumps(
{"notices": [{"notice_id_str": "1", "effect_groups": [960]}]}
),
}
),
["1"],
)
self.assertEqual(
notice_ids(
{
"service": 20003,
"payload": json.dumps({"notice_type": 45, "notice_id_str": "2"}),
}
),
["2"],
)
browser = DouyinBrowser()
cast(Any, browser).identity = lambda alias, expected: {"uid": expected}
cast(Any, browser)._evaluate = lambda *args: {
"status": "succeeded",
"action": "followed",
}
preview = browser.action(
"safe", "123", "dm", "456", text="hello", confirm=False
)
self.assertEqual(preview["action"], "preview")
self.assertEqual(
browser.action("safe", "123", "like_work", work_id="789", confirm=False)[
"action"
],
"preview",
)
with self.assertRaises(DouyinError):
browser.action("safe", "123", "dm", "456", text=" ", confirm=False)
with self.assertRaises(DouyinError):
browser.action("safe", "123", "follow", "bad", confirm=False)
def test_browser_queue_is_retained_until_ack(self) -> None:
wait = wait_expression("alpha")
self.assertIn("delivered", wait)
self.assertNotIn("splice(0)", wait)
ack = ack_expression("__creatorhub_notice_sub_alpha", ["browser-1"])
self.assertIn("browser-1", ack)
self.assertIn("__creatorhub_notice_sub_alpha", ack)
self.assertIn(
"crypto.randomUUID", douyin_module.install_expression("alpha", "123")
)
def test_listener_start_retries_until_runtime_ready(self) -> None:
subscription = DouyinSubscription.__new__(DouyinSubscription)
subscription.alias = "safe"
subscription.uid = "123"
subscription.stopped = threading.Event()
subscription.stopped.wait = Mock(return_value=False)
subscription._open_listener = Mock(
side_effect=[
DouyinError("SDK_NOT_READY"),
(Mock(), "2026-09-14T18:00:00+00:00"),
]
)
with patch.object(douyin_module.time, "monotonic", side_effect=[0.0, 1.0]):
connection, boundary = subscription._open_listener_until_ready()
self.assertEqual(boundary, "2026-09-14T18:00:00+00:00")
self.assertIsNotNone(connection)
self.assertEqual(subscription._open_listener.call_count, 2)
subscription.stopped.wait.assert_called_once_with(0.5)
def test_subscription_receipts_are_replayed_until_ack(self) -> None:
subscription = DouyinSubscription.__new__(DouyinSubscription)
subscription.uid = "123"
subscription.queue = deque()
subscription.condition = threading.Condition()
subscription.stopped = threading.Event()
subscription._put({"kind": "notice", "notice": {"event_key": "1"}})
first = subscription.poll(10, 0)
second = subscription.poll(10, 0)
self.assertEqual(first, second)
self.assertTrue(first[0]["notice"]["gateway_received_at"].endswith("+00:00"))
subscription.ack([first[0]["delivery_id"]])
self.assertEqual(subscription.poll(10, 0), [])
def test_subscription_connection_failure_requests_recovery(self) -> None:
subscription = DouyinSubscription.__new__(DouyinSubscription)
subscription.alias = "safe"
subscription.browser = Mock()
subscription.browser._connect.side_effect = DouyinError("browser unavailable")
subscription._put = Mock()
subscription._request_recovery = Mock()
subscription._browser_inflight_lock = threading.Lock()
subscription._browser_inflight = set()
subscription._process_push_async({"delivery_id": "delivery-1"}, 0, False)
subscription._request_recovery.assert_called_once_with()
def test_subscription_detail_failure_does_not_discard_siblings(self) -> None:
bad = json.dumps(
{
"status": 200,
"body": json.dumps(
{
"status_code": 0,
"notice_list_v2": [{"nid_str": "9", "user_id": "123"}],
}
),
}
)
good = json.dumps(
{
"status": 200,
"body": json.dumps(
{
"status_code": 0,
"notice_list_v2": [
{
"nid_str": "2",
"user_id": "123",
"follow": {"from_user": [{"uid": "7"}]},
}
],
}
),
}
)
subscription = DouyinSubscription.__new__(DouyinSubscription)
subscription.uid = "123"
subscription.queue = deque()
subscription.condition = threading.Condition()
subscription.stopped = threading.Event()
subscription.connection = cast(CDPConnection, FakeConnection([bad, bad, good]))
subscription._get_connection = lambda: subscription.connection
subscription._handle(
{
"kind": "push",
"service": 20313,
"payload": json.dumps(
{
"notices": [
{"notice_id_str": "1", "effect_groups": [960]},
{"notice_id_str": "2", "effect_groups": [960]},
]
}
),
}
)
events = subscription.poll(10, 0)
self.assertEqual(events[0]["kind"], "error")
self.assertEqual(events[0]["event_key"], "1")
self.assertEqual(events[1]["notice"]["event_key"], "2")
def test_subscription_manager_and_queue(self) -> None:
event = {"kind": "open"}
subscription = DouyinSubscription.__new__(DouyinSubscription)
subscription.uid = "123"
subscription.queue = deque()
subscription.condition = __import__("threading").Condition()
subscription.stopped = __import__("threading").Event()
subscription._handle(event)
first = subscription.poll(10, 0)
self.assertEqual(first[0]["kind"], "open")
self.assertTrue(first[0]["delivery_id"])
subscription.queue = deque([{} for _ in range(1000)])
subscription._put({"kind": "new"})
self.assertEqual(subscription.queue[0]["kind"], "error")
subscription.stopped.set()
overflow = subscription.poll(10, 0)
self.assertEqual(overflow[0]["kind"], "error")
self.assertEqual(overflow[0]["reason"], "notification queue overflow")
self.assertTrue(overflow[0]["delivery_id"])
self.assertEqual(len(overflow), 10)
self.assertEqual(len(subscription.queue), 1000)
subscription.ack([overflow[0]["delivery_id"]])
self.assertEqual(len(subscription.queue), 999)
self.assertEqual(subscription.queue[0], {})
browser = DouyinBrowser()
fake = Mock()
fake.poll.return_value = [{"kind": "notice"}]
with patch.object(douyin_module, "DouyinSubscription", return_value=fake):
manager = SubscriptionManager(browser)
self.assertTrue(manager.start("safe", "123")["connected"])
self.assertEqual(manager.poll("safe", 1, 0), [{"kind": "notice"}])
manager.stop("safe")
manager.close()
with self.assertRaises(DouyinError):
manager.poll("safe", 1, 0)
class ProxyTests(unittest.TestCase):
def test_chunked_request_body_is_decoded_and_forwarded_with_length(self) -> None:
request = ChunkSocket(
[
(
b"POST http://example.test/a HTTP/1.1\r\nHost: example.test\r\n"
b"Transfer-Encoding: chunked\r\n\r\n2\r\nab\r\n3;part=x\r\ncde\r\n0\r\nX-Trailer: yes\r\n\r\n"
)
]
)
head, body = _read_request(cast(socket.socket, request))
self.assertEqual(body, b"abcde")
proxy = MemoryProxy.__new__(MemoryProxy)
proxy.exit = ProxyExit("socks5", "proxy", 1080)
upstream = ChunkSocket([b""])
proxy.dial = lambda target, timeout=20.0: cast(socket.socket, upstream)
with patch.object(proxy_module, "_copy_until_close"):
proxy.forward_http(
cast(socket.socket, ChunkSocket([])),
"POST",
"http://example.test/a",
_parse_request(head)[2],
body,
)
sent = upstream.sent[0].decode("iso-8859-1")
self.assertIn("Content-Length: 5", sent)
self.assertNotIn("Transfer-Encoding:", sent)
def test_registry_generation_and_shutdown(self) -> None:
registry = ProxyRegistry()
url, undo = registry.configure(
"safe", 1, "127.0.0.1", 0, ProxyExit("http", "127.0.0.1", 8080), "network-1"
)
port = int(url.rsplit(":", 1)[1])
self.assertFalse(registry.ready("safe", port, "runtime-1", "network-1"))
self.assertTrue(registry.bind("safe", 1, url, "runtime-1", "network-1"))
self.assertTrue(registry.ready("safe", port, "runtime-1", "network-1"))
self.assertFalse(registry.remove("safe", 1, "runtime-2", "network-1"))
undo()
registry.close()
def test_memory_proxy_can_close(self) -> None:
proxy = MemoryProxy(
"safe", 1, "127.0.0.1", 0, ProxyExit("http", "127.0.0.1", 8080), "network"
)
self.assertGreater(proxy.listener.getsockname()[1], 0)
proxy.close()
def test_proxy_request_parser_and_copy_helpers(self) -> None:
request = ChunkSocket(
[
b"POST http://example.test/a HTTP/1.1\r\nContent-Length: 3\r\nHost: example.test\r\n\r\nabc"
]
)
head, body = _read_request(cast(socket.socket, request))
self.assertEqual(body, b"abc")
method, target, headers = _parse_request(head)
self.assertEqual((method, target), ("POST", "http://example.test/a"))
self.assertEqual(headers[0], ("Content-Length", "3"))
source = ChunkSocket([b"one", b"", b"ignored"])
destination = ChunkSocket([])
_copy_until_close(cast(socket.socket, source), cast(socket.socket, destination))
self.assertEqual(b"".join(destination.sent), b"one")
self.assertEqual(
_recv_exact(cast(socket.socket, ChunkSocket([b"ab", b"cd"])), 4), b"abcd"
)
self.assertTrue(_is_ipv4("127.0.0.1"))
self.assertFalse(_is_ipv4("host.example"))
with self.assertRaises(ValueError):
_parse_request(b"BROKEN\r\n\r\n")
def test_connect_payload_in_same_read_is_preserved(self) -> None:
class GreedySocket(ChunkSocket):
def recv(self, size: int) -> bytes:
del size
return self.chunks.pop(0) if self.chunks else b""
upstream = GreedySocket([b"HTTP/1.1 200 OK\r\nX-Test: yes\r\n\r\nTLS"])
with patch.object(proxy_module, "_open_host", return_value=upstream):
result = _dial_http_proxy(
ProxyExit("http", "proxy", 8080), "target:443", 1.0
)
self.assertEqual(_recv_exact(cast(socket.socket, result), 3), b"TLS")
result.close()
def test_http_and_socks_handshakes(self) -> None:
http_socket = ChunkSocket([b"HTTP/1.1 200 Connection Established\r\n\r\n"])
with patch.object(proxy_module, "_open_host", return_value=http_socket):
result = _dial_http_proxy(
ProxyExit("http", "proxy", 8080, "u", "c"), "target:443", 1.0
)
self.assertIs(result, http_socket)
self.assertIn(b"Proxy-Authorization: Basic dTpj", http_socket.sent[0])
socks4_socket = ChunkSocket([b"\x00\x5a\x00\x00\x00\x00\x00\x00"])
with patch.object(proxy_module, "_open_host", return_value=socks4_socket):
self.assertIs(
_dial_socks4(ProxyExit("socks4", "proxy", 1080), "127.0.0.1:80", 1.0),
socks4_socket,
)
socks5_socket = ChunkSocket(
[b"\x05\x00", b"\x05\x00\x00\x01\x7f\x00\x00\x01\x00\x50"]
)
with patch.object(proxy_module, "_open_host", return_value=socks5_socket):
self.assertIs(
_dial_socks5(ProxyExit("socks5", "proxy", 1080), "127.0.0.1:80", 1.0),
socks5_socket,
)
with self.assertRaises(OSError):
_dial_socks5(ProxyExit("socks5", "proxy", 1080), "host:0", 1.0)
def test_proxy_forward_and_tunnel_paths(self) -> None:
proxy = MemoryProxy.__new__(MemoryProxy)
proxy.exit = ProxyExit("socks5", "proxy", 1080)
upstream = ChunkSocket([b"reply", b""])
client = ChunkSocket([])
cast(Any, proxy).dial = lambda target, timeout=20.0: cast(
socket.socket, upstream
)
with patch.object(proxy_module, "_copy_until_close") as copy:
proxy.forward_http(
cast(socket.socket, client),
"GET",
"http://example.test/path?q=1",
[("Host", "example.test")],
b"",
)
copy.assert_called_once()
self.assertIn(b"GET /path?q=1 HTTP/1.1", upstream.sent[0])
proxy.exit = ProxyExit("http", "proxy", 8080)
upstream = ChunkSocket([b""])
cast(Any, proxy).dial = lambda target, timeout=20.0: cast(
socket.socket, upstream
)
with (
patch.object(proxy_module, "_open_host", return_value=upstream),
patch.object(proxy_module, "_copy_until_close"),
):
proxy.forward_http(
cast(socket.socket, client), "GET", "http://example.test/", [], b""
)
self.assertIn(b"GET http://example.test/ HTTP/1.1", upstream.sent[0])
class ChunkSocket:
def __init__(self, chunks: list[bytes]) -> None:
self.chunks = list(chunks)
self.sent: list[bytes] = []
def recv(self, size: int) -> bytes:
if not self.chunks:
return b""
chunk = self.chunks.pop(0)
if len(chunk) <= size:
return chunk
self.chunks.insert(0, chunk[size:])
return chunk[:size]
def sendall(self, data: bytes) -> None:
self.sent.append(data)
def send(self, data: bytes) -> None:
self.sent.append(data)
def settimeout(self, value: float | None) -> None:
del value
def close(self) -> None:
return None
class GatewayLifecycleTests(unittest.TestCase):
def test_server_tracks_daemon_request_threads_and_timeout(self) -> None:
server = gateway_module.GatewayHTTPServer(("127.0.0.1", 0), Mock())
try:
self.assertTrue(server.daemon_threads)
self.assertEqual(gateway_module.GatewayHandler.protocol_version, "HTTP/1.1")
finally:
server.server_close()
def _input(self, stopped: bool = True) -> dict:
return {
"alias": "safe",
"name": "Safe",
"profile_id": "safe",
"cmd": ["about:blank"],
"binding_version": 1,
"network_exit_id": "",
"network_exit": {},
"stopped": stopped,
}
class AdditionalGatewayCoverageTests(unittest.TestCase):
def test_douyin_connect_rejects_bad_discovery(self) -> None:
browser = DouyinBrowser(lambda _: "https://browser:9222")
with self.assertRaises(DouyinError):
browser._connect("safe")
cases = [
(500, b"{}"),
(200, b"{}"),
(200, json.dumps([{"type": "service"}]).encode()),
(
200,
json.dumps(
[{"type": "page", "url": "https://www.douyin.com/1"}]
).encode(),
),
(
200,
json.dumps(
[
{
"type": "page",
"url": "https://www.douyin.com/1",
"webSocketDebuggerUrl": "http://browser/devtools/page/1",
}
]
).encode(),
),
]
for status, body in cases:
with self.subTest(status=status, body=body):
http = FakeHTTPConnection(FakeHTTPResponse(status, body))
with (
patch.object(
douyin_module.http.client, "HTTPConnection", return_value=http
),
self.assertRaises(DouyinError),
):
browser._connect("safe")
targets = [
{
"type": "page",
"url": "https://www.douyin.com/1",
"webSocketDebuggerUrl": "ws://browser:9222/devtools/page/1",
},
{
"type": "page",
"url": "https://www.douyin.com/2",
"webSocketDebuggerUrl": "ws://browser:9222/devtools/page/2",
},
]
http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(targets).encode()))
with (
patch.object(
douyin_module.http.client, "HTTPConnection", return_value=http
),
self.assertRaises(DouyinError),
):
browser._connect("safe")
def test_douyin_fetch_identity_and_confirmed_actions(self) -> None:
browser = DouyinBrowser()
def bind(connection: BrowserCDP) -> None:
BrowserTests()._with_connection(browser, connection)
for result in (
{"too_large": True},
{"status": 302, "body": ""},
{"status": 200, "body": 1},
):
bind(BrowserCDP(["https://www.douyin.com", result]))
with self.assertRaises(DouyinError):
browser.get("safe", "https://www.douyin.com/a")
cast(Any, browser).get = lambda alias, target: BrowserResponse(200, "not-json")
with self.assertRaises(DouyinError):
browser.identity("safe")
cast(Any, browser).get = lambda alias, target: BrowserResponse(
200, json.dumps({"status_code": 0, "user": {"uid": "1", "sec_uid": "sec"}})
)
self.assertEqual(browser.identity("safe")["uid"], "1")
cast(Any, browser).get = lambda alias, target: BrowserResponse(
200,
json.dumps(
{
"status_code": 0,
"extra": {"now": 1789401629000},
"user": {"uid": "1", "sec_uid": "sec"},
}
),
)
self.assertTrue(browser.identity("safe")["platform_now"].startswith("2026-"))
cast(Any, browser).get = lambda alias, target: BrowserResponse(
403, json.dumps({"status_code": 0, "user": {"uid": "1", "sec_uid": "sec"}})
)
with self.assertRaises(DouyinError):
browser.identity("safe")
cast(Any, browser).identity = lambda alias, expected_uid=None: {
"uid": expected_uid or "1"
}
cast(Any, browser)._evaluate = lambda alias, expression: {
"status": 200,
"action": "sent",
}
bind(BrowserCDP(["https://www.douyin.com", {}]))
self.assertEqual(
browser.action("safe", "1", "follow", "2", confirm=True)["action"], "sent"
)
bind(BrowserCDP(["https://www.douyin.com", {}]))
self.assertEqual(
browser.action("safe", "1", "dm", "2", text="hello", confirm=True)[
"status"
],
200,
)
cast(Any, browser)._evaluate = lambda alias, expression: "bad"
bind(BrowserCDP(["https://www.douyin.com", "bad"]))
with self.assertRaises(DouyinError):
browser.action("safe", "1", "follow", "2", confirm=True)
def test_gateway_and_proxy_validation_edges(self) -> None:
self.assertEqual(
parse_proxy_exit({"protocol": "http", "host": "", "port": 80}).host, ""
)
with self.assertRaises(RequestError):
validate_proxy_exit(
parse_proxy_exit({"protocol": "http", "host": "", "port": 80})
)
with self.assertRaises(RequestError):
validate_proxy_exit(
parse_proxy_exit({"protocol": "http", "host": "proxy", "port": 0})
)
with self.assertRaises(RequestError):
validate_proxy_restore(
{
"binding_version": 1,
"runtime_id": "a" * 64,
"network_id": "n",
"network_exit_id": "x",
},
"bad alias",
)
self.assertFalse(valid_account_key_query({"key": ["bad key"]}, "key"))
def test_cdp_error_and_proxy_auth_paths(self) -> None:
socket_ = FakeSocket([{"id": 1, "result": {"result": {"value": {"ok": True}}}}])
connection = CDPConnection(cast(websocket.WebSocket, socket_))
self.assertEqual(connection.evaluate("1"), {"ok": True})
socket_ = FakeSocket([{"id": 1, "result": {"result": {}}}])
with self.assertRaises(DouyinError):
CDPConnection(cast(websocket.WebSocket, socket_)).evaluate("1")
with self.assertRaises(DouyinError):
CDPConnection(
cast(websocket.WebSocket, FakeSocket([{"id": 1, "error": {}}]))
).command("Page.enable")
with self.assertRaises(DouyinError):
CDPConnection(
cast(websocket.WebSocket, FakeSocket([{"id": 1, "result": []}]))
).command("Page.enable")
with self.assertRaises(DouyinError):
CDPConnection(cast(websocket.WebSocket, FakeSocket([]))).wait_event(
"Page.loadEventFired", lambda _: True, timeout=0.01
)
socket_ = FakeSocket([{"id": 1, "result": {"exceptionDetails": {}}}])
with self.assertRaises(DouyinError):
CDPConnection(cast(websocket.WebSocket, socket_)).evaluate("1")
target = [
{
"type": "page",
"url": "https://www.douyin.com/1",
"webSocketDebuggerUrl": "ws://browser:9222/devtools/page/1",
}
]
http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(target).encode()))
with (
patch.object(
douyin_module.http.client, "HTTPConnection", return_value=http
),
patch.object(
douyin_module.websocket,
"create_connection",
side_effect=OSError("down"),
),
self.assertRaises(DouyinError),
):
DouyinBrowser(lambda _: "http://browser:9222")._connect("safe")
socks5 = ChunkSocket(
[b"\x05\x02", b"\x01\x00", b"\x05\x00\x00\x01\x7f\x00\x00\x01\x00\x50"]
)
with patch.object(proxy_module, "_open_host", return_value=socks5):
self.assertIs(
_dial_socks5(
ProxyExit("socks5", "proxy", 1080, "u", "p"), "127.0.0.1:80", 1.0
),
socks5,
)
def test_direct_message_notice_and_uncertain_post_contract(self) -> None:
notice = normalize_notice(
{
"dm": {
"message_id": "123456",
"from_user": {"uid": "456789"},
"text": "hello",
},
"create_time": 1700000000,
}
)
self.assertIsNotNone(notice)
assert notice is not None
self.assertEqual(notice["event_type"], "dm")
self.assertEqual(notice["interactor_uid"], "456789")
self.assertEqual(notice["message_type"], "text")
self.assertEqual(notice["message_text"], "hello")
non_text = normalize_notice(
{
"dm": {
"message_id": "123457",
"from_user": {"uid": "456789"},
"message_type": "image",
"image_url": "https://example.invalid/image",
}
}
)
assert non_text is not None
self.assertEqual(non_text["message_type"], "image")
self.assertEqual(non_text["message_text"], "")
script = action_expression({"alias": "safe", "action": "follow", "target": "2"})
self.assertIn("POST_UNCERTAIN", script)
self.assertIn("BUSINESS_REJECTED", script)
def test_cdp_timeout_terminates_page_evaluation(self) -> None:
class TimeoutSocket(FakeSocket):
def __init__(self) -> None:
super().__init__([])
self.receives = 0
def recv(self) -> str:
self.receives += 1
if self.receives == 1:
raise TimeoutError("deadline")
return json.dumps({"id": 2, "result": {}})
socket_ = TimeoutSocket()
connection = CDPConnection(cast(websocket.WebSocket, socket_))
with (
patch.object(douyin_module, "CONTROL_TIMEOUT", 0.001),
self.assertRaises(DouyinError),
):
connection.command("Runtime.evaluate")
sent = [json.loads(item) for item in socket_.sent]
self.assertEqual(sent[1]["method"], "Runtime.terminateExecution")
def test_configured_target_and_media_wait_edge_cases(self) -> None:
targets = [
{
"type": "page",
"id": "other",
"url": "https://www.douyin.com/1",
"webSocketDebuggerUrl": "ws://browser:9222/devtools/page/1",
}
]
http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(targets).encode()))
with (
patch.object(
douyin_module.http.client, "HTTPConnection", return_value=http
),
self.assertRaisesRegex(DouyinError, "configured browser page target"),
):
DouyinBrowser(
lambda _: "http://browser:9222", target_id="missing"
)._connect("safe")
cdp = Mock()
cdp.command.return_value = {"frameId": "frame-1"}
cdp.evaluate.side_effect = [
DouyinError("page is still loading"),
{"url": "https://www.douyin.com/video/123", "readyState": "complete"},
{"error": "media_source_unavailable"},
]
browser = DouyinBrowser()
BrowserTests()._with_connection(browser, cast(BrowserCDP, cdp))
with (
patch.object(douyin_module.time, "monotonic", side_effect=[0.0, 1.0, 2.0]),
patch.object(douyin_module.time, "sleep"),
self.assertRaisesRegex(DouyinError, "media_source_unavailable"),
):
browser.get_media("safe", "https://www.douyin.com/video/123")
def test_identity_and_message_history_reject_invalid_values(self) -> None:
browser = DouyinBrowser()
for server_now in (0, float("nan")):
cast(Any, browser).get = lambda alias, target, now=server_now: (
BrowserResponse(
200,
json.dumps(
{
"status_code": 0,
"extra": {"now": now},
"user": {"uid": "123", "sec_uid": "sec"},
}
),
)
)
with (
self.subTest(server_now=server_now),
self.assertRaisesRegex(DouyinError, "platform clock"),
):
browser.identity("safe")
identity = Mock(return_value={"uid": "123"})
evaluate = Mock(return_value={"status": "succeeded", "messages": []})
cast(Any, browser).identity = identity
cast(Any, browser)._evaluate = evaluate
result = browser.message_history("safe", "123", "456", 20)
self.assertEqual(result["status"], "succeeded")
identity.assert_called_once_with("safe", "123")
self.assertIn("456", evaluate.call_args.args[1])
for expected_uid, target_uid, limit in (
("bad", "456", 20),
("123", "bad", 20),
("123", "456", 0),
("123", "456", 201),
):
with (
self.subTest(
expected_uid=expected_uid, target_uid=target_uid, limit=limit
),
self.assertRaises(DouyinError),
):
browser.message_history("safe", expected_uid, target_uid, limit)
evaluate.return_value = "bad"
with self.assertRaisesRegex(DouyinError, "message history response"):
browser.message_history("safe", "123", "456")
def test_listener_open_and_recovery_boundaries(self) -> None:
browser = Mock()
browser.identity.return_value = {
"uid": "123",
"platform_now": "2026-09-14T00:00:00+00:00",
}
connection = BrowserCDP([json.dumps({"connected": True})])
browser._connect.return_value = connection
subscription = DouyinSubscription.__new__(DouyinSubscription)
subscription.browser = browser
subscription.alias = "safe"
subscription.uid = "123"
subscription.key = "__creatorhub_notice_sub_safe"
opened, boundary_at = subscription._open_listener()
self.assertIs(opened, connection)
self.assertEqual(boundary_at, "2026-09-14T00:00:00+00:00")
constructor_connection = BrowserCDP([json.dumps({"connected": True})])
browser._connect.return_value = constructor_connection
with patch.object(threading.Thread, "start"):
constructed = DouyinSubscription(browser, "safe", "123")
self.assertEqual(constructed.queue[0]["kind"], "baseline")
constructed.stopped.set()
constructed._detail_pool.shutdown(wait=True, cancel_futures=True)
broken = BrowserCDP([json.dumps({"connected": True}), "{}"])
browser._connect.return_value = broken
browser.identity.return_value = {"uid": "123"}
with self.assertRaisesRegex(DouyinError, "event boundary"):
subscription._open_listener()
self.assertTrue(broken.closed)
old = Mock()
old.evaluate.side_effect = DouyinError("stale listener")
new = Mock()
subscription.connection = old
subscription._connection_lock = threading.RLock()
subscription._epoch = 0
subscription._initial_boundary_pending = False
subscription.stopped = threading.Event()
subscription.stopped.wait = Mock(return_value=False)
subscription._open_listener = Mock(
return_value=(new, "2026-09-14T00:00:01+00:00")
)
subscription._put = Mock()
subscription._recover()
old.close.assert_called_once_with()
self.assertIs(subscription.connection, new)
self.assertEqual(subscription._epoch, 1)
self.assertEqual(
[call.args[0]["kind"] for call in subscription._put.call_args_list],
["reconnected", "baseline"],
)
class DouyinReleaseRemediationTests(unittest.TestCase):
def test_browser_action_marker_is_persisted_in_profile_storage(self) -> None:
browser = DouyinBrowser()
browser._evaluate = Mock(side_effect=[{"runtime_id": "old"}, True, True])
self.assertEqual(browser.action_ownership("safe"), {"runtime_id": "old"})
browser.set_action_ownership(
"safe", {"runtime_id": "new", "operation_id": "op-1"}
)
browser.clear_action_ownership("safe", "op-1")
self.assertEqual(browser._evaluate.call_count, 3)
self.assertIn(
"__creatorhub_action_ownership_v1",
browser._evaluate.call_args_list[1].args[1],
)
def test_gateway_action_claim_rejects_marker_from_any_runtime(self) -> None:
gateway = Gateway.__new__(Gateway)
gateway._action_ownership_lock = threading.Lock()
gateway._uncertain_actions = {}
gateway.browser = Mock()
gateway.browser.action_ownership.return_value = {"runtime_id": "old-runtime"}
with self.assertRaises(RequestError):
gateway._claim_action("safe", "new-runtime", "op-1")
gateway.browser.clear_action_ownership.assert_not_called()
gateway.browser.set_action_ownership.assert_not_called()
def test_notice_keeps_raw_gateway_receipt_time(self) -> None:
notice = normalize_notice(
{"dm": {"message_id": "1", "from_user": {"uid": "2"}, "text": "hi"}},
"2026-09-15T00:00:00+00:00",
)
assert notice is not None
self.assertEqual(notice["gateway_received_at"], "2026-09-15T00:00:00+00:00")
if __name__ == "__main__":
unittest.main()