verify-review-display-hotfix.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. """Verify the staged module or its published version against real public data."""
  2. import argparse
  3. import base64
  4. import hashlib
  5. import json
  6. from pathlib import Path
  7. import sqlite3
  8. import subprocess
  9. import tempfile
  10. import time
  11. from urllib.parse import quote
  12. from urllib.request import Request, urlopen
  13. import websocket
  14. ROOT = Path(__file__).resolve().parents[1]
  15. WORK = ROOT / 'work/review-display-hotfix-20261106-r1'
  16. payload = (WORK / 'package/payload/src/pages/library-product-detail.js').read_bytes()
  17. parser = argparse.ArgumentParser(description=__doc__)
  18. parser.add_argument('--production', action='store_true', help='Read the deployed module without any browser override')
  19. args = parser.parse_args()
  20. production_files = []
  21. if args.production:
  22. for relative, expected in [('src/pages/library-product-detail.js', hashlib.sha256(payload).hexdigest()),
  23. ('index.html', 'dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3')]:
  24. with urlopen(Request('https://kangshifu.fmode.cn/' + relative, headers={'Cache-Control': 'no-cache'}), timeout=30) as response:
  25. raw = response.read()
  26. actual = hashlib.sha256(raw).hexdigest()
  27. assert actual == expected, 'PUBLIC_FILE_SHA_MISMATCH:' + relative
  28. production_files.append({'path': relative, 'status': response.status, 'bytes': len(raw), 'sha256': actual,
  29. 'contentType': response.headers.get('Content-Type'),
  30. 'cacheControl': response.headers.get('Cache-Control')})
  31. def api(request):
  32. req = Request('https://kangshifu.fmode.cn/api/library', json.dumps(request).encode(), {'Content-Type': 'application/json'})
  33. with urlopen(req, timeout=35) as response:
  34. result = json.load(response)
  35. assert result.get('ok'), result.get('error')
  36. return result
  37. summary = api({'op': 'summary'})
  38. generation = summary['generation']
  39. empty = api({'op': 'browse', 'entity': 'products', 'quality': 'audit', 'sort': 'reviews_asc', 'limit': 1,
  40. 'generation': generation})['records'][0]
  41. assert empty['relatedCounts'].get('reviews', 0) == 0
  42. samples = []
  43. for folder in sorted((ROOT / 'work/product-reviews/round2/intake').glob('batch-*')):
  44. with sqlite3.connect((folder / 'staging.sqlite').resolve().as_uri() + '?mode=ro', uri=True) as connection:
  45. connection.row_factory = sqlite3.Row
  46. pid = connection.execute('SELECT product_id FROM reviews ORDER BY length(content) DESC,id LIMIT 1').fetchone()[0]
  47. rows = [dict(r) for r in connection.execute('SELECT id,content FROM reviews WHERE product_id=?', (pid,))]
  48. samples.append({'batch': folder.name, 'productId': pid, 'reviews': rows})
  49. profile = Path(tempfile.mkdtemp(prefix='staged-module-browser-', dir=WORK))
  50. process = subprocess.Popen(['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', '--headless=new',
  51. '--disable-gpu', '--remote-debugging-port=0', '--disable-background-networking', '--disable-extensions',
  52. '--no-first-run', '--user-data-dir=' + str(profile), 'about:blank'], stdout=subprocess.DEVNULL,
  53. stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
  54. ws = None
  55. serial = 0
  56. events = []
  57. overrides = 0
  58. report = {'ok': False, 'generation': generation, 'payloadSha256': hashlib.sha256(payload).hexdigest(),
  59. 'verificationMode': 'public-site-no-overrides' if args.production else 'browser-only-single-module-override-on-public-site',
  60. 'productionInstalled': args.production, 'productionWrites': 0, 'paidCalls': 0, 'products': [],
  61. 'productionFiles': production_files}
  62. try:
  63. deadline = time.monotonic() + 20
  64. while not (profile / 'DevToolsActivePort').exists():
  65. assert time.monotonic() < deadline and process.poll() is None
  66. time.sleep(.1)
  67. port = (profile / 'DevToolsActivePort').read_text().splitlines()[0]
  68. page = next(p for p in json.load(urlopen('http://127.0.0.1:' + port + '/json/list', timeout=5)) if p['type'] == 'page')
  69. ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=40, suppress_origin=True,
  70. http_no_proxy=['127.0.0.1', 'localhost'])
  71. def send(method, params=None):
  72. global serial
  73. serial += 1
  74. ws.send(json.dumps({'id': serial, 'method': method, 'params': params or {}}))
  75. return serial
  76. def call(method, params=None):
  77. global overrides
  78. rid = send(method, params)
  79. while True:
  80. response = json.loads(ws.recv())
  81. if response.get('method') == 'Fetch.requestPaused':
  82. request = response['params']
  83. assert request['request']['url'].split('?')[0] == 'https://kangshifu.fmode.cn/src/pages/library-product-detail.js'
  84. overrides += 1
  85. send('Fetch.fulfillRequest', {'requestId': request['requestId'], 'responseCode': 200,
  86. 'responseHeaders': [{'name': 'Content-Type', 'value': 'application/javascript; charset=utf-8'},
  87. {'name': 'Cache-Control', 'value': 'no-store'}],
  88. 'body': base64.b64encode(payload).decode()})
  89. elif response.get('id') == rid:
  90. assert 'error' not in response, response
  91. return response.get('result', {})
  92. else:
  93. events.append(response)
  94. def ev(expression):
  95. response = call('Runtime.evaluate', {'expression': expression, 'returnByValue': True, 'awaitPromise': True})
  96. assert 'exceptionDetails' not in response, response
  97. return response['result'].get('value')
  98. def wait(expression):
  99. ev('new Promise((resolve,reject)=>{const end=Date.now()+30000;const tick=()=>{try{if(' + expression +
  100. '){resolve(true);return}}catch{}if(Date.now()>end)reject(new Error("staged module timeout"));'
  101. 'else setTimeout(tick,60)};tick()})')
  102. V = 'document.querySelector("kf-app")?.renderRoot.querySelector("view-library")'
  103. P = V + '?.renderRoot.querySelector("library-product-detail")'
  104. def action(body):
  105. return ev('(()=>{const p=' + P + ';' + body + '})()')
  106. def ready(pid):
  107. wait(P + '?.product?.id===' + json.dumps(pid) + ' && ' + P + '.panel==="reviews" && !' + P + '._loading && ' + P + '._data')
  108. assert ev(V + '._summary.generation') == generation
  109. def go(pid, quality=None):
  110. path = '/library/products/' + quote(pid, safe='') + '?panel=reviews'
  111. if quality:
  112. path += '&relatedQuality=' + quality
  113. call('Page.navigate', {'url': 'https://kangshifu.fmode.cn' + path})
  114. ready(pid)
  115. def choose(quality, expected):
  116. action('const s=p.renderRoot.querySelector("select[aria-label=商品评价范围]");s.value=' + json.dumps(quality) +
  117. ';s.dispatchEvent(new Event("change",{bubbles:true}));')
  118. wait(P + '.query.relatedQuality===' + json.dumps(quality) + ' && !' + P + '._loading && ' + P + '._data?.totalMatched===' + str(expected))
  119. call('Page.enable')
  120. call('Runtime.enable')
  121. call('Network.enable')
  122. call('Network.setCacheDisabled', {'cacheDisabled': True})
  123. if not args.production:
  124. call('Fetch.enable', {'patterns': [{'urlPattern': 'https://kangshifu.fmode.cn/src/pages/library-product-detail.js*',
  125. 'resourceType': 'Script', 'requestStage': 'Request'}]})
  126. call('Emulation.setDeviceMetricsOverride', {'width': 1440, 'height': 1100, 'deviceScaleFactor': 1, 'mobile': False})
  127. for index, sample in enumerate(samples):
  128. pid = sample['productId']
  129. go(pid)
  130. assert action('return p.reviewQuality;') == 'audit'
  131. assert action('return p._data.totalMatched;') == len(sample['reviews'])
  132. assert action('return p.renderRoot.querySelector("select[aria-label=商品评价范围]").value;') == 'audit'
  133. assert action('return p.renderRoot.textContent.includes("尚未计入分析概况");')
  134. actual = action('return Array.from(p.renderRoot.querySelectorAll("article.review")).map(e=>({'
  135. 'id:e.dataset.childId,content:e.querySelector("p.full-review").textContent}));')
  136. assert {r['id']: r['content'] for r in actual} == {r['id']: r['content'] for r in sample['reviews']}
  137. if index == 0:
  138. call('Page.reload', {'ignoreCache': True})
  139. ready(pid)
  140. assert action('return p._data.totalMatched;') == len(actual)
  141. choose('analysis', 0)
  142. call('Page.reload', {'ignoreCache': True})
  143. ready(pid)
  144. assert action('return p.reviewQuality;') == 'analysis'
  145. assert action('return p._data.totalMatched;') == 0
  146. choose('audit', len(actual))
  147. for width in [1440, 390, 320]:
  148. call('Emulation.setDeviceMetricsOverride', {'width': width, 'height': 1100,
  149. 'deviceScaleFactor': 1, 'mobile': width < 600})
  150. ev('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))')
  151. assert ev('document.documentElement.scrollWidth<=innerWidth')
  152. data = call('Page.captureScreenshot', {'format': 'png', 'captureBeyondViewport': False})
  153. prefix = 'production-reviews-' if args.production else 'patched-reviews-'
  154. (WORK / (prefix + str(width) + '.png')).write_bytes(base64.b64decode(data['data']))
  155. call('Emulation.setDeviceMetricsOverride', {'width': 1440, 'height': 1100, 'deviceScaleFactor': 1, 'mobile': False})
  156. report['products'].append({'productId': pid, 'batch': sample['batch'], 'defaultScope': 'audit',
  157. 'visibleReviews': len(actual), 'allTextsMatched': True})
  158. print(sample['batch'] + ' default display passed', flush=True)
  159. go('jd:100005702440')
  160. assert action('return p.reviewQuality;') == 'analysis'
  161. assert action('return p._data.totalMatched;') == 18
  162. go(empty['record_id'])
  163. assert action('return p.reviewQuality;') == 'analysis'
  164. assert action('return p._data.totalMatched;') == 0
  165. assert action('return p.renderRoot.textContent.includes("不代表平台上没有评论");')
  166. assert not [e for e in events if e.get('method') == 'Runtime.exceptionThrown']
  167. assert overrides == 0 if args.production else overrides >= 8
  168. api({'op': 'version', 'generation': generation})
  169. report.update(ok=True, explicitAnalysisHonoredAfterReload=True, explicitAuditRestoresAll=True,
  170. eligibleProductKeepsAnalysis18=True, emptyProductAccurate=True,
  171. widthsVerified=[1440, 390, 320], consoleExceptions=0, overriddenModuleRequests=overrides,
  172. allApiResponsesRealProduction=True)
  173. finally:
  174. if ws:
  175. ws.close()
  176. process.terminate()
  177. try:
  178. process.wait(timeout=8)
  179. except subprocess.TimeoutExpired:
  180. process.kill()
  181. report_name = 'production-browser-verification.json' if args.production else 'browser-verification.json'
  182. (WORK / report_name).write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  183. print(json.dumps(report, ensure_ascii=False, indent=2))