audit-unavailable-research.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. """Separate source disappearance from missing prose or restricted access."""
  2. from datetime import datetime, timezone
  3. import hashlib
  4. from html.parser import HTMLParser
  5. import json
  6. from pathlib import Path
  7. import re
  8. WORK = Path(__file__).resolve().parents[1] / 'work/database-enrichment-20261110'
  9. def official_bili_state(doc, http):
  10. # 62002 means inaccessible. It does not prove deletion and must not become
  11. # a destructive decision just because a proxy labels it NO VIDEO.
  12. if http in (401, 403, 412, 429) or doc.get('code') in (-101, -111, -352, 62002):
  13. return 'ACCESS_RESTRICTED_NOT_CONFIRMED_REMOVED'
  14. if http == 200 and doc.get('code') == -404:
  15. return 'OFFICIAL_NOT_FOUND_REQUIRES_PAGE_CONFIRMATION'
  16. if http == 200 and doc.get('code') == 0 and isinstance(doc.get('data'), dict):
  17. return 'SOURCE_RECORD_EXISTS'
  18. return 'UNCONFIRMED_RESPONSE'
  19. class VisibleText(HTMLParser):
  20. def __init__(self):
  21. super().__init__()
  22. self.hidden, self.text = 0, []
  23. def handle_starttag(self, tag, attrs):
  24. if tag in ('head', 'script', 'style', 'template'): self.hidden += 1
  25. def handle_endtag(self, tag):
  26. if tag in ('head', 'script', 'style', 'template'): self.hidden = max(0, self.hidden - 1)
  27. def handle_data(self, text):
  28. if not self.hidden: self.text.append(text)
  29. def page_confirms_removed(raw, meta, iid):
  30. if meta.get('httpStatus') not in (200, 404): return False
  31. # An authentication redirect or error text found only in JS is insufficient.
  32. if not re.fullmatch(r'https://(?:www\.)?bilibili\.com/video/' + re.escape(iid) + r'/?', meta.get('finalUrl', '')):
  33. return False
  34. parser = VisibleText()
  35. parser.feed(raw.decode('utf-8', errors='replace'))
  36. return bool(re.search(r'视频(?:不见了|已被删除|已删除|不存在)|稿件(?:已删除|不存在)', ''.join(parser.text)))
  37. def audit():
  38. source = json.loads((WORK / 'social-body-audit.json').read_bytes())
  39. rows = []
  40. for post in source['posts']:
  41. row = {'id': post['id'], 'deleteEligible': False, 'attempts': len(post['attempts'])}
  42. states = {a['state'] for a in post['attempts']}
  43. if states & {'UPSTREAM_BODY_EMPTY', 'UPSTREAM_BODY_PLACEHOLDER', 'BODY_ONLY_REPEATS_TITLE'}:
  44. row.update(state='MISSING_AUTHOR_TEXT_NOT_EVIDENCE_OF_REMOVAL', reason='Exact source object returned; missing description or repeated title does not mean video or images disappeared.')
  45. elif 'UPSTREAM_DETAIL_UNAVAILABLE' in states:
  46. row.update(state='SOURCE_REPORTED_UNAVAILABLE_REQUIRES_VERIFICATION')
  47. else:
  48. row.update(state='COLLECTION_FAILED_OR_MISMATCH_NOT_EVIDENCE_OF_REMOVAL')
  49. if post['platform'] == 'bilibili':
  50. iid = post['id'].split(':', 1)[1]
  51. meta_path = WORK / 'link-checks' / (iid + '--official-api.json')
  52. if meta_path.exists():
  53. meta = json.loads(meta_path.read_bytes())
  54. body_path = meta_path.parent / meta['responseFile']
  55. raw = body_path.read_bytes()
  56. if hashlib.sha256(raw).hexdigest() != meta['sha256']:
  57. raise ValueError('OFFICIAL_RESPONSE_HASH_MISMATCH')
  58. state = official_bili_state(json.loads(raw), meta['httpStatus'])
  59. row.update(state=state,
  60. officialEvidence={**meta, 'shaVerified': True})
  61. page_path = meta_path.with_name(iid + '--official-page.json')
  62. if state == 'OFFICIAL_NOT_FOUND_REQUIRES_PAGE_CONFIRMATION' and page_path.exists():
  63. page_meta = json.loads(page_path.read_bytes())
  64. page_raw = (page_path.parent / page_meta['responseFile']).read_bytes()
  65. if hashlib.sha256(page_raw).hexdigest() != page_meta['sha256']:
  66. raise ValueError('OFFICIAL_PAGE_HASH_MISMATCH')
  67. if page_confirms_removed(page_raw, page_meta, iid):
  68. row.update(state='CONFIRMED_ORIGINAL_SOURCE_REMOVED', deleteEligible=True,
  69. pageEvidence={**page_meta, 'shaVerified': True})
  70. rows.append(row)
  71. # Audit available product details too. Out-of-stock and a review page with
  72. # no rows never establish that the original product link has disappeared.
  73. products = {}
  74. for meta_path in sorted((WORK / 'collected').glob('*.meta.json')):
  75. meta = json.loads(meta_path.read_bytes())
  76. if meta.get('kind') not in ('product-detail', 'product-reviews'): continue
  77. row = products.setdefault(meta['target'], {'id': meta['target'], 'deleteEligible': False, 'evidence': []})
  78. attempt = {'endpoint': meta['id'], 'metaFile': meta_path.name, 'httpStatus': meta.get('httpStatus')}
  79. if not meta.get('ok'):
  80. attempt['state'] = 'REQUEST_FAILED_NOT_EVIDENCE_OF_REMOVAL'
  81. else:
  82. raw = (meta_path.parent / meta['responseFile']).read_bytes()
  83. if hashlib.sha256(raw).hexdigest() != meta['sha256']:
  84. raise ValueError('PRODUCT_RESPONSE_HASH_MISMATCH')
  85. attempt.update(responseFile=meta['responseFile'], sha256=meta['sha256'], shaVerified=True)
  86. doc = json.loads(raw)
  87. wrapper = doc.get('data', {})
  88. payload = wrapper.get('data', {}) if isinstance(wrapper, dict) else {}
  89. product = payload.get('product' if meta['target'].startswith('jd:') else 'item', {}) if isinstance(payload, dict) else {}
  90. iid = product.get('skuId', product.get('itemId')) if isinstance(product, dict) else None
  91. if meta['kind'] == 'product-detail' and str(iid) == meta['target'].split(':', 1)[1]:
  92. attempt.update(state='EXACT_PRODUCT_OBJECT_EXISTS', title=product.get('skuName', product.get('title')))
  93. row['state'] = 'SOURCE_PRODUCT_EXISTS_KEEP_RECORD'
  94. else:
  95. attempt['state'] = 'REVIEW_RESPONSE_OR_UNCONFIRMED_DETAIL_NOT_EVIDENCE_OF_REMOVAL'
  96. row['evidence'].append(attempt)
  97. for row in products.values():
  98. row.setdefault('state', 'AVAILABILITY_UNCONFIRMED_KEEP_RECORD')
  99. rows.append(row)
  100. result = {'schema': 'ksf.unavailable-research-audit.v1', 'checkedAt': datetime.now(timezone.utc).isoformat(),
  101. 'authorization': 'User authorized removing research records only when original link is confirmed to have no remaining source content.',
  102. 'summary': {'recordsChecked': len(rows), 'socialChecked': len(source['posts']), 'productsChecked': len(products),
  103. 'confirmedDeletionCandidates': sum(r['deleteEligible'] for r in rows),
  104. 'removedRecords': 0, 'productionWrites': 0}, 'records': rows}
  105. (WORK / 'unavailable-records-audit.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
  106. print(json.dumps(result['summary']))
  107. return result
  108. if __name__ == '__main__': audit()