117 lines
7.5 KiB
Python
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()
|