Files
creator-hub/browser_gateway/test_gateway.py
T
rogee 143f3b263e fix(creator): 竞品分析页走查修复——封面本地缓存、作者名、指标全宽行、数量入卡片标题
- 后端:works 采集解析 author.nickname 与 video.cover.url_list(migration 042 新增
  creator_work_cover 本地缓存表);竞品同步成功后回填缺失封面(单次上限 40,余量自愈)
- 网关:新增 /douyin/image 浏览器内 fetch 图片端点(douyinpic CDN 白名单、4MB 限额)
- API:GET /api/creator/works/:id/cover 输出本地缓存封面(private 缓存头)
- 前端:封面改读本地缓存(认证 fetch + blob);点赞/评论/分享全宽行贴封面下方;
  作者名独立展示;'下次采集'移除;作品总数移入卡片标题右侧 extra
- '已核验' 状态标签补充 Tooltip 释义(发布时间已从平台数据成功解析核验)

测试:douyin 解析 3 例、cover 存储 PG 集成 1 例、网关 image 3 例全绿
2026-09-28 10:39:56 +08:00

1816 lines
71 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 errno
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")
create_server = gateway_module.create_server
format_listen_address = gateway_module.format_listen_address
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_create_server_reports_conflicting_listen_address(self) -> None:
gateway = Gateway.__new__(Gateway)
with patch.object(
gateway_module,
"GatewayHTTPServer",
side_effect=OSError(errno.EADDRINUSE, "Address already in use"),
):
with self.assertRaises(SystemExit) as ctx:
create_server({"listen": ("", 8081)}, gateway)
message = str(ctx.exception)
self.assertIn("0.0.0.0:8081", message)
self.assertIn("address already in use", message)
self.assertIn("LISTEN_ADDR", message)
with patch.object(
gateway_module,
"GatewayHTTPServer",
side_effect=OSError(errno.EACCES, "Permission denied"),
):
with self.assertRaises(OSError) as denied:
create_server({"listen": ("127.0.0.1", 8081)}, gateway)
self.assertEqual(denied.exception.errno, errno.EACCES)
def test_format_listen_address(self) -> None:
self.assertEqual(format_listen_address(("", 8081)), "0.0.0.0:8081")
self.assertEqual(format_listen_address(("127.0.0.1", 28187)), "127.0.0.1:28187")
self.assertEqual(format_listen_address(("::1", 8081)), "[::1]:8081")
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_image_download_accepts_douyinpic_and_rejects_other(self) -> None:
# 封面/头像走 douyinpic CDN:浏览器内 fetch + base64,无导航无签名。
payload = base64.b64encode(b"jpeg-bytes").decode("ascii")
cdp = BrowserCDP(
[
{"status": 200, "content_type": "image/jpeg", "body": payload},
]
)
browser = DouyinBrowser()
self._with_connection(browser, cdp)
response = browser.get_image(
"safe",
"https://p3-pc-sign.douyinpic.com/tos-cn-i-0813/cover.jpeg?x-expires=2105848800&x-signature=abc",
)
self.assertEqual(response.status, 200)
self.assertEqual(response.content_type, "image/jpeg")
self.assertEqual(base64.b64decode(response.body_base64), b"jpeg-bytes")
self.assertIn("fetch", cdp.expressions[-1])
cdp = BrowserCDP([])
self._with_connection(browser, cdp)
with self.assertRaisesRegex(DouyinError, "image target is invalid"):
browser.get_image("safe", "https://evil.example.com/cover.jpeg")
def test_image_download_rejects_non_image_content(self) -> None:
payload = base64.b64encode(b"<html>").decode("ascii")
cdp = BrowserCDP(
[
{"status": 200, "content_type": "text/html", "body": payload},
]
)
browser = DouyinBrowser()
self._with_connection(browser, cdp)
with self.assertRaisesRegex(DouyinError, "not an image"):
browser.get_image("safe", "https://p3-pc-sign.douyinpic.com/cover.jpeg")
def test_image_download_rejects_too_large(self) -> None:
cdp = BrowserCDP(
[
{"too_large": True},
]
)
browser = DouyinBrowser()
self._with_connection(browser, cdp)
with self.assertRaisesRegex(DouyinError, "image download failed"):
browser.get_image("safe", "https://p9-pc-sign.douyinpic.com/big.jpeg")
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()