recover-product-completion.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. """Recover exact-SKU attributes and audit review originals without new collection.
  2. Public HTML is retained byte-for-byte. JSON is explicitly a parsed adapter, never
  3. an upstream response. Historical capture times and unknown numeric units survive.
  4. """
  5. import argparse
  6. from collections import Counter, defaultdict
  7. from datetime import datetime, timezone
  8. import hashlib
  9. import importlib.util
  10. import json
  11. from pathlib import Path
  12. import re
  13. import zipfile
  14. ROOT = Path(__file__).resolve().parents[1]
  15. WORK = ROOT / 'work/product-completion-20261108-r1'
  16. DOCS = ROOT / 'docs/product-completion-20261108'
  17. PLACEHOLDER = re.compile(r'未填写评价|该用户觉得商品|此用户没有填写|系统默认')
  18. def module(name, filename):
  19. spec = importlib.util.spec_from_file_location(name, ROOT / 'scripts' / filename)
  20. result = importlib.util.module_from_spec(spec)
  21. spec.loader.exec_module(result)
  22. return result
  23. links = module('product_links', 'enrich-product-links.py')
  24. unbound = module('unbound_evidence', 'audit-industry-unbound-evidence.py')
  25. def sha(raw):
  26. return hashlib.sha256(raw).hexdigest()
  27. def read(path):
  28. return json.loads(Path(path).read_bytes())
  29. def save(path, value):
  30. path = Path(path)
  31. path.parent.mkdir(parents=True, exist_ok=True)
  32. path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  33. def select_spec(raw, expected_id, expected_sha):
  34. """Only explicit sale-property labels on the exact selected SKU are facts."""
  35. if sha(raw) != expected_sha:
  36. raise ValueError('PAGE_HASH_MISMATCH')
  37. text = raw.decode('utf-8')
  38. match = re.search(r'window\._itemOnly\s*=\s*\(?\s*', text)
  39. if not match:
  40. raise ValueError('NO_LITERAL_PAYLOAD')
  41. item_only, end = json.JSONDecoder().raw_decode(text[match.end():])
  42. # A literal must finish at its assignment boundary, not be an expression.
  43. if not re.match(r'\s*\)?\s*;', text[match.end() + end:]):
  44. raise ValueError('NON_LITERAL_ASSIGNMENT')
  45. item = item_only.get('item', {})
  46. if str(item.get('skuId')) != expected_id:
  47. raise ValueError('PAGE_PRODUCT_MISMATCH')
  48. matches = [(i, v) for i, v in enumerate(item.get('newColorSize', []))
  49. if isinstance(v, dict) and str(v.get('skuId')) == expected_id]
  50. if len(matches) != 1:
  51. raise ValueError('SELECTED_SKU_NOT_UNIQUE')
  52. index, selected = matches[0]
  53. attributes = []
  54. for key, label in item.get('saleProp', {}).items():
  55. value = selected.get(key)
  56. if isinstance(label, str) and label.strip() and isinstance(value, str) and value.strip():
  57. attributes.append({'label': label, 'value': value,
  58. 'labelPointer': '/item/saleProp/' + key,
  59. 'valuePointer': '/item/newColorSize/' + str(index) + '/' + key})
  60. if not attributes:
  61. raise ValueError('NO_EXPLICIT_SELECTED_ATTRIBUTE')
  62. spec_text = ';'.join(a['label'] + ':' + a['value'] for a in attributes)
  63. return {'payload': item_only, 'literalCharacterOffset': match.end(),
  64. 'literalCharacterLength': end, 'selectedIndex': index,
  65. 'attributes': attributes, 'specText': spec_text}
  66. def reviews(snapshot):
  67. """Audit requested/returned identity before any full-key deduplication."""
  68. existing = {r['record_id'] for r in snapshot['entities']['reviews']}
  69. products = {r['record_id'] for r in snapshot['entities']['products']}
  70. seen, unique, counts, excluded, missing = set(), set(), Counter(), [], {}
  71. roots = [ROOT / 'work/product-reviews',
  72. ROOT / 'work/industry-review-source-recovery-20261107-r2/verified/private-recovered-envelopes',
  73. ROOT / 'work/voc-shop-chain-audit-20261106-r1/received-r2/originals',
  74. ROOT / 'work/existing-voc-mcp-readonly-20261106-r1']
  75. original = next(Path('F:/文档').glob('*VOC*全部已采集*.zip'))
  76. archives = [original, *sorted((ROOT / 'work/product-reviews').rglob('staging.source.zip')),
  77. ROOT / 'work/voc-review-restoration-20261106-r1/batch/staging.source.zip',
  78. ROOT / 'work/industry-review-source-recovery-20261107-r2/received/original-return.zip']
  79. def consume(raw, locator):
  80. counts['jsonFilesRead'] += 1
  81. hashed = sha(raw)
  82. if hashed in seen:
  83. counts['duplicateBytes'] += 1
  84. return
  85. seen.add(hashed)
  86. try:
  87. doc = json.loads(raw)
  88. except (ValueError, UnicodeDecodeError):
  89. counts['nonJson'] += 1
  90. return
  91. if not isinstance(doc, dict):
  92. return
  93. meta = unbound.metadata(doc)
  94. endpoint, params = meta['endpointId'], meta['params']
  95. if endpoint == 'jd.get_item_comments_v1':
  96. family, rows, pointer = 'jd', meta['records'], meta['recordArrayPointer']
  97. elif endpoint == 'taobao.get_item_comment_v3':
  98. family, rows, pointer = 'taobao', None, None
  99. for p in ['/result/data/data/comments', '/data/data/comments', '/data/result/comments',
  100. '/response/structuredContent/data/result/comments']:
  101. candidate = unbound.at(doc, p)
  102. if isinstance(candidate, list):
  103. rows, pointer = candidate, p
  104. break
  105. else:
  106. return
  107. counts['uniqueReviewEnvelopes'] += 1
  108. if not isinstance(rows, list):
  109. counts['reviewResponseWithoutRows'] += 1
  110. return
  111. request = str(params.get('itemId')) if isinstance(params, dict) and params.get('itemId') is not None else None
  112. for i, r in enumerate(rows):
  113. counts['rawReviewRows'] += 1
  114. if family == 'jd':
  115. item, rid, content, append = r.get('productId'), r.get('commentId'), r.get('commentData'), r.get('afterComment')
  116. else:
  117. item, rid, content = r.get('auction', {}).get('aucNumId'), r.get('rateId'), r.get('content')
  118. append = r.get('append') or r.get('appendList')
  119. # Floats, booleans and missing IDs must not become plausible strings.
  120. if type(item) not in (str, int) or type(rid) not in (str, int) or not str(item).isdigit() or not str(rid).isdigit():
  121. counts['invalidIdentityRows'] += 1
  122. continue
  123. pid = family + ':' + str(item)
  124. key = pid + ':' + str(rid)
  125. evidence = {'recordId': key, 'requestedItemId': request, 'returnedItemId': str(item),
  126. 'rawSha256': hashed, 'locator': locator, 'pointer': pointer + '/' + str(i)}
  127. if request != str(item):
  128. counts['identityMismatchRows'] += 1
  129. if key not in existing:
  130. excluded.append({**evidence, 'reason': 'REQUEST_RETURN_IDENTITY_MISMATCH'})
  131. continue
  132. if key in unique:
  133. counts['duplicateFullKeys'] += 1
  134. else:
  135. unique.add(key)
  136. if key in existing:
  137. counts['alreadyStoredRowOccurrences'] += 1
  138. continue
  139. if pid not in products:
  140. counts['productNotInCurrentLibrary'] += 1
  141. excluded.append({**evidence, 'reason': 'PRODUCT_NOT_IN_LIBRARY'})
  142. continue
  143. placeholder = not isinstance(content, str) or not content.strip() or bool(PLACEHOLDER.search(content))
  144. # Append content needs review by field, never stringify metadata into prose.
  145. def meaningful_append(value):
  146. if isinstance(value, dict):
  147. return any(isinstance(value.get(k), str) and value[k].strip() and not PLACEHOLDER.search(value[k])
  148. for k in ('content', 'commentData'))
  149. return isinstance(value, list) and any(meaningful_append(v) for v in value)
  150. if placeholder and not meaningful_append(append):
  151. counts['templateOnlyMissingRowOccurrences'] += 1
  152. excluded.append({**evidence, 'reason': 'PLACEHOLDER_WITHOUT_MEANINGFUL_APPEND'})
  153. continue
  154. # Preserve one complete response row, never splice fields from calls.
  155. candidate = {**evidence, 'content': content, 'append': append}
  156. prior = missing.get(key)
  157. if prior and prior['content'] != content:
  158. raise ValueError('MISSING_REVIEW_CONTENT_CONFLICT:' + key)
  159. if not prior or meaningful_append(append) and not meaningful_append(prior['append']):
  160. missing[key] = candidate
  161. inventory = []
  162. for archive in archives:
  163. if not archive.exists():
  164. continue
  165. hashed = sha(archive.read_bytes())
  166. inventory.append({'archive': str(archive), 'sha256': hashed})
  167. with zipfile.ZipFile(archive) as z:
  168. for info in z.infolist():
  169. if info.filename.endswith('.json') and ('/raw/' in info.filename or archive.name == 'original-return.zip') and info.file_size <= 20_000_000:
  170. consume(z.read(info), {'archive': str(archive), 'archiveSha256': hashed, 'member': info.filename})
  171. for folder in roots:
  172. for path in sorted(folder.rglob('*.json')):
  173. if path.stat().st_size <= 20_000_000:
  174. consume(path.read_bytes(), {'file': str(path)})
  175. counts['meaningfulMissingFullKeys'] = len(missing)
  176. counts['alreadyStoredFullKeys'] = len(unique & existing)
  177. counts['templateOnlyMissingFullKeys'] = len({r['recordId'] for r in excluded
  178. if r['reason'] == 'PLACEHOLDER_WITHOUT_MEANINGFUL_APPEND'} - set(missing))
  179. result = {'schema': 'ksf.product-review-recovery-audit.v1', 'generation': snapshot['generation'],
  180. 'checkedAt': datetime.now(timezone.utc).isoformat(), 'scope': 'All current product IDs; known JD/Taobao review endpoints in listed bytes',
  181. 'counts': dict(counts), 'archives': inventory, 'filesystemRoots': [str(p) for p in roots],
  182. 'missingMeaningful': list(missing.values()), 'excluded': excluded, 'paidCalls': 0, 'productionWrites': 0}
  183. save(DOCS / 'review-recovery-audit.json', result)
  184. print(json.dumps({'reviews': result['counts']}, ensure_ascii=False))
  185. def specs(snapshot):
  186. products = {r['record_id']: r for r in snapshot['entities']['products']}
  187. scope = {r['productId'] for r in read(WORK / 'products.json') if r['instantCandidate']}
  188. existing = {(r['product_id'], r.get('spec_text')) for r in snapshot['entities']['specs']}
  189. counts, candidates, excluded = Counter(), [], []
  190. provenance_path = ROOT / 'work/product-media/collected.json'
  191. provenance_raw = provenance_path.read_bytes()
  192. for meta in json.loads(provenance_raw)['records']:
  193. if not meta.get('sourcePageSha256'):
  194. continue
  195. counts['pageProvenanceRows'] += 1
  196. pid = meta['productId']
  197. if pid not in scope:
  198. continue
  199. counts['researchPages'] += 1
  200. path = ROOT / 'work/product-media' / meta['sourceMember']
  201. raw = path.read_bytes()
  202. try:
  203. selected = select_spec(raw, pid.split(':')[1], meta['sourcePageSha256'])
  204. except ValueError as error:
  205. excluded.append({'productId': pid, 'reason': str(error), 'sha256': sha(raw)})
  206. counts[str(error)] += 1
  207. continue
  208. if (pid, selected['specText']) in existing:
  209. counts['alreadyStoredExactText'] += 1
  210. continue
  211. # Archived page extraction captures no weights, prices, stock or dates from title.
  212. candidates.append({'productId': pid, 'title': products[pid]['title'], 'brand': products[pid].get('brand'),
  213. 'specText': selected['specText'], 'meta': meta, 'pagePath': str(path),
  214. 'selected': selected})
  215. counts['safeProducts'] = len(candidates)
  216. report = {'schema': 'ksf.product-spec-recovery-plan.v1', 'generation': snapshot['generation'],
  217. 'provenanceInventorySha256': sha(provenance_raw), 'counts': dict(counts),
  218. 'brands': dict(Counter(r['brand'] for r in candidates)),
  219. 'candidates': [{k: v for k, v in r.items() if k not in ('selected', 'meta', 'pagePath')}
  220. for r in candidates], 'excluded': excluded, 'numericWeightFieldsInferred': 0,
  221. 'newObservations': False, 'paidCalls': 0, 'productionWrites': 0}
  222. save(DOCS / 'spec-recovery-plan.json', report)
  223. return candidates, report
  224. def review_batch(directory, snapshot):
  225. audit = read(DOCS / 'review-recovery-audit.json')
  226. assert audit['generation'] == snapshot['generation']
  227. rows = audit['missingMeaningful']
  228. if not rows:
  229. return None
  230. products = {r['record_id']: r for r in snapshot['entities']['products']}
  231. ids = sorted({r['recordId'].rsplit(':', 1)[0] for r in rows})
  232. batch = {'schema': 'ksf.voc-agent-batch.v2', 'batchId': 'product-review-recovery-20261108-r1',
  233. 'producer': 'Codex: byte-verified historical review recovery; no new collection or high-like claim',
  234. 'sources': [], 'attachments': [], 'products': [], 'reviews': []}
  235. context = {'kind': 'existing_library_context_not_new_observation', 'generation': snapshot['generation'],
  236. 'exportedAt': snapshot['exportedAt'], 'products': [products[pid] for pid in ids]}
  237. save(directory / 'raw/context.json', context)
  238. for i, pid in enumerate(ids):
  239. p = products[pid]
  240. sid = 'context-' + pid.replace(':', '-')
  241. batch['sources'].append({'id': sid, 'path': 'raw/context.json', 'sha256': sha((directory / 'raw/context.json').read_bytes()),
  242. 'platform': pid.split(':')[0], 'marketplace': p['platform'] if pid.startswith('jd:') else 'tmall' if p['platform'] == 'tmall' else 'taobao_unverified',
  243. 'requestItemId': pid.split(':')[1]})
  244. batch['products'].append({'sourceId': sid, 'pointer': '/products/' + str(i),
  245. 'fields': {'itemId': '/item_id', 'title': '/title', 'shopId': '/shop_id', 'shopName': '/shop_name'}})
  246. originals = {}
  247. records = []
  248. for candidate in rows:
  249. family, item, rid = candidate['recordId'].split(':')
  250. loc = candidate['locator']
  251. if 'file' in loc:
  252. raw = Path(loc['file']).read_bytes()
  253. else:
  254. assert sha(Path(loc['archive']).read_bytes()) == loc['archiveSha256']
  255. with zipfile.ZipFile(loc['archive']) as z:
  256. raw = z.read(loc['member'])
  257. assert sha(raw) == candidate['rawSha256']
  258. if re.search(rb'\bsk-[A-Za-z0-9_-]{20,}|Bearer\s+[A-Za-z0-9._-]{20,}', raw):
  259. raise ValueError('POSSIBLE_CREDENTIAL_IN_SOURCE')
  260. doc = json.loads(raw)
  261. meta = unbound.metadata(doc)
  262. assert str(meta['params']['itemId']) == item
  263. original = unbound.at(doc, candidate['pointer'])
  264. if family == 'jd':
  265. actual, review_id, content = original['productId'], original['commentId'], original['commentData']
  266. append, date, rating, sku = original.get('afterComment'), original.get('commentDate'), original.get('commentScore'), original.get('productColor')
  267. likes = original.get('praiseCnt')
  268. else:
  269. actual, review_id, content = original['auction']['aucNumId'], original['rateId'], original['content']
  270. # Keep both original append fields in the attachment; prefer nonempty
  271. # list if present, otherwise the actual append object. Do not invent dates.
  272. append = original.get('appendList') or original.get('append')
  273. date, rating, sku = original.get('date'), original.get('rate'), original['auction'].get('sku')
  274. likes = original.get('likeCount')
  275. assert str(actual) == item and str(review_id) == rid and content == candidate['content']
  276. rel = 'raw/original/' + candidate['rawSha256'] + '.json'
  277. originals[rel] = raw
  278. record = {'itemId': item, 'reviewId': rid, 'content': content, 'appendContent': append,
  279. 'reviewDate': date, 'rating': rating, 'ratingScale': None, 'purchasedSku': sku,
  280. 'isDefault': None, 'sourceTime': meta['observedAtRaw'], 'likeCount': likes,
  281. 'origin': {'path': rel, 'sha256': sha(raw), 'pointer': candidate['pointer'],
  282. 'endpoint': meta['endpointId'], 'requestParams': meta['params'],
  283. 'requestIdentities': meta['requestIdentities']},
  284. 'selection': 'exact_request_return_identity_and_missing_full_key; no_high_like_claim'}
  285. records.append(record)
  286. for rel, raw in originals.items():
  287. (directory / rel).parent.mkdir(parents=True, exist_ok=True)
  288. (directory / rel).write_bytes(raw)
  289. batch['attachments'].append({'path': rel, 'sha256': sha(raw)})
  290. adapter_rel = 'raw/parsed/reviews.json'
  291. save(directory / adapter_rel, {'kind': 'archived_response_mapping_adapter_not_new_upstream_response',
  292. 'generation': snapshot['generation'], 'records': records})
  293. for i, record in enumerate(records):
  294. pid = record['origin']['endpoint'].split('.')[0] + ':' + record['itemId']
  295. p = products[pid]
  296. sid = 'review-' + str(i)
  297. batch['sources'].append({'id': sid, 'path': adapter_rel, 'sha256': sha((directory / adapter_rel).read_bytes()),
  298. 'platform': pid.split(':')[0], 'marketplace': 'jd' if pid.startswith('jd:') else 'tmall' if p['platform'] == 'tmall' else 'taobao_unverified',
  299. 'requestItemId': record['itemId'], 'observedAt': record['sourceTime']})
  300. batch['reviews'].append({'sourceId': sid, 'pointer': '/records/' + str(i),
  301. 'fields': {k: '/' + k for k in ('itemId', 'reviewId', 'content', 'appendContent', 'reviewDate', 'rating', 'ratingScale', 'purchasedSku', 'isDefault', 'sourceTime')}})
  302. save(directory / 'batch.json', batch)
  303. return {'directory': str(directory.relative_to(WORK)), 'batchId': batch['batchId'], 'products': len(ids),
  304. 'specs': 0, 'reviews': len(records), 'productIds': ids, 'reviewIds': [r['recordId'] for r in rows],
  305. 'baselineGeneration': snapshot['generation']}
  306. def build(candidates, snapshot):
  307. folder = WORK / 'intake'
  308. folder.mkdir(exist_ok=False)
  309. # 160 specs = 320 sources + 160 original attachments; below the 1,000-file cap.
  310. # Product contexts come from the fixed generation, not a new product observation.
  311. batches = []
  312. snapshot_sha = sha((ROOT / 'work/industry-monitoring-20261107-r1/snapshot.json').read_bytes())
  313. inventory_sha = sha((ROOT / 'work/product-media/collected.json').read_bytes())
  314. products = {r['record_id']: r for r in snapshot['entities']['products']}
  315. for offset in range(0, len(candidates), 160):
  316. group = candidates[offset:offset + 160]
  317. number = offset // 160 + 1
  318. directory = folder / ('batch-%02d' % number)
  319. batch = {'schema': 'ksf.voc-agent-batch.v2', 'batchId': 'product-spec-recovery-20261108-r1-%02d' % number,
  320. 'producer': 'Codex: exact-SKU archived public attribute recovery; historical evidence, no new collection',
  321. 'sources': [], 'attachments': [], 'products': [], 'specs': []}
  322. context = {'kind': 'existing_library_context_not_new_observation', 'generation': snapshot['generation'],
  323. 'exportedAt': snapshot['exportedAt'], 'snapshotSha256': snapshot_sha,
  324. 'products': [products[r['productId']] for r in group]}
  325. save(directory / 'raw/context.json', context)
  326. for index, candidate in enumerate(group):
  327. pid = candidate['productId']
  328. iid = pid.split(':')[1]
  329. meta, selected = candidate['meta'], candidate['selected']
  330. name = 'jd-' + iid
  331. html_rel = 'raw/original/' + name + '.html'
  332. adapter_rel = 'raw/parsed/' + name + '.json'
  333. raw = Path(candidate['pagePath']).read_bytes()
  334. assert sha(raw) == meta['sourcePageSha256']
  335. (directory / html_rel).parent.mkdir(parents=True, exist_ok=True)
  336. (directory / html_rel).write_bytes(raw)
  337. basis = ('京东商品页的当前SKU销售属性原文;仅核规格文字,不核净含量/件数/总重。'
  338. '原页SHA256=' + sha(raw) + ';SKU=' + iid)
  339. adapter = {'kind': 'parsed_public_html_adapter_not_upstream_json',
  340. 'original': {'path': html_rel, 'sha256': sha(raw), 'url': meta['sourcePageUrl'],
  341. 'observedAt': meta['observedAt'], 'collectedAt': meta['collectedAt']},
  342. 'provenance': {'inventory': 'work/product-media/collected.json',
  343. 'inventorySha256': inventory_sha,
  344. 'record': meta},
  345. 'extraction': {k: v for k, v in selected.items() if k not in ('payload', 'specText')},
  346. 'literal': selected['payload'],
  347. 'spec': {'itemId': iid, 'skuId': iid, 'specText': selected['specText'],
  348. 'verification': 'source_attribute', 'basis': basis}}
  349. save(directory / adapter_rel, adapter)
  350. product = products[pid]
  351. context_id, source_id = 'context-' + name, 'page-' + name
  352. batch['sources'].append({'id': context_id, 'path': 'raw/context.json', 'sha256': sha((directory / 'raw/context.json').read_bytes()),
  353. 'platform': 'jd', 'marketplace': 'jd', 'requestItemId': iid})
  354. batch['products'].append({'sourceId': context_id, 'pointer': '/products/' + str(index),
  355. 'fields': {'itemId': '/item_id', 'title': '/title', 'shopId': '/shop_id', 'shopName': '/shop_name'}})
  356. batch['sources'].append({'id': source_id, 'path': adapter_rel, 'sha256': sha((directory / adapter_rel).read_bytes()),
  357. 'platform': 'jd', 'marketplace': 'jd', 'requestItemId': iid,
  358. 'observedAt': meta['observedAt'], 'collectedAt': meta['collectedAt'], 'url': meta['sourcePageUrl']})
  359. batch['attachments'].append({'path': html_rel, 'sha256': sha(raw)})
  360. batch['specs'].append({'sourceId': source_id, 'pointer': '/spec',
  361. 'fields': {k: '/' + k for k in adapter['spec']}})
  362. save(directory / 'batch.json', batch)
  363. rows = {'directory': str(directory.relative_to(WORK)), 'batchId': batch['batchId'], 'products': len(group), 'specs': len(group),
  364. 'productIds': [r['productId'] for r in group], 'baselineGeneration': snapshot['generation'],
  365. 'originalBytes': sum((directory / a['path']).stat().st_size for a in batch['attachments'])}
  366. batches.append(rows)
  367. recovered = review_batch(folder / ('batch-%02d' % (len(batches) + 1)), snapshot)
  368. if recovered:
  369. batches.append(recovered)
  370. save(WORK / 'batch-plan.json', {'generation': snapshot['generation'], 'batches': batches, 'paidCalls': 0, 'productionWrites': 0})
  371. print(json.dumps({'specsPrepared': len(candidates), 'batches': len(batches)}, ensure_ascii=False))
  372. def correct_probe():
  373. # Derived correction only; retain original per-request receipts unchanged.
  374. report = read(DOCS / 'public-probe.json')
  375. original_sha = sha((DOCS / 'public-probe.json').read_bytes())
  376. for row in report['results']:
  377. if 'risk_handler' in (row.get('finalUrl') or '') or '验证' in (row.get('pageTitle') or ''):
  378. row['priorState'] = row['state']
  379. row['state'] = 'LOGIN_OR_ACCESS_CHALLENGE'
  380. elif row.get('error') == 'AssertionError' and (row.get('storedUrl') or '').startswith('https://sh.jd.com/') and not row.get('requestUrl'):
  381. row['priorState'] = row['state']
  382. row['state'] = 'SKIPPED_SHORT_LINK_BY_PROBE_VALIDATOR'
  383. row['externalRequestIssued'] = False
  384. row['explanation'] = '原探测器的域名白名单遗漏京东短链;本次未向此商品页面发外部请求,不能判为平台不可读。'
  385. report['states'] = dict(Counter(r['state'] for r in report['results']))
  386. report['derivedFromSha256'] = original_sha
  387. report['newNetworkRequests'] = 0
  388. save(DOCS / 'public-probe-corrected.json', report)
  389. def main():
  390. parser = argparse.ArgumentParser(description=__doc__)
  391. parser.add_argument('command', choices=['scan', 'build'])
  392. args = parser.parse_args()
  393. snapshot = read(ROOT / 'work/industry-monitoring-20261107-r1/snapshot.json')
  394. assert snapshot['generation'] == read(WORK / 'summary-before.json')['generation']
  395. if args.command == 'scan':
  396. correct_probe()
  397. reviews(snapshot)
  398. _, result = specs(snapshot)
  399. print(json.dumps({'specs': result['counts']}, ensure_ascii=False))
  400. else:
  401. candidates, _ = specs(snapshot)
  402. build(candidates, snapshot)
  403. if __name__ == '__main__':
  404. main()