prepare-five-topic-public.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. """Seal reviewed public community articles through the existing research intake."""
  2. from datetime import datetime, timezone
  3. import hashlib
  4. import importlib.util
  5. import json
  6. from pathlib import Path
  7. import re
  8. import sqlite3
  9. import sys
  10. ROOT = Path(__file__).resolve().parents[1]
  11. WORK = ROOT / 'work/five-topic-collection-20261110'
  12. BATCH = ROOT / 'work/database-enrichment-20261110/batches/five-topics-public-01'
  13. sys.path.insert(0, str(ROOT / 'server/voc-library'))
  14. import structured_batch
  15. import library_channel
  16. def sha(raw): return hashlib.sha256(raw).hexdigest()
  17. def save(path, value):
  18. path.parent.mkdir(parents=True, exist_ok=True)
  19. path.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding='utf8')
  20. def main():
  21. spec = importlib.util.spec_from_file_location('public_collection', ROOT / 'scripts/collect-five-topic-public.py')
  22. collector = importlib.util.module_from_spec(spec)
  23. spec.loader.exec_module(collector)
  24. result = json.loads((WORK / 'public-collection-result.json').read_bytes())
  25. assert result['status'] == 'complete'
  26. # Only the original community experience texts reviewed in this pass.
  27. approved = {'https://post.smzdm.com/talk/p/a6p3l48e/', 'https://post.smzdm.com/p/aek8v7zq/',
  28. 'https://post.smzdm.com/p/aqmr7o3x/', 'https://post.smzdm.com/p/arqpdepg/',
  29. 'https://post.smzdm.com/p/apm9dg09/', 'https://post.smzdm.com/p/a4d38e3k/'}
  30. selected = [row for row in result['sources'] if row['url'] in approved]
  31. assert len(selected) == len(approved) == 6, 'REVIEWED_SOURCE_MISSING'
  32. assert not BATCH.exists(), 'FRESH_SEALED_BATCH_REQUIRED'
  33. BATCH.mkdir(parents=True)
  34. batch = {'schema': 'ksf.voc-agent-batch.v2', 'batchId': 'ksf-five-topics-public-20261110-r1',
  35. 'producer': 'Codex: reviewed public SMZDM article originals; exact URL and HTML extraction; author claims not independently verified',
  36. 'sources': [], 'attachments': [], 'social': []}
  37. expected, topic_counts = [], {topic['id']: 0 for topic in collector.PLAN['topics']}
  38. for index, row in enumerate(selected):
  39. http = row['httpReceipt']
  40. raw = (WORK / 'public-sources' / http['responseFile']).read_bytes()
  41. assert sha(raw) == http['sha256'], 'SOURCE_HTTP_SHA_MISMATCH'
  42. title, body, selector, published, encoding = collector.extract(raw, http)
  43. assert title == row['title'] and body == row['body'], 'SOURCE_EXTRACTION_CHANGED'
  44. assert sha(body.encode()) == row['bodySha256'], 'BODY_SHA_MISMATCH'
  45. match = re.fullmatch(r'https://post\.smzdm\.com/(?:talk/)?p/([a-z0-9]+)/', row['url'])
  46. assert match, 'CANONICAL_POST_ID_REQUIRED'
  47. post_id = match.group(1)
  48. original_path = 'raw/original/' + post_id + '.html'
  49. (BATCH / 'raw/original').mkdir(parents=True, exist_ok=True)
  50. (BATCH / original_path).write_bytes(raw)
  51. receipt_path = 'raw/original/' + post_id + '.http.json'
  52. save(BATCH / receipt_path, http)
  53. for relative in [original_path, receipt_path]:
  54. batch['attachments'].append({'path': relative, 'sha256': sha((BATCH / relative).read_bytes())})
  55. record = {'postId': post_id, 'title': title, 'content': body, 'url': row['url'],
  56. 'contentScope': 'packaged_noodles', 'publishedAt': published}
  57. adapter = {'kind': 'explicit_html_extraction_adapter_not_upstream_json', 'record': record,
  58. 'original': {'path': original_path, 'sha256': sha(raw), 'sourceUrl': row['url'],
  59. 'resolvedUrl': http['finalUrl'], 'collectedAt': http['collectedAt'],
  60. 'selector': selector, 'encoding': encoding},
  61. 'researchTopics': row['topics'],
  62. 'limits': ['原作者体验及观点,未独立验证功效/价格/市场数字',
  63. '历史原文保留原日期,不冒充近期趋势或当前商品报价',
  64. '外部社区文章,不是电商购买评价,不推定中产或学生身份']}
  65. relative = 'raw/adapter-' + post_id + '.json'
  66. save(BATCH / relative, adapter)
  67. sid = 'public-' + post_id
  68. batch['sources'].append({'id': sid, 'path': relative, 'sha256': sha((BATCH / relative).read_bytes()),
  69. 'platform': 'smzdm', 'collectedAt': http['collectedAt'], 'url': row['url']})
  70. batch['social'].append({'sourceId': sid, 'pointer': '/record',
  71. 'fields': {field: '/' + field for field in record if record[field] is not None}})
  72. topics = [item['topic'] for item in row['topics']]
  73. for topic in topics: topic_counts[topic] += 1
  74. expected.append({'entity': 'social', 'id': 'smzdm:' + post_id, 'postId': post_id, 'title': title,
  75. 'content': body, 'url': row['url'], 'published_at_raw': published,
  76. 'originalSha256': sha(raw), 'adapterSha256': sha((BATCH / relative).read_bytes()),
  77. 'adapterPath': relative, 'originalPath': original_path, 'topics': topics})
  78. save(BATCH / 'batch.json', batch)
  79. preview = structured_batch.build(BATCH / 'batch.json', BATCH / 'staging.sqlite', False)
  80. assert preview['ok'] and preview['readOnly'] and preview['counts']['social'] == 6
  81. save(BATCH / 'validation.json', preview)
  82. built = structured_batch.build(BATCH / 'batch.json', BATCH / 'staging.sqlite', True)
  83. with sqlite3.connect(BATCH / 'staging.sqlite') as connection:
  84. connection.row_factory = sqlite3.Row
  85. rows = {r['id']: dict(r) for r in connection.execute('SELECT * FROM social_posts')}
  86. assert len(rows) == 6
  87. for expected_row in expected:
  88. actual = rows[expected_row['id']]
  89. for field in ['title', 'content', 'url', 'published_at_raw']: assert actual[field] == expected_row[field]
  90. assert actual['platform'] == 'smzdm' and actual['fulltext_available'] == 1
  91. transport, fingerprint = library_channel.pack(BATCH / 'batch.json')
  92. (BATCH / 'transport.zip').write_bytes(transport)
  93. receipt = {'kind': 'five_topic_public_community_originals', 'batchId': batch['batchId'],
  94. 'archiveSha256': fingerprint, 'archiveBytes': len(transport), 'accepted': {'social': 6},
  95. 'products': [], 'sourceTopics': topic_counts, 'validation': built,
  96. 'productionImported': False, 'createdAt': datetime.now(timezone.utc).isoformat()}
  97. save(BATCH / 'receipt.json', receipt)
  98. save(BATCH / 'expected-records.json', expected)
  99. excluded = [{'url': r['url'], 'title': r['title'],
  100. 'reason': 'not selected for import: generic health claims, unattributed consumer quotes, recipe content or insufficient verified product experience'}
  101. for r in result['sources'] if r['url'] not in approved]
  102. save(WORK / 'reviewed-public-sources.json', {'selected': expected, 'excluded': excluded,
  103. 'countsByTopic': topic_counts, 'sourceBodiesReviewed': 12,
  104. 'acceptedUniqueOriginals': 6})
  105. print(json.dumps({'ok': True, 'batch': BATCH.name, 'originals': len(expected),
  106. 'countsByTopic': topic_counts, 'archiveSha256': fingerprint,
  107. 'archiveBytes': len(transport), 'productionWrites': 0}, ensure_ascii=False))
  108. if __name__ == '__main__': main()