fix: complete Douyin release remediation

This commit is contained in:
2026-09-16 01:51:14 +08:00
parent 977e541fef
commit 9095920a5b
56 changed files with 4530 additions and 518 deletions
+71 -14
View File
@@ -504,6 +504,28 @@ class DouyinBrowser:
raise DouyinError("Douyin platform clock is invalid") from exc
return result
def action_ownership(self, alias: str) -> dict | None:
value = self._evaluate(
alias,
"(() => { const raw = localStorage.getItem('__creatorhub_action_ownership_v1'); return raw === null ? null : JSON.parse(raw); })()",
)
if value is None:
return None
if not isinstance(value, dict):
raise DouyinError("browser action ownership marker is invalid")
return value
def set_action_ownership(self, alias: str, marker: dict) -> None:
if not isinstance(marker, dict):
raise DouyinError("browser action ownership marker is invalid")
expression = f"localStorage.setItem('__creatorhub_action_ownership_v1', {json.dumps(json.dumps(marker, separators=(',', ':')))})"
self._evaluate(alias, expression)
def clear_action_ownership(self, alias: str, operation_id: str = "") -> None:
expected = json.dumps(operation_id)
expression = f"(() => {{ const key='__creatorhub_action_ownership_v1'; const raw=localStorage.getItem(key); if ({expected} === '' || raw === null || JSON.parse(raw).operation_id === {expected}) localStorage.removeItem(key); return true; }})()"
self._evaluate(alias, expression)
def action(
self,
alias: str,
@@ -604,17 +626,22 @@ class DouyinBrowser:
raise
def message_history(
self, alias: str, expected_uid: str, target_uid: str, limit: int = 100
self,
alias: str,
expected_uid: str,
target_uid: str,
limit: int = 100,
cursor: str = "",
) -> dict:
if not UID_RE.fullmatch(expected_uid) or not UID_RE.fullmatch(target_uid):
raise DouyinError("message history UID is invalid")
if not 1 <= limit <= 200:
raise DouyinError("message history limit is invalid")
if not 1 <= limit <= 200 or not isinstance(cursor, str) or len(cursor) > 500:
raise DouyinError("message history request is invalid")
self.identity(alias, expected_uid)
value = self._evaluate(
alias,
message_history_expression(
{"uid": target_uid, "limit": limit}, expected_uid
{"uid": target_uid, "cursor": cursor, "limit": limit}, expected_uid
),
)
if not isinstance(value, dict):
@@ -644,6 +671,7 @@ class DouyinSubscription:
self._initial_boundary_pending = True
self._browser_inflight: set[str] = set()
self._browser_inflight_lock = threading.Lock()
self._recovery_lock = threading.Lock()
self._detail_pool = ThreadPoolExecutor(
max_workers=4, thread_name_prefix=f"creatorhub-notice-details-{alias}"
)
@@ -745,9 +773,17 @@ class DouyinSubscription:
if self.stopped.is_set():
break
self._put({"kind": "error", "reason": str(exc)})
self._recover()
self._request_recovery()
self._dispose_current()
def _request_recovery(self) -> None:
if not self._recovery_lock.acquire(blocking=False):
return
try:
self._recover()
finally:
self._recovery_lock.release()
def _recover(self) -> None:
# Dispose the old same-page handlers before installing a new state with
# the stable key; disposing afterward would remove the fresh handlers.
@@ -830,6 +866,8 @@ class DouyinSubscription:
return
if kind != "push":
raise DouyinError("notification event kind is invalid")
event = dict(event)
event.setdefault("gateway_received_at", datetime.now(timezone.utc).isoformat())
if not hasattr(self, "_browser_inflight_lock"):
self._browser_inflight_lock = threading.Lock()
if not hasattr(self, "_browser_inflight"):
@@ -849,7 +887,10 @@ class DouyinSubscription:
if success:
self._ack_browser_event(delivery_id, self._get_connection())
else:
self._retry_browser_event(delivery_id, self._get_connection())
if not self._retry_browser_event(
delivery_id, self._get_connection()
):
self._request_recovery()
if delivery_id:
with self._browser_inflight_lock:
self._browser_inflight.discard(delivery_id)
@@ -861,8 +902,10 @@ class DouyinSubscription:
connection: CDPConnection | None,
) -> None:
self._put({"kind": "error", "reason": str(error), "continuity": "gap"})
if isinstance(delivery_id, str) and connection is not None:
self._retry_browser_event(delivery_id, connection)
if not isinstance(delivery_id, str):
return
if connection is None or not self._retry_browser_event(delivery_id, connection):
self._request_recovery()
def _process_push(
self, event: dict, connection: CDPConnection, baseline: bool
@@ -884,7 +927,9 @@ class DouyinSubscription:
for notice_id in ids:
try:
for notice in self._details([notice_id], connection):
normalized = normalize_notice(notice)
normalized = normalize_notice(
notice, event.get("gateway_received_at")
)
if normalized is not None:
self._put(
{
@@ -926,7 +971,11 @@ class DouyinSubscription:
if success:
self._ack_browser_event(delivery_id, connection)
else:
self._retry_browser_event(delivery_id, connection)
# A malformed detail is a continuity gap, not a retryable
# delivery. Drop the bad delivery, record the gap, and
# establish a new baseline before accepting later events.
self._ack_browser_event(delivery_id, connection)
self._request_recovery()
except LISTENER_ERRORS as exc:
self._handle_push_error(exc, delivery_id, connection)
finally:
@@ -956,13 +1005,14 @@ class DouyinSubscription:
def _retry_browser_event(
self, delivery_id: object, connection: CDPConnection
) -> None:
) -> bool:
if not isinstance(delivery_id, str) or not delivery_id:
return
return False
try:
result = connection.evaluate(retry_expression(self.key, [delivery_id]))
if result not in (True, "true"):
raise DouyinError("notification retry acknowledgement failed")
return True
except (
DouyinError,
OSError,
@@ -971,6 +1021,7 @@ class DouyinSubscription:
websocket.WebSocketException,
):
LOG.warning("notification retry marker failed", exc_info=True)
return False
def pending(self) -> list[dict]:
with self.condition:
@@ -1172,7 +1223,9 @@ def _direct_message_type(detail: dict, message: object) -> str:
return "non_text"
def normalize_notice(notice: object) -> dict | None:
def normalize_notice(
notice: object, gateway_received_at: str | None = None
) -> dict | None:
if not isinstance(notice, dict):
raise DouyinError("notification detail is invalid")
if notice.get("comment"):
@@ -1248,6 +1301,8 @@ def normalize_notice(notice: object) -> dict | None:
).isoformat()
except (OverflowError, OSError, ValueError) as exc:
raise DouyinError("notification timestamp is invalid") from exc
if isinstance(gateway_received_at, str) and gateway_received_at:
result["gateway_received_at"] = gateway_received_at
return result
users = detail.get("from_user") or []
if isinstance(users, dict):
@@ -1315,6 +1370,8 @@ def normalize_notice(notice: object) -> dict | None:
).isoformat()
except (OverflowError, OSError, ValueError) as exc:
raise DouyinError("notification timestamp is invalid") from exc
if isinstance(gateway_received_at, str) and gateway_received_at:
result["gateway_received_at"] = gateway_received_at
return result
@@ -1709,6 +1766,6 @@ FOLLOW_SCRIPT = r"""(async()=>{const p=PARAMS_VALUE;let sent=false;try{if(locati
ACTION_SCRIPT = r"""(async()=>{const p=PARAMS_VALUE;let sent=false;const fail=(code,definitive=false)=>{const e=Error(code);e.definitive=definitive;throw e;};try{if(location.origin!=="https://www.douyin.com")fail("WRONG_ORIGIN");const qs="device_platform=webapp&aid=6383&channel=channel_pc_web";const get=async path=>{const r=await fetch(path,{credentials:"include",redirect:"error",signal:AbortSignal.timeout(15000)});let v;try{v=await r.json();}catch(_){fail("INVALID_RESPONSE");}if(!r.ok||v.status_code!==0)fail("READ_FAILED");return v;};const post=async(path,data)=>{const r=await fetch(path,{method:"POST",credentials:"include",redirect:"error",headers:{"Content-Type":"application/x-www-form-urlencoded;charset=UTF-8"},body:new URLSearchParams(data),signal:AbortSignal.timeout(10000)});let v;try{v=await r.json();}catch(_){fail(r.ok?"INVALID_RESPONSE":"POST_UNCERTAIN");}if(!r.ok)fail("POST_UNCERTAIN");if(v.status_code===undefined)fail("POST_UNCONFIRMED");if(v.status_code!==0)fail("BUSINESS_REJECTED",true);return v;};const self=await get("/aweme/v1/web/user/profile/self/?"+qs);if(String(self.user?.uid)!==p.expected)fail("IDENTITY_MISMATCH");if((p.action==="follow"||p.action==="dm")&&p.target===p.expected)fail("SELF_TARGET");const detail=async()=>get("/aweme/v1/web/aweme/detail/?"+qs+"&aweme_id="+encodeURIComponent(p.work));const findComment=async()=>{let cursor=0;for(let page=0;page<100;page++){const response=await get("/aweme/v1/web/comment/list/?"+qs+"&aweme_id="+encodeURIComponent(p.work)+"&cursor="+cursor+"&count=50");if(!Array.isArray(response.comments)||typeof response.has_more!=="boolean")fail("READ_FAILED");const list=response.comments;const comment=list.find(c=>String(c?.cid||c?.comment_id||"")===p.comment);if(comment){if(String(comment.aweme_id||comment.item_id||p.work)!==p.work||String(comment.user?.uid||comment.user_id||"")!==p.target)fail("TARGET_MISMATCH");return comment;}if(!response.has_more)break;const next=Number(response.cursor);if(!Number.isSafeInteger(next)||next<=cursor)fail("PAGINATION_INVALID");cursor=next;}fail("TARGET_NOT_FOUND");};if(p.action==="like_work"){const before=(await detail()).aweme_detail;if(String(before?.aweme_id)!==p.work)fail("TARGET_MISMATCH");if(Number(before.user_digged)===1)return {status:"succeeded",action:"already_liked",evidence:{work_id:p.work,user_digged:"1"}};sent=true;const result=await post("/aweme/v1/web/commit/item/digg/?"+qs,{aweme_id:p.work,type:"1",item_type:"0"});if(Number(result.is_digg)!==1)fail("POST_UNCONFIRMED");const after=(await detail()).aweme_detail;return {status:Number(after?.user_digged)===1?"succeeded":"unknown",action:"liked",evidence:{work_id:p.work,user_digged:String(after?.user_digged??"")}};}if(p.action==="like_comment"){const comment=await findComment();if(Number(comment.user_digged)===1)return {status:"succeeded",action:"already_liked",evidence:"comment.user_digged"};sent=true;await post("/aweme/v1/web/comment/digg?"+qs,{cid:p.comment,aweme_id:p.work,digg_type:"1",channel_id:"0",app_name:"aweme",item_type:"0",level:"1"});const after=await findComment();return {status:Number(after.user_digged)===1?"succeeded":"unknown",action:"liked_comment",evidence:{comment_id:p.comment,work_id:p.work,user_digged:String(after.user_digged??"")}};}if(p.action==="reply_comment"){await findComment();sent=true;const result=await post("/aweme/v1/web/comment/publish?"+qs,{app_name:"aweme",enter_from:"pc_web",previous_page:"video",reply_id:p.comment,reply_to_reply_id:"0",aweme_id:p.work,text:p.text,text_extra:"[]",comment_send_celltime:"0",comment_video_celltime:"0"});const posted=result.comment||result.comment_info||result.data?.comment;const postedID=String(posted?.cid||posted?.comment_id||"");const postedWork=String(posted?.aweme_id||posted?.item_id||"");const postedAuthor=String(posted?.user?.uid||posted?.user_id||"");const postedText=String(posted?.text??posted?.content??"");if(!posted||!postedID||postedWork!==p.work||postedAuthor!==p.expected||postedText!==p.text)fail("UNCONFIRMED");return {status:"succeeded",action:"replied",evidence:{comment_id:postedID,work_id:postedWork,author_uid:postedAuthor,text:postedText}};}if(p.action==="repost"){if(!p.text)fail("TEXT_REQUIRED");fail("REPOST_TEXT_UNSUPPORTED");}fail("ACTION_NOT_IMPLEMENTED");}catch(e){const code=String(e.message||"REQUEST_FAILED");return {status:sent?(e.definitive?"failed":"unknown"):"failed",code};}})()"""
MESSAGE_HISTORY_SCRIPT = r"""(async()=>{const p=PARAMS_VALUE;const fail=code=>{throw Error(code);};try{if(location.origin!=="https://www.douyin.com")fail("WRONG_ORIGIN");const response=await fetch("/aweme/v1/web/user/profile/self/?device_platform=webapp&aid=6383",{credentials:"include",signal:AbortSignal.timeout(15000)});if(!response.ok)fail("LOGIN_CHECK_FAILED");const profile=await response.json();if(profile.status_code!==0||String(profile.user?.uid)!==EXPECTED_UID_VALUE)fail("IDENTITY_MISMATCH");if(String(profile.user.uid)===p.uid)fail("SELF_TARGET");let service;for(const key of Object.keys(window).filter(k=>k.startsWith("@pc-im/im:"))){const chunks=window[key];if(!Array.isArray(chunks))continue;let req;chunks.push([["creatorhub-history-"+Date.now()],{},r=>{req=r;}]);chunks.pop();for(const [id,module] of Object.entries(req?.c||{})){if(!String(req.m?.[id]||"").includes("getOrCreatePrivateConversationByUid"))continue;for(const exported of Object.values(module.exports||{}))if(exported?.instance?.imSdkService)service=exported.instance.imSdkService;}if(service)break;}if(!service)fail("IM_SDK_NOT_READY");const sdk=service.imSdkManager.getImSdkInstance();if(!sdk)fail("IM_SDK_NOT_READY");const conversation=sdk.getConversationList().find(c=>c.type===1&&String(c.toParticipantUserId)===p.uid);if(!conversation)fail("CONVERSATION_NOT_FOUND");const meta=c=>({id:String(c.id),short_id:String(c.shortId),uid:String(c.toParticipantUserId),type:c.type});let cursor,previousCursor="",hasMore=false;for(let page=0;page<20;page++){const request={conversation,limit:Math.min(50,p.limit)};if(cursor!==undefined)request.cursor=cursor;const result=await sdk.getMessagesByConversation(request);if(!result||!Array.isArray(result.messages))fail("MESSAGE_HISTORY_INVALID");hasMore=Boolean(result.hasMore);if(!hasMore)break;const next=result.cursor,key=String(next?.toString?.()??next??"");if(!key||key===previousCursor)fail("MESSAGE_HISTORY_CURSOR_INVALID");previousCursor=key;cursor=next;}const messages=conversation.getMessageList();if(!Array.isArray(messages))fail("MESSAGE_HISTORY_INVALID");const pack=m=>{const ext=m.ext||{};const rawTime=ext["s:server_message_create_time"]||"";return {server_id:String(m.serverId||""),sender_uid:String(m.sender||""),message_type:String(m.type??""),content:typeof m.content==="string"?m.content.slice(0,100000):m.content,created_at:/^[0-9]+$/.test(String(rawTime))?String(rawTime):null,server_status:m.serverStatus??null};};return {status:"succeeded",action:"history",history_source:"im_sdk_pull",history_has_more:hasMore,account_uid:String(profile.user.uid),conversation:meta(conversation),messages:messages.slice(-p.limit).map(pack)};}catch(e){const known=["WRONG_ORIGIN","LOGIN_CHECK_FAILED","IDENTITY_MISMATCH","SELF_TARGET","IM_SDK_NOT_READY","CONVERSATION_NOT_FOUND","MESSAGE_HISTORY_INVALID","MESSAGE_HISTORY_CURSOR_INVALID"];return {status:"failed",code:known.includes(e.message)?e.message:"SDK_REQUEST_FAILED"};}})()"""
MESSAGE_HISTORY_SCRIPT = r"""(async()=>{const p=PARAMS_VALUE;const fail=code=>{throw Error(code);};try{if(location.origin!=="https://www.douyin.com")fail("WRONG_ORIGIN");const response=await fetch("/aweme/v1/web/user/profile/self/?device_platform=webapp&aid=6383",{credentials:"include",signal:AbortSignal.timeout(15000)});if(!response.ok)fail("LOGIN_CHECK_FAILED");const profile=await response.json();if(profile.status_code!==0||String(profile.user?.uid)!==EXPECTED_UID_VALUE)fail("IDENTITY_MISMATCH");if(String(profile.user.uid)===p.uid)fail("SELF_TARGET");let service;for(const key of Object.keys(window).filter(k=>k.startsWith("@pc-im/im:"))){const chunks=window[key];if(!Array.isArray(chunks))continue;let req;chunks.push([["creatorhub-history-"+Date.now()],{},r=>{req=r;}]);chunks.pop();for(const [id,module] of Object.entries(req?.c||{})){if(!String(req.m?.[id]||"").includes("getOrCreatePrivateConversationByUid"))continue;for(const exported of Object.values(module.exports||{}))if(exported?.instance?.imSdkService)service=exported.instance.imSdkService;}if(service)break;}if(!service)fail("IM_SDK_NOT_READY");const sdk=service.imSdkManager.getImSdkInstance();if(!sdk)fail("IM_SDK_NOT_READY");const conversation=sdk.getConversationList().find(c=>c.type===1&&String(c.toParticipantUserId)===p.uid);if(!conversation)fail("CONVERSATION_NOT_FOUND");const meta=c=>({id:String(c.id),short_id:String(c.shortId),uid:String(c.toParticipantUserId),type:c.type});let cursor=p.cursor?String(p.cursor):undefined,previousCursor=cursor??"",hasMore=false;for(let page=0;page<20;page++){const request={conversation,limit:Math.min(50,p.limit)};if(cursor!==undefined)request.cursor=cursor;const result=await sdk.getMessagesByConversation(request);if(!result||!Array.isArray(result.messages))fail("MESSAGE_HISTORY_INVALID");hasMore=Boolean(result.hasMore);if(!hasMore)break;const next=result.cursor,key=String(next?.toString?.()??next??"");if(!key||key===previousCursor)fail("MESSAGE_HISTORY_CURSOR_INVALID");previousCursor=key;cursor=next;}const messages=conversation.getMessageList();if(!Array.isArray(messages))fail("MESSAGE_HISTORY_INVALID");const pack=m=>{const ext=m.ext||{};const rawTime=ext["s:server_message_create_time"]||"";return {server_id:String(m.serverId||""),sender_uid:String(m.sender||""),message_type:String(m.type??""),content:typeof m.content==="string"?m.content.slice(0,100000):m.content,created_at:/^[0-9]+$/.test(String(rawTime))?String(rawTime):null,server_status:m.serverStatus??null};};return {status:"succeeded",action:"history",history_source:"im_sdk_pull",history_cursor:hasMore?String(cursor??""):"",history_has_more:hasMore,account_uid:String(profile.user.uid),conversation:meta(conversation),messages:messages.slice(-p.limit).map(pack)};}catch(e){const known=["WRONG_ORIGIN","LOGIN_CHECK_FAILED","IDENTITY_MISMATCH","SELF_TARGET","IM_SDK_NOT_READY","CONVERSATION_NOT_FOUND","MESSAGE_HISTORY_INVALID","MESSAGE_HISTORY_CURSOR_INVALID"];return {status:"failed",code:known.includes(e.message)?e.message:"SDK_REQUEST_FAILED"};}})()"""
IM_SCRIPT = r"""(async()=>{const p=PARAMS_VALUE;let sent=false;const fail=(code,definitive=false)=>{const e=Error(code);e.definitive=definitive;throw e;};try{if(location.origin!=="https://www.douyin.com")fail("WRONG_ORIGIN");const response=await fetch("/aweme/v1/web/user/profile/self/?device_platform=webapp&aid=6383",{credentials:"include",signal:AbortSignal.timeout(15000)});if(!response.ok)fail("LOGIN_CHECK_FAILED");const profile=await response.json();if(profile.status_code!==0||String(profile.user?.uid)!==EXPECTED_UID_VALUE)fail("IDENTITY_MISMATCH");if(String(profile.user.uid)===p.uid)fail("SELF_TARGET");let service;for(const key of Object.keys(window).filter(k=>k.startsWith("@pc-im/im:"))){const chunks=window[key];if(!Array.isArray(chunks))continue;let req;chunks.push([["creatorhub-im-"+Date.now()],{},r=>{req=r;}]);for(const [id,module] of Object.entries(req?.c||{})){if(!String(req.m[id]).includes("getOrCreatePrivateConversationByUid"))continue;for(const exported of Object.values(module.exports||{}))if(exported?.instance?.imSdkService)service=exported.instance.imSdkService;}}if(!service)fail("IM_SDK_NOT_READY");const sdk=service.imSdkManager.getImSdkInstance();if(!sdk)fail("IM_SDK_NOT_READY");const meta=c=>({id:String(c.id),short_id:String(c.shortId),uid:String(c.toParticipantUserId),type:c.type});const pack=m=>({server_id:String(m.serverId||""),client_id:m.clientId||null,sender:String(m.sender),type:m.type,content:m.content,created_at:m.createdAt,server_status:m.serverStatus});let conversation=sdk.getConversationList().find(c=>c.type===1&&String(c.toParticipantUserId)===p.uid);if(!conversation&&p.action==="send")conversation=await service.conversationManager.getOrCreatePrivateConversationByUid(p.uid);if(!conversation)fail("CONVERSATION_NOT_FOUND");if(conversation.type!==1||String(conversation.toParticipantUserId)!==p.uid)fail("TARGET_MISMATCH");if(p.action!=="send"||!p.confirm)return {status:"preview",action:"preview",sender_uid:String(profile.user.uid),uid:p.uid,text:p.text,conversation:meta(conversation)};const message=await sdk.createMessage({conversation,type:7,content:JSON.stringify({aweType:700,type:0,richTextInfos:[],text:p.text})});if(!message||typeof message.sendFunc!=="function")fail("MESSAGE_BUILD_FAILED");sent=true;const result=await Promise.race([sdk.sendMessage({message}),new Promise((_,reject)=>setTimeout(()=>reject(Error("MESSAGE_UNCONFIRMED")),10000))]);if(result?.success===false)fail("MESSAGE_REJECTED",true);if(result?.success!==true)fail("MESSAGE_UNCONFIRMED");const packed=pack(message);if(!packed.server_id&&!packed.client_id)fail("MESSAGE_UNCONFIRMED");return {status:"succeeded",action:"send",success:true,status_code:result?.statusCode??null,check_code:String(result?.checkCode??""),conversation:meta(conversation),message:packed,evidence:{conversation_id:String(conversation.id),message_server_id:packed.server_id,message_client_id:String(packed.client_id??"")}};}catch(e){const known=["WRONG_ORIGIN","LOGIN_CHECK_FAILED","IDENTITY_MISMATCH","SELF_TARGET","IM_SDK_NOT_READY","CONVERSATION_NOT_FOUND","TARGET_MISMATCH","MESSAGE_BUILD_FAILED","MESSAGE_REJECTED","MESSAGE_UNCONFIRMED"];return {status:sent?(e.definitive?"failed":"unknown"):"failed",code:known.includes(e.message)?e.message:"SDK_REQUEST_FAILED"};}})()"""
+45 -13
View File
@@ -834,10 +834,13 @@ class Gateway:
expected_uid = input.get("expected_uid", "")
target_uid = input.get("target_uid", "")
limit = input.get("limit", 100)
cursor = input.get("cursor", "")
if (
not valid_douyin_generation(input)
or not isinstance(expected_uid, str)
or not isinstance(target_uid, str)
or not isinstance(cursor, str)
or len(cursor) > 500
or type(limit) is not int
or not UID_RE.fullmatch(expected_uid)
or not UID_RE.fullmatch(target_uid)
@@ -848,9 +851,14 @@ class Gateway:
with self._alias_lock(alias):
self._require_douyin_generation(alias, input)
try:
result = self.browser.message_history(
alias, expected_uid, target_uid, limit
)
if cursor:
result = self.browser.message_history(
alias, expected_uid, target_uid, limit, cursor=cursor
)
else:
result = self.browser.message_history(
alias, expected_uid, target_uid, limit
)
self._require_douyin_generation(alias, input)
except DouyinError as exc:
LOG.warning(
@@ -869,6 +877,7 @@ class Gateway:
target_work_id = input.get("target_work_id", "")
text = input.get("text", "")
confirm = input.get("confirm", False)
operation_id = input.get("operation_id", "")
if (
not valid_douyin_generation(input)
or not isinstance(expected_uid, str)
@@ -877,6 +886,8 @@ class Gateway:
or not isinstance(target_comment_id, str)
or not isinstance(target_work_id, str)
or not isinstance(text, str)
or not isinstance(operation_id, str)
or len(operation_id) > 200
or type(confirm) is not bool
or not UID_RE.fullmatch(expected_uid)
or action not in ACTIONS
@@ -886,7 +897,11 @@ class Gateway:
raise RequestError("ACTION_UNAVAILABLE", 409)
with self._alias_lock(alias):
self._require_douyin_generation(alias, input)
self._claim_action(alias)
self._claim_action(
alias,
str(input.get("runtime_id", "")),
operation_id,
)
try:
result = self.browser.action(
alias,
@@ -900,22 +915,22 @@ class Gateway:
)
self._require_douyin_generation(alias, input)
except DouyinError as exc:
self._handle_douyin_action_error(alias, action, exc)
self._handle_douyin_action_error(alias, action, exc, operation_id)
raise RequestError("Douyin action failed") from exc
except Exception:
self._release_action_ownership(alias)
self._release_action_ownership(alias, operation_id)
raise
else:
self._release_action_ownership(alias)
self._release_action_ownership(alias, operation_id)
return result
def _handle_douyin_action_error(
self, alias: str, action: str, error: DouyinError
self, alias: str, action: str, error: DouyinError, operation_id: str = ""
) -> None:
if getattr(error, "uncertain", False) or "timed out" in str(error).lower():
self._retain_action_ownership(alias)
self._retain_action_ownership(alias, operation_id)
else:
self._release_action_ownership(alias)
self._release_action_ownership(alias, operation_id)
LOG.warning(
"Douyin action failed alias=%s action=%s reason=%s",
alias,
@@ -923,20 +938,37 @@ class Gateway:
str(error),
)
def _claim_action(self, alias: str) -> None:
def _claim_action(
self, alias: str, runtime_id: str = "", operation_id: str = ""
) -> None:
now = time.monotonic()
with self._action_ownership_lock:
until = self._uncertain_actions.get(alias, 0.0)
if until > now:
raise RequestError("previous Douyin action outcome is uncertain", 409)
if runtime_id:
marker = self.browser.action_ownership(alias)
if marker is not None:
raise RequestError("previous Douyin action outcome is uncertain", 409)
self.browser.set_action_ownership(
alias,
{
"runtime_id": runtime_id,
"operation_id": operation_id,
"started_at": time.time(),
},
)
with self._action_ownership_lock:
self._uncertain_actions.pop(alias, None)
self._uncertain_actions[alias] = 0.0
def _release_action_ownership(self, alias: str) -> None:
def _release_action_ownership(self, alias: str, operation_id: str = "") -> None:
if operation_id:
self.browser.clear_action_ownership(alias, operation_id)
with self._action_ownership_lock:
self._uncertain_actions.pop(alias, None)
def _retain_action_ownership(self, alias: str) -> None:
def _retain_action_ownership(self, alias: str, operation_id: str = "") -> None:
with self._action_ownership_lock:
# A timed-out page script may still finish its network write. Keep the
# alias blocked until the browser generation is removed or replaced.
+67 -14
View File
@@ -885,6 +885,18 @@ class BrowserTests(unittest.TestCase):
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(
{
@@ -2244,18 +2256,21 @@ class AdditionalGatewayCoverageTests(unittest.TestCase):
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"},
}
),
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"
with (
self.subTest(server_now=server_now),
self.assertRaisesRegex(DouyinError, "platform clock"),
):
browser.identity("safe")
@@ -2273,9 +2288,12 @@ class AdditionalGatewayCoverageTests(unittest.TestCase):
("123", "456", 0),
("123", "456", 201),
):
with self.subTest(
expected_uid=expected_uid, target_uid=target_uid, limit=limit
), self.assertRaises(DouyinError):
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"):
@@ -2432,5 +2450,40 @@ class AdditionalGatewayCoverageTests(unittest.TestCase):
load_config({**base, "BROWSER_CDP_ALIAS": "bad alias"})
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()