verify-library-cleanup-browser.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
  1. """Real production browser checks, without modifying responses or page code."""
  2. import base64
  3. from datetime import datetime,timezone
  4. import json
  5. from pathlib import Path
  6. import subprocess
  7. import tempfile
  8. import time
  9. from urllib.parse import quote
  10. from urllib.request import urlopen
  11. import websocket
  12. ROOT=Path(__file__).resolve().parents[1]
  13. WORK=ROOT/'work/library-cleanup-20261111'
  14. final_dir=WORK/'round-02' if (WORK/'round-02/apply-result.json').exists() else WORK
  15. applied=json.loads((final_dir/'apply-result.json').read_bytes())['body']['result']
  16. OUT=WORK/('browser-final' if final_dir!=WORK else 'browser')
  17. OUT.mkdir(parents=True,exist_ok=True)
  18. profile=Path(tempfile.mkdtemp(prefix='cleanup-browser-',dir=ROOT/'work'))
  19. browser=subprocess.Popen(['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe','--headless=new','--disable-gpu',
  20. '--disable-background-networking','--disable-component-update','--disable-extensions','--no-first-run',
  21. '--remote-debugging-port=0','--user-data-dir='+str(profile),'about:blank'],
  22. stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,creationflags=subprocess.CREATE_NO_WINDOW)
  23. serial=0;ws=None;events=[]
  24. report={'ok':False,'checkedAt':datetime.now(timezone.utc).isoformat(),'scenes':[],'responseOverrides':0}
  25. try:
  26. end=time.monotonic()+30
  27. while not (profile/'DevToolsActivePort').exists():
  28. assert time.monotonic()<end and browser.poll() is None
  29. time.sleep(.1)
  30. port=(profile/'DevToolsActivePort').read_text().splitlines()[0]
  31. page=next(p for p in json.load(urlopen('http://127.0.0.1:'+port+'/json/list')) if p['type']=='page')
  32. ws=websocket.create_connection(page['webSocketDebuggerUrl'],timeout=150,suppress_origin=True,http_no_proxy=['127.0.0.1'])
  33. def call(method,params=None):
  34. global serial
  35. serial+=1;ws.send(json.dumps({'id':serial,'method':method,'params':params or {}}))
  36. while True:
  37. d=json.loads(ws.recv())
  38. if d.get('id')==serial:
  39. assert 'error' not in d,d
  40. return d.get('result',{})
  41. events.append(d)
  42. def ev(expression):
  43. r=call('Runtime.evaluate',{'expression':expression,'returnByValue':True,'awaitPromise':True})
  44. assert 'exceptionDetails' not in r,r
  45. return r['result'].get('value')
  46. view='document.querySelector("kf-app")?.renderRoot?.querySelector("view-library")'
  47. call('Page.enable');call('Runtime.enable');call('Network.enable')
  48. plan=json.loads((WORK/'sealed-plan.json').read_bytes())['plan']
  49. removed_final={r['id'] for r in json.loads((final_dir/'sealed-plan.json').read_bytes())['plan']['removals']}
  50. repaired=next(r for r in plan['repairs'] if r['id'] not in removed_final)
  51. scenarios=[('products','/library?type=products&quality=audit',1440,900),
  52. ('social-mobile','/library?type=social&quality=audit',390,844),
  53. ('removed-social','/library/social/bilibili%3ABV11ENX6BE6p?type=social&quality=audit',1440,900),
  54. ('removed-product','/library/products/jd%3A100000283767?type=products&quality=audit',1440,900),
  55. ('corrected-body','/library/social/'+quote(repaired['id'],safe='')+'?type=social&quality=audit',1440,900),
  56. ('retained-article','/library/social/smzdm%3Aag5rd99w?type=social&quality=audit',390,844)]
  57. for name,path,width,height in scenarios:
  58. call('Emulation.setDeviceMetricsOverride',{'width':width,'height':height,'deviceScaleFactor':1,'mobile':False})
  59. call('Page.navigate',{'url':'https://kangshifu.fmode.cn'+path})
  60. ev('new Promise((resolve,reject)=>{const end=Date.now()+100000;function tick(){const p='+view+';if(p&&!p._loading&&(p._error||p._data||p._detail)){p.updateComplete.then(()=>resolve(true));return}if(Date.now()>end)reject(Error("render timeout"));else setTimeout(tick,150)}tick()})')
  61. state=ev('(()=>{const p='+view+';return {error:p._error,generation:p._summary?.generation,counts:p._summary?.counts,total:p._data?.totalMatched,detailId:p._detail?.record?.id,fulltext:p._detail?.record?.fulltext_available,content:p._detail?.record?.content,renderedText:p.renderRoot.textContent,innerWidth,clientWidth:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth}})()')
  62. assert state['generation']==applied['generation'],state
  63. assert state['innerWidth']==width and state['scrollWidth']<=state['clientWidth'],state
  64. if name in ('removed-social','removed-product'):
  65. assert state['error']=='未找到这条资料',state
  66. assert 'detailId' not in state,state
  67. else:
  68. assert not state['error'],state
  69. if name=='products':assert state['total']==applied['plannedCounts']['products'],state
  70. if name=='social-mobile':assert state['total']==applied['plannedCounts']['social_posts'],state
  71. if name=='corrected-body':assert state['detailId']==repaired['id'] and state['fulltext']==0 and not state.get('content'),state
  72. if name=='retained-article':
  73. expected=next(r['content'] for r in json.loads((ROOT/'work/database-enrichment-20261110/batches/five-topics-public-03/expected-records.json').read_bytes()) if r['id']=='smzdm:ag5rd99w')
  74. assert state['detailId']=='smzdm:ag5rd99w' and state['content']==expected,state
  75. (OUT/(name+'.png')).write_bytes(base64.b64decode(call('Page.captureScreenshot',{'format':'png','captureBeyondViewport':False})['data']))
  76. state.pop('content',None);state['renderedText']=state['renderedText'][-1200:]
  77. report['scenes'].append({'name':name,'path':path,'width':width,'height':height,**state})
  78. (OUT/'report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf8')
  79. print(json.dumps({'scene':name,'passed':True},ensure_ascii=False),flush=True)
  80. report['uncaughtExceptions']=[e['params'].get('exceptionDetails',{}) for e in events if e['method']=='Runtime.exceptionThrown']
  81. assert not report['uncaughtExceptions'],report['uncaughtExceptions']
  82. report['ok']=True
  83. finally:
  84. (OUT/'report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf8')
  85. if ws:ws.close()
  86. browser.terminate()
  87. try:browser.wait(timeout=10)
  88. except subprocess.TimeoutExpired:browser.kill()
  89. print(json.dumps({'ok':report['ok'],'scenes':len(report['scenes'])},ensure_ascii=False))