feat: integrate creator hub douyin workflows
This commit is contained in:
+154
-62
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import http.client
|
||||
import json
|
||||
import logging
|
||||
@@ -31,6 +33,7 @@ IDENTITY_URL = (
|
||||
WORKS_PATH = "/aweme/v1/web/aweme/post/"
|
||||
COMMENTS_PATH = "/aweme/v1/web/comment/list/"
|
||||
RESPONSE_LIMIT = 1 << 20
|
||||
MEDIA_RESPONSE_LIMIT = 32 << 20
|
||||
CONTROL_TIMEOUT = 15.0
|
||||
UID_RE = re.compile(r"^[1-9][0-9]{0,19}$")
|
||||
ID_RE = re.compile(r"^[1-9][0-9]{0,63}$")
|
||||
@@ -62,6 +65,13 @@ class BrowserResponse:
|
||||
challenge: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserMediaResponse:
|
||||
status: int
|
||||
content_type: str
|
||||
body_base64: str
|
||||
|
||||
|
||||
class CDPConnection:
|
||||
def __init__(self, socket: websocket.WebSocket) -> None:
|
||||
self.socket = socket
|
||||
@@ -212,10 +222,20 @@ class CDPConnection:
|
||||
|
||||
|
||||
class DouyinBrowser:
|
||||
def __init__(self, endpoint: Callable[[str], str] | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: Callable[[str], str] | None = None,
|
||||
*,
|
||||
origin: str = ORIGIN,
|
||||
url_validator: Callable[[object], bool] | None = None,
|
||||
media_validator: Callable[[object], bool] | None = None,
|
||||
) -> None:
|
||||
self.endpoint = endpoint or (
|
||||
lambda alias: f"http://creatorhub-browser-{alias}:9222"
|
||||
)
|
||||
self.origin = origin
|
||||
self.url_validator = url_validator or is_douyin_url
|
||||
self.media_validator = media_validator or is_douyin_media_url
|
||||
|
||||
@contextmanager
|
||||
def connection(self, alias: str):
|
||||
@@ -272,14 +292,18 @@ class DouyinBrowser:
|
||||
for target in targets
|
||||
if isinstance(target, dict) and target.get("type") == "page"
|
||||
]
|
||||
douyin_targets = [
|
||||
target for target in page_targets if is_douyin_url(target.get("url", ""))
|
||||
matching_targets = [
|
||||
target
|
||||
for target in page_targets
|
||||
if self.url_validator(target.get("url", ""))
|
||||
]
|
||||
if len(douyin_targets) > 1 or (not douyin_targets and len(page_targets) > 1):
|
||||
if len(matching_targets) > 1 or (
|
||||
not matching_targets and len(page_targets) > 1
|
||||
):
|
||||
raise DouyinError("browser has more than one page target")
|
||||
target = (
|
||||
douyin_targets[0]
|
||||
if douyin_targets
|
||||
matching_targets[0]
|
||||
if matching_targets
|
||||
else page_targets[0]
|
||||
if page_targets
|
||||
else None
|
||||
@@ -319,63 +343,9 @@ class DouyinBrowser:
|
||||
raise DouyinError("browser CDP connection failed") from exc
|
||||
return CDPConnection(socket)
|
||||
|
||||
def set_cookies(self, alias: str, cookies: list[dict]) -> None:
|
||||
with self.connection(alias) as cdp:
|
||||
cdp.command("Network.enable")
|
||||
cdp.command("Network.clearBrowserCookies")
|
||||
cdp.command("Page.enable")
|
||||
navigation = cdp.command("Page.navigate", {"url": ORIGIN_URL})
|
||||
frame_id = navigation.get("frameId")
|
||||
if not isinstance(frame_id, str) or navigation.get("errorText"):
|
||||
raise DouyinError("browser navigation failed")
|
||||
cdp.wait_event(
|
||||
"Page.frameNavigated",
|
||||
lambda params: (
|
||||
params.get("frame", {}).get("id") == frame_id
|
||||
and is_douyin_url(params.get("frame", {}).get("url"))
|
||||
),
|
||||
)
|
||||
deadline = time.monotonic() + CONTROL_TIMEOUT
|
||||
last_error: DouyinError | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
state = cdp.evaluate(
|
||||
"({origin:location.origin,state:document.readyState})"
|
||||
)
|
||||
if (
|
||||
isinstance(state, dict)
|
||||
and state.get("origin") == ORIGIN
|
||||
and state.get("state") in {"interactive", "complete"}
|
||||
):
|
||||
break
|
||||
except DouyinError as exc:
|
||||
last_error = exc
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
if last_error:
|
||||
raise DouyinError("browser did not reach Douyin") from last_error
|
||||
raise DouyinError("browser did not reach Douyin")
|
||||
values = []
|
||||
for cookie in cookies:
|
||||
value = {
|
||||
"name": cookie["name"],
|
||||
"value": cookie["value"],
|
||||
"url": ORIGIN_URL,
|
||||
"domain": cookie["domain"],
|
||||
"path": cookie.get("path") or "/",
|
||||
"secure": bool(cookie.get("secure")),
|
||||
"httpOnly": bool(cookie.get("http_only")),
|
||||
}
|
||||
if cookie.get("same_site"):
|
||||
value["sameSite"] = cookie["same_site"]
|
||||
if cookie.get("expires"):
|
||||
value["expires"] = cookie["expires"]
|
||||
values.append(value)
|
||||
cdp.command("Network.setCookies", {"cookies": values})
|
||||
|
||||
def get(self, alias: str, target: str) -> BrowserResponse:
|
||||
with self.connection(alias) as cdp:
|
||||
if cdp.evaluate("location.origin") != ORIGIN:
|
||||
if cdp.evaluate("location.origin") != self.origin:
|
||||
raise DouyinError("restricted browser origin changed")
|
||||
expression = f"""(async()=>{{
|
||||
const r=await fetch({json.dumps(target)},{{credentials:'include',redirect:'error'}});
|
||||
@@ -402,6 +372,72 @@ class DouyinBrowser:
|
||||
raise DouyinError("restricted browser fetch returned invalid body")
|
||||
return BrowserResponse(status, body, detect_challenge(status, body))
|
||||
|
||||
def get_media(self, alias: str, target: str) -> BrowserMediaResponse:
|
||||
if not self.media_validator(target):
|
||||
raise DouyinError("restricted browser media target is invalid")
|
||||
with self.connection(alias) as cdp:
|
||||
navigation = cdp.command("Page.navigate", {"url": target})
|
||||
frame_id = navigation.get("frameId")
|
||||
if not isinstance(frame_id, str) or navigation.get("errorText"):
|
||||
raise DouyinError("Douyin media page navigation failed")
|
||||
cdp.wait_event(
|
||||
"Page.frameNavigated",
|
||||
lambda params: (
|
||||
params.get("frame", {}).get("id") == frame_id
|
||||
and self.url_validator(params.get("frame", {}).get("url"))
|
||||
),
|
||||
)
|
||||
deadline = time.monotonic() + CONTROL_TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
if cdp.evaluate("document.readyState") in {"interactive", "complete"}:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
raise DouyinError("Douyin media page did not load")
|
||||
result = cdp.evaluate(
|
||||
f"""(async()=>{{
|
||||
const video=document.querySelector('video');
|
||||
const source=video?.currentSrc||video?.src||'';
|
||||
if(!source)return {{error:'media_source_unavailable'}};
|
||||
const r=await fetch(source,{{credentials:'include',redirect:'error'}});
|
||||
if(!r.body)return {{status:r.status,content_type:r.headers.get('content-type')||'',body:''}};
|
||||
const reader=r.body.getReader(), chunks=[]; let size=0;
|
||||
for(;;){{const item=await reader.read();if(item.done)break;
|
||||
if(size+item.value.byteLength>{MEDIA_RESPONSE_LIMIT}){{await reader.cancel();return {{too_large:true}};}}
|
||||
size+=item.value.byteLength;chunks.push(item.value);
|
||||
}}
|
||||
const bytes=new Uint8Array(size); let offset=0;
|
||||
for(const chunk of chunks){{bytes.set(chunk,offset);offset+=chunk.length;}}
|
||||
let binary='';
|
||||
for(let offset=0;offset<bytes.length;offset+=0x8000)binary+=String.fromCharCode(...bytes.subarray(offset,Math.min(offset+0x8000,bytes.length)));
|
||||
return {{status:r.status,content_type:r.headers.get('content-type')||'',body:btoa(binary)}};
|
||||
}})()"""
|
||||
)
|
||||
if (
|
||||
not isinstance(result, dict)
|
||||
or result.get("too_large")
|
||||
or result.get("error")
|
||||
):
|
||||
raise DouyinError("Douyin media download failed")
|
||||
status = result.get("status")
|
||||
content_type = result.get("content_type")
|
||||
body = result.get("body")
|
||||
if (
|
||||
not isinstance(status, int)
|
||||
or not isinstance(content_type, str)
|
||||
or not isinstance(body, str)
|
||||
):
|
||||
raise DouyinError("Douyin media response is invalid")
|
||||
if status < 200 or status >= 300:
|
||||
raise DouyinError("Douyin media response was not successful")
|
||||
try:
|
||||
decoded_size = len(base64.b64decode(body, validate=True))
|
||||
except (ValueError, binascii.Error) as exc:
|
||||
raise DouyinError("Douyin media response is invalid") from exc
|
||||
if decoded_size > MEDIA_RESPONSE_LIMIT:
|
||||
raise DouyinError("Douyin media response is too large")
|
||||
return BrowserMediaResponse(status, content_type, body)
|
||||
|
||||
def identity(self, alias: str, expected_uid: str | None = None) -> dict:
|
||||
response = self.get(alias, IDENTITY_URL)
|
||||
try:
|
||||
@@ -521,7 +557,7 @@ class DouyinBrowser:
|
||||
|
||||
def _evaluate(self, alias: str, expression: str) -> object:
|
||||
with self.connection(alias) as cdp:
|
||||
if cdp.evaluate("location.origin") != ORIGIN:
|
||||
if cdp.evaluate("location.origin") != self.origin:
|
||||
raise DouyinError("restricted browser origin changed")
|
||||
return cdp.evaluate(expression)
|
||||
|
||||
@@ -1020,6 +1056,43 @@ def _notice_id(value: object) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _direct_message_type(detail: dict, message: object) -> str:
|
||||
raw_type = (
|
||||
detail.get("message_type") or detail.get("msg_type") or detail.get("type")
|
||||
)
|
||||
if isinstance(message, dict):
|
||||
raw_type = raw_type or message.get("type") or message.get("message_type")
|
||||
if isinstance(raw_type, int) and not isinstance(raw_type, bool):
|
||||
raw_type = {1: "text", 2: "image", 3: "voice", 4: "video", 5: "sticker"}.get(
|
||||
raw_type
|
||||
)
|
||||
if isinstance(raw_type, str):
|
||||
normalized = raw_type.strip().lower()
|
||||
aliases = {
|
||||
"text": "text",
|
||||
"txt": "text",
|
||||
"image": "image",
|
||||
"img": "image",
|
||||
"picture": "image",
|
||||
"voice": "voice",
|
||||
"audio": "voice",
|
||||
"video": "video",
|
||||
"sticker": "sticker",
|
||||
"emoji": "sticker",
|
||||
}
|
||||
if normalized in aliases:
|
||||
return aliases[normalized]
|
||||
if any(detail.get(key) for key in ("voice", "audio", "audio_url")):
|
||||
return "voice"
|
||||
if any(detail.get(key) for key in ("image", "picture", "image_url")):
|
||||
return "image"
|
||||
if any(detail.get(key) for key in ("video", "video_url")):
|
||||
return "video"
|
||||
if isinstance(message, str) and message:
|
||||
return "text"
|
||||
return "non_text"
|
||||
|
||||
|
||||
def normalize_notice(notice: object) -> dict | None:
|
||||
if not isinstance(notice, dict):
|
||||
raise DouyinError("notification detail is invalid")
|
||||
@@ -1062,6 +1135,7 @@ def normalize_notice(notice: object) -> dict | None:
|
||||
or notice.get("text")
|
||||
or ""
|
||||
)
|
||||
message_type = _direct_message_type(detail, message)
|
||||
if isinstance(message, dict):
|
||||
message = message.get("text") or message.get("content") or ""
|
||||
if not isinstance(message, str) or len(message) > 100000:
|
||||
@@ -1082,6 +1156,7 @@ def normalize_notice(notice: object) -> dict | None:
|
||||
"interactor_uid": _notice_id(sender.get("uid") or sender.get("user_id")),
|
||||
"comment_id": "",
|
||||
"work_id": "",
|
||||
"message_type": message_type,
|
||||
"message_text": message,
|
||||
}
|
||||
create_time = notice.get("create_time")
|
||||
@@ -1180,6 +1255,23 @@ def is_douyin_url(value: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def is_douyin_media_url(value: object) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
return (
|
||||
parsed.scheme == "https"
|
||||
and parsed.hostname == "www.douyin.com"
|
||||
and parsed.port is None
|
||||
and parsed.username is None
|
||||
and parsed.password is None
|
||||
and parsed.path.startswith("/video/")
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def detect_challenge(status: int, body: str) -> str:
|
||||
if status == 429:
|
||||
return ""
|
||||
|
||||
@@ -590,19 +590,6 @@ class Gateway:
|
||||
)
|
||||
raise
|
||||
|
||||
def set_douyin_cookies(self, alias: str, input: dict) -> None:
|
||||
cookies_value = input.get("cookies")
|
||||
if not valid_douyin_generation(input) or not valid_cookies(cookies_value):
|
||||
raise RequestError("invalid restricted browser request", 400)
|
||||
cookies = cast(list[dict], cookies_value)
|
||||
with self._alias_lock(alias):
|
||||
self._require_douyin_generation(alias, input)
|
||||
try:
|
||||
self.browser.set_cookies(alias, cookies)
|
||||
self._require_douyin_generation(alias, input)
|
||||
except DouyinError as exc:
|
||||
raise RequestError("restricted browser operation failed") from exc
|
||||
|
||||
def get_douyin(self, alias: str, input: dict) -> dict:
|
||||
if not valid_douyin_generation(input) or not valid_douyin_url(
|
||||
input.get("url", "")
|
||||
@@ -626,6 +613,28 @@ class Gateway:
|
||||
"challenge": response.challenge,
|
||||
}
|
||||
|
||||
def get_douyin_media(self, alias: str, input: dict) -> dict:
|
||||
target = input.get("url", "")
|
||||
if not valid_douyin_generation(input) or not isinstance(target, str):
|
||||
raise RequestError("invalid restricted browser request", 400)
|
||||
with self._alias_lock(alias):
|
||||
self._require_douyin_generation(alias, input)
|
||||
try:
|
||||
response = self.browser.get_media(alias, target)
|
||||
self._require_douyin_generation(alias, input)
|
||||
except DouyinError as exc:
|
||||
LOG.warning(
|
||||
"Douyin media download failed alias=%s reason=%s",
|
||||
alias,
|
||||
str(exc),
|
||||
)
|
||||
raise RequestError("restricted browser media download failed") from exc
|
||||
return {
|
||||
"status": response.status,
|
||||
"content_type": response.content_type,
|
||||
"body_base64": response.body_base64,
|
||||
}
|
||||
|
||||
def douyin_identity(self, alias: str, input: dict) -> dict:
|
||||
expected_account_key = input.get("expected_account_key", "")
|
||||
if (
|
||||
@@ -678,6 +687,8 @@ class Gateway:
|
||||
or action not in ACTIONS
|
||||
):
|
||||
raise RequestError("invalid Douyin action request", 400)
|
||||
if action == "repost":
|
||||
raise RequestError("ACTION_UNAVAILABLE", 409)
|
||||
with self._alias_lock(alias):
|
||||
self._require_douyin_generation(alias, input)
|
||||
self._claim_action(alias)
|
||||
@@ -1120,16 +1131,15 @@ class GatewayHandler(BaseHTTPRequestHandler):
|
||||
gateway.restore_proxy(alias, body)
|
||||
return None
|
||||
match = re.fullmatch(
|
||||
r"/v1/browsers/([a-z0-9][a-z0-9-]{0,31})/douyin/(cookies|get|identity|action|events)",
|
||||
r"/v1/browsers/([a-z0-9][a-z0-9-]{0,31})/douyin/(get|media|identity|action|events)",
|
||||
path,
|
||||
)
|
||||
if match:
|
||||
alias, action = match.groups()
|
||||
if action == "cookies" and method == "POST":
|
||||
gateway.set_douyin_cookies(alias, body)
|
||||
return None
|
||||
if action == "get" and method == "POST":
|
||||
return gateway.get_douyin(alias, body)
|
||||
if action == "media" and method == "POST":
|
||||
return gateway.get_douyin_media(alias, body)
|
||||
if action == "identity" and method == "POST":
|
||||
return gateway.douyin_identity(alias, body)
|
||||
if action == "action" and method == "POST":
|
||||
@@ -1409,57 +1419,6 @@ def valid_douyin_generation(value: dict) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def valid_cookies(cookies: object) -> bool:
|
||||
if not isinstance(cookies, list) or not 1 <= len(cookies) <= 64:
|
||||
return False
|
||||
for cookie in cookies:
|
||||
if not isinstance(cookie, dict):
|
||||
return False
|
||||
allowed = {
|
||||
"name",
|
||||
"value",
|
||||
"domain",
|
||||
"path",
|
||||
"secure",
|
||||
"http_only",
|
||||
"same_site",
|
||||
"expires",
|
||||
}
|
||||
if set(cookie) - allowed:
|
||||
return False
|
||||
name = cookie.get("name")
|
||||
value = cookie.get("value")
|
||||
domain = cookie.get("domain")
|
||||
path_value = cookie.get("path")
|
||||
path = "/" if path_value is None else path_value
|
||||
if (
|
||||
not isinstance(name, str)
|
||||
or not isinstance(value, str)
|
||||
or not isinstance(domain, str)
|
||||
or not isinstance(path, str)
|
||||
):
|
||||
return False
|
||||
if (
|
||||
not name
|
||||
or len(name) > 256
|
||||
or len(value) > 4096
|
||||
or len(domain) > 256
|
||||
or len(path) > 256
|
||||
or domain != domain.lower().strip()
|
||||
or (domain != "douyin.com" and not domain.endswith(".douyin.com"))
|
||||
or not path.startswith("/")
|
||||
or has_control(name)
|
||||
or has_control(value)
|
||||
or has_control(path)
|
||||
or (cookie.get("same_site", "") not in {"", "Lax", "Strict", "None"})
|
||||
):
|
||||
return False
|
||||
expires = cookie.get("expires", 0)
|
||||
if type(expires) not in (int, float) or expires < 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def valid_douyin_url(raw: object) -> bool:
|
||||
if not isinstance(raw, str):
|
||||
return False
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import socket
|
||||
@@ -83,7 +84,6 @@ 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_cookies = gateway_module.valid_cookies
|
||||
valid_douyin_generation = gateway_module.valid_douyin_generation
|
||||
valid_account_key_query = gateway_module.valid_account_key_query
|
||||
numeric_cursor = gateway_module.numeric_cursor
|
||||
@@ -228,7 +228,8 @@ class GatewayValidationTests(unittest.TestCase):
|
||||
handler._route("POST", "/v1/browsers/safe/start", {}, {})
|
||||
handler._route("POST", "/v1/browsers/safe/stop", {}, {})
|
||||
handler._route("POST", "/v1/browsers/safe/proxy", {}, {})
|
||||
handler._route("POST", "/v1/browsers/safe/douyin/cookies", {}, {})
|
||||
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", {}, {})
|
||||
handler._route("POST", "/v1/browsers/safe/douyin/action", {}, {})
|
||||
@@ -285,10 +286,6 @@ class GatewayValidationTests(unittest.TestCase):
|
||||
)
|
||||
with self.assertRaises(RequestError):
|
||||
validate_proxy_exit(ProxyExit("http", "proxy", 0))
|
||||
cookie = {"name": "sessionid", "value": "v", "domain": ".douyin.com"}
|
||||
self.assertTrue(valid_cookies([cookie]))
|
||||
self.assertFalse(valid_cookies([{**cookie, "domain": "evil.example"}]))
|
||||
self.assertFalse(valid_cookies([{**cookie, "same_site": "bad"}]))
|
||||
generation = {
|
||||
"binding_version": 1,
|
||||
"runtime_id": "a" * 64,
|
||||
@@ -332,19 +329,6 @@ class GatewayValidationTests(unittest.TestCase):
|
||||
load_config({"GATEWAY_TOKEN": "short"})
|
||||
self.assertFalse(valid_douyin_url("http://www.douyin.com/video/1"))
|
||||
self.assertFalse(valid_douyin_url("https://www.douyin.com/unknown"))
|
||||
self.assertFalse(valid_cookies("not-a-list"))
|
||||
self.assertFalse(
|
||||
valid_cookies(
|
||||
[
|
||||
{
|
||||
"name": "x",
|
||||
"value": "v",
|
||||
"domain": ".douyin.com",
|
||||
"path": "relative",
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
with self.assertRaises(RequestError):
|
||||
decode_generation(
|
||||
{"binding_version": 1, "runtime_id": "a" * 64}, True, True
|
||||
@@ -651,33 +635,36 @@ class BrowserTests(unittest.TestCase):
|
||||
|
||||
cast(Any, browser).connection = bound
|
||||
|
||||
def test_cookie_navigation_and_fetch(self) -> None:
|
||||
cdp = BrowserCDP(
|
||||
[{"origin": "https://www.douyin.com", "state": "complete"}, "{}"]
|
||||
)
|
||||
browser = DouyinBrowser()
|
||||
self._with_connection(browser, cdp)
|
||||
browser.set_cookies(
|
||||
"safe", [{"name": "sessionid", "value": "v", "domain": ".douyin.com"}]
|
||||
)
|
||||
self.assertIn("Page.navigate", [method for method, _ in cdp.commands])
|
||||
set_command = next(
|
||||
params for method, params in cdp.commands if method == "Network.setCookies"
|
||||
)
|
||||
assert set_command is not None
|
||||
self.assertEqual(set_command["cookies"][0]["domain"], ".douyin.com")
|
||||
|
||||
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_media_download_is_browser_mediated_and_bounded(self) -> None:
|
||||
payload = base64.b64encode(b"video-bytes").decode("ascii")
|
||||
cdp = BrowserCDP(
|
||||
[
|
||||
"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])
|
||||
|
||||
def test_connect_identity_and_actions(self) -> None:
|
||||
target = [
|
||||
@@ -1768,13 +1755,6 @@ class AdditionalGatewayCoverageTests(unittest.TestCase):
|
||||
browser.action("safe", "1", "follow", "2", confirm=True)
|
||||
|
||||
def test_gateway_and_proxy_validation_edges(self) -> None:
|
||||
for value in (
|
||||
None,
|
||||
[],
|
||||
[{"name": "bad"}],
|
||||
[{"name": "sessionid", "value": "x", "domain": "evil.test"}],
|
||||
):
|
||||
self.assertFalse(valid_cookies(value))
|
||||
self.assertEqual(
|
||||
parse_proxy_exit({"protocol": "http", "host": "", "port": 80}).host, ""
|
||||
)
|
||||
@@ -2009,7 +1989,21 @@ class AdditionalGatewayCoverageTests(unittest.TestCase):
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user