verify-original-review-browser.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. """Read real published review pages and record the known append-text omissions."""
  2. import argparse
  3. import base64
  4. from collections import defaultdict
  5. from datetime import datetime, timezone
  6. import json
  7. from pathlib import Path
  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. parser = argparse.ArgumentParser(description=__doc__)
  15. parser.add_argument('--evidence', required=True, type=Path)
  16. parser.add_argument('--out', required=True, type=Path)
  17. args = parser.parse_args()
  18. source = args.evidence.resolve()
  19. out = args.out.resolve()
  20. out.mkdir(exist_ok=False)
  21. shots = out / 'screenshots'
  22. shots.mkdir()
  23. verification = json.loads((source / 'verification.json').read_text(encoding='utf-8'))
  24. originals = json.loads((source / 'selected-original-reviews.json').read_text(encoding='utf-8'))['records']
  25. products = json.loads((source / 'product-verification.json').read_text(encoding='utf-8'))['records']
  26. expected = defaultdict(dict)
  27. for row in originals:
  28. expected[row['product_id']][row['record_id']] = row
  29. sample_ids = list(dict.fromkeys([r['product_id'] for r in originals if r['append_content']] + [
  30. max(originals, key=lambda r: r['likeCount'] if type(r['likeCount']) is int else -1)['product_id'],
  31. next(r['productId'] for r in products if not r['eligibleExactReviews']),
  32. ]))
  33. base = 'https://kangshifu.fmode.cn'
  34. generation = verification['generation']
  35. def api(query):
  36. request = Request(base + '/api/library', json.dumps(query).encode(), {'Content-Type': 'application/json'})
  37. with urlopen(request, timeout=40) as response:
  38. data = json.load(response)
  39. assert data.get('ok') and data['generation'] == generation
  40. return data
  41. api({'op': 'summary', 'generation': generation})
  42. profile = Path(tempfile.mkdtemp(prefix='real-review-browser-', dir=out))
  43. process = subprocess.Popen([
  44. 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', '--headless=new',
  45. '--disable-gpu', '--remote-debugging-port=0', '--disable-background-networking',
  46. '--disable-extensions', '--no-first-run', '--user-data-dir=' + str(profile), 'about:blank',
  47. ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
  48. ws = None
  49. serial = 0
  50. events = []
  51. report = {'ok': False, 'checkedAt': datetime.now(timezone.utc).isoformat(), 'generation': generation,
  52. 'realProductionPage': True, 'moduleOverrides': 0, 'tokenProvided': False,
  53. 'products': [], 'productionWrites': 0, 'paidCalls': 0}
  54. try:
  55. deadline = time.monotonic() + 20
  56. while not (profile / 'DevToolsActivePort').exists():
  57. assert time.monotonic() < deadline and process.poll() is None
  58. time.sleep(.1)
  59. port = (profile / 'DevToolsActivePort').read_text().splitlines()[0]
  60. page = next(p for p in json.load(urlopen('http://127.0.0.1:' + port + '/json/list', timeout=5)) if p['type'] == 'page')
  61. ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=45, suppress_origin=True,
  62. http_no_proxy=['127.0.0.1', 'localhost'])
  63. def call(method, params=None):
  64. global serial
  65. serial += 1
  66. ws.send(json.dumps({'id': serial, 'method': method, 'params': params or {}}))
  67. while True:
  68. response = json.loads(ws.recv())
  69. if response.get('id') == serial:
  70. assert 'error' not in response, response
  71. return response.get('result', {})
  72. events.append(response)
  73. def ev(expression):
  74. data = call('Runtime.evaluate', {'expression': expression, 'returnByValue': True, 'awaitPromise': True})
  75. assert 'exceptionDetails' not in data, data
  76. return data['result'].get('value')
  77. def wait(expression):
  78. ev('new Promise((resolve,reject)=>{const end=Date.now()+30000;const tick=()=>{try{if(' + expression +
  79. '){resolve(true);return}}catch{}if(Date.now()>end)reject(new Error("review page timeout"));'
  80. 'else setTimeout(tick,70)};tick()})')
  81. view = 'document.querySelector("kf-app")?.renderRoot.querySelector("view-library")'
  82. detail = view + '?.renderRoot.querySelector("library-product-detail")'
  83. def action(body):
  84. return ev('(()=>{const p=' + detail + ';' + body + '})()')
  85. call('Page.enable')
  86. call('Runtime.enable')
  87. call('Network.enable')
  88. for index, pid in enumerate(sample_ids):
  89. call('Emulation.setDeviceMetricsOverride', {'width': 1440, 'height': 1100,
  90. 'deviceScaleFactor': 1, 'mobile': False})
  91. url = base + '/library/products/' + quote(pid, safe='') + '?panel=reviews'
  92. call('Page.navigate', {'url': url})
  93. wait(detail + '?.product?.id===' + json.dumps(pid) + ' && ' + detail +
  94. '.panel==="reviews" && !' + detail + '._loading && ' + detail + '._data')
  95. assert ev(view + '._summary.generation') == generation
  96. snapshot = action('return {generation:p._data.generation,total:p._data.totalMatched,'
  97. 'scope:p.renderRoot.querySelector("select[aria-label=商品评价范围]").value,'
  98. 'rows:Array.from(p.renderRoot.querySelectorAll("article.review")).map(el=>({'
  99. 'id:el.dataset.childId,productId:el.dataset.productId,'
  100. 'content:el.querySelector("p.full-review").textContent,'
  101. 'append:el.querySelector(".appended p")?.textContent||""}))};')
  102. expected_rows = expected[pid]
  103. assert snapshot['generation'] == generation
  104. assert snapshot['total'] == len(expected_rows) == len(snapshot['rows'])
  105. if expected_rows:
  106. assert snapshot['scope'] == 'audit'
  107. assert {r['id']: r['content'] for r in snapshot['rows']} == {
  108. rid: row['content'] for rid, row in expected_rows.items()}
  109. assert all(r['productId'] == pid for r in snapshot['rows'])
  110. omissions = [r['id'] for r in snapshot['rows']
  111. if r['append'] != expected_rows[r['id']]['append_content']]
  112. known = {r['reviewId'] for r in json.loads((source / 'failures.json').read_text(encoding='utf-8'))['selected']}
  113. assert set(omissions) <= known
  114. layouts = []
  115. for width in ([1440, 390, 320] if index == 0 else [390]):
  116. call('Emulation.setDeviceMetricsOverride', {'width': width, 'height': 1100,
  117. 'deviceScaleFactor': 1, 'mobile': width < 600})
  118. ev('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))')
  119. assert ev('document.documentElement.scrollWidth<=innerWidth')
  120. name = pid.replace(':', '-') + '-' + str(width) + '.png'
  121. data = call('Page.captureScreenshot', {'format': 'png', 'captureBeyondViewport': False})
  122. (shots / name).write_bytes(base64.b64decode(data['data']))
  123. layouts.append({'width': width, 'noDocumentOverflow': True, 'screenshot': 'screenshots/' + name})
  124. report['products'].append({'productId': pid, 'url': url, 'defaultScope': snapshot['scope'],
  125. 'visibleMainReviews': len(expected_rows), 'allMainTextMatched': True,
  126. 'appendOmissionsConfirmed': omissions, 'layouts': layouts})
  127. print(pid + ': main review text verified; append omissions=' + str(len(omissions)), flush=True)
  128. report['consoleExceptions'] = [e['params']['exceptionDetails'].get('text')
  129. for e in events if e.get('method') == 'Runtime.exceptionThrown']
  130. assert not report['consoleExceptions']
  131. api({'op': 'summary', 'generation': generation})
  132. report['mainReviewDisplayVerified'] = True
  133. report['knownAppendOmissions'] = sum(len(r['appendOmissionsConfirmed']) for r in report['products'])
  134. report['completeDataAcceptancePassed'] = report['knownAppendOmissions'] == 0
  135. report['ok'] = True
  136. finally:
  137. if ws:
  138. ws.close()
  139. process.terminate()
  140. try:
  141. process.wait(timeout=8)
  142. except subprocess.TimeoutExpired:
  143. process.kill()
  144. (out / 'browser-verification.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  145. print(json.dumps(report, ensure_ascii=False, indent=2))