Files
creator-hub/browser_gateway/platform/notice_signal.py
T
rogee 360bf96bab
douyin-release-gate / verify (push) Failing after 18m0s
feat: push Douyin events through shared WebSocket and SSE
2026-10-07 23:45:32 +08:00

117 lines
7.5 KiB
Python

"""Read-only bridge to the notification socket owned by the Douyin page."""
import json
import logging
import uuid
from datetime import datetime, timezone
LOG = logging.getLogger('browser-gateway')
from .douyin import DouyinError
INSTALL = r"""(async()=>{
const uid=UID,key=KEY,alias=ALIAS;
if(location.origin!=='https://www.douyin.com')throw Error('notification origin changed');
const chunks=window.webpackChunkdouyin_web;if(!chunks)throw Error('notification runtime missing');
let req;chunks.push([['notice-'+key],{},r=>{req=r}]);chunks.pop();
const entries=Object.entries(req.m);
const entry=entries.find(([,f])=>String(f).includes('NOTICE_PUSH_EVENT_NAMES:function'));
const codec=entries.find(([,f])=>String(f).includes('.decodedFrame=')&&String(f).includes('.encodeFrame='));
if(!entry||!codec)throw Error('notification SDK changed');
const C=req(entry[0]).NoticeFrontier,decode=req(codec[0]).decodedFrame,f=C.frontierInstance;
if(!f||String(f._options.deviceID)!==uid)throw Error('notification identity changed');
const registry=window.__creator_hub_notice_sessions||(window.__creator_hub_notice_sessions=Object.create(null));
for(const [oldKey,old] of Object.entries(registry)){if(old.alias===alias){old.dispose();delete window[oldKey];delete registry[oldKey]}}
const s={queue:[],error:null,wake:null,C,f,uid,alias};
const emit=e=>{if(s.queue.length>=1000)s.error='notification signal queue full';else s.queue.push(e);if(s.wake)s.wake()};
const message=e=>{try{const v=decode(new Uint8Array(e.data));if(v.service===20313||v.service===20003)emit({kind:'push',signal_at:new Date().toISOString(),service:v.service,payload:new TextDecoder().decode(v.payload)})}catch(e){s.error='notification frame decode failed: '+String(e.message||e);if(s.wake)s.wake()}};
const close=()=>emit({kind:'close'});
f.addEventListener('message',message);f.addEventListener('close',close);
s.dispose=()=>{f.removeEventListener('message',message);f.removeEventListener('close',close);if(s.wake)s.wake()};window[key]=s;registry[key]=s;
if(f.readyState!==f.OPEN)await new Promise(resolve=>{const done=()=>{clearTimeout(timer);f.removeEventListener('open',done);resolve()};const timer=setTimeout(done,5000);f.addEventListener('open',done)});
if(f.readyState!==f.OPEN||C.frontierInstance!==f){s.dispose();delete window[key];delete registry[key];throw Error('notification socket not connected')};
return JSON.stringify({connected:true});})()"""
WAIT = r"""(async()=>{const s=window[KEY];
if(!s||s.C.frontierInstance!==s.f||String(s.f._options.deviceID)!==s.uid)throw Error('notification identity or page changed');
if(!s.queue.length&&!s.error)await new Promise(resolve=>{const done=()=>{clearTimeout(timer);s.wake=null;resolve()};const timer=setTimeout(done,2000);s.wake=done});
if(s.error)throw Error(s.error);if(s.f.readyState!==s.f.OPEN)throw Error('notification socket disconnected');
return JSON.stringify(s.queue.splice(0));})()"""
def notice_ids(event):
payload = json.loads(event['payload'])
if not isinstance(payload, dict):
raise DouyinError('notification push shape invalid')
if event['service'] == 20313:
notices = payload.get('notices', [])
if not isinstance(notices, list) or any(not isinstance(n, dict) or not isinstance(n.get('effect_groups', []), list) for n in notices):
raise DouyinError('notification push list invalid')
items = [n for n in notices if {str(g) for g in n.get('effect_groups', [])} & {'700','960','961'}]
elif event['service'] == 20003 and payload.get('notice_type') in (45,31,9009,9002,514,9067):
items = [payload]
else:
return []
ids = [n.get('notice_id_str') for n in items]
if any(not isinstance(n, str) or not n.isascii() or not n.isdecimal() for n in ids):
raise DouyinError('notification push ID invalid')
return list(dict.fromkeys(ids))
def detail_script(ids):
return r"""(async ids=>{
const chunks=window.webpackChunkdouyin_web;let req;chunks.push([['notice-detail-'+Date.now()],{},r=>{req=r}]);chunks.pop();
const entry=Object.entries(req.m).find(([,f])=>String(f).includes('getNoticeDetail:function'));
const sdk=entry&&req(entry[0]),client=window.axiosInstance;
if(!sdk||!client?.interceptors?.response)throw Error('notification detail SDK unavailable');
const params={notice_ids:JSON.stringify(ids),is_mark_read:0};let raw,timer;
const observer=client.interceptors.response.use(r=>{const p=r.config?.params||{},u=new URL(r.config?.url||'',location.origin),xhr=r.request;
if(u.origin===location.origin&&u.pathname==='/aweme/v1/web/notice/detail/'&&p.notice_ids===params.notice_ids&&Number(p.is_mark_read)===0&&xhr&&(!xhr.responseType||xhr.responseType==='text')&&typeof xhr.responseText==='string')raw={status:r.status,body:xhr.responseText};return r});
try{await Promise.race([sdk.getNoticeDetail(params),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('notification detail timeout')),10000)})])}
finally{clearTimeout(timer);client.interceptors.response.eject(observer)}
if(!raw)throw Error('notification raw detail missing');return JSON.stringify(raw);})(IDS)""".replace('IDS', json.dumps(ids))
class NoticeSignal:
def __init__(self, browser, alias, uid):
self.browser, self.alias, self.uid = browser, alias, uid
self.key = '__creator_notice_' + uuid.uuid4().hex
self.connection = browser._connect(alias)
try:
self.evaluate(INSTALL.replace('UID', json.dumps(uid)).replace('KEY', json.dumps(self.key)).replace('ALIAS', json.dumps(alias)))
except Exception:
self.connection.close()
raise
def evaluate(self, expression):
raw = self.connection.evaluate(expression)
if not isinstance(raw, str):
raise DouyinError('notification bridge must return raw JSON')
return json.loads(raw)
def receive(self):
events = self.evaluate(WAIT.replace('KEY', json.dumps(self.key)))
if not isinstance(events, list):
raise DouyinError('notification bridge result invalid')
ids = list(dict.fromkeys(n for event in events if event.get('kind') == 'push' for n in notice_ids(event)))
if any(event.get('kind') == 'close' for event in events):
raise DouyinError('notification socket disconnected')
if not ids:
return []
self.received_at = datetime.now(timezone.utc).isoformat()
LOG.info('notification signal received alias=%s uid=%s signal_at=%s gateway_received_at=%s notice_ids=%s',
self.alias, self.uid, next((e.get('signal_at') for e in events if e.get('kind') == 'push'), None), self.received_at, ids)
response = self.evaluate(detail_script(ids))
body = json.loads(response['body'])
raws = body.get('notice_list_v2')
if response['status'] != 200 or body.get('status_code') != 0 or not isinstance(raws, list):
raise DouyinError('notification detail request failed: HTTP %s, platform status %s' % (response['status'], body.get('status_code')))
found = {str(n.get('nid_str') or n.get('nid')) for n in raws if isinstance(n, dict)}
if found != set(ids):
raise DouyinError('notification pushed detail incomplete: ' + ','.join(ids))
return raws
def close(self):
# Dispose page callbacks before closing; cleanup errors are observable.
try:
self.evaluate('(async()=>{const s=window['+json.dumps(self.key)+'];if(s){s.dispose();delete window['+json.dumps(self.key)+'];if(window.__creator_hub_notice_sessions)delete window.__creator_hub_notice_sessions['+json.dumps(self.key)+']}return "null"})()')
finally:
self.connection.close()