prepare-five-topic-supplement.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. """Seal source bytes and reviewed topic evidence using the existing intake v2."""
  2. from datetime import datetime, timezone
  3. import argparse
  4. import hashlib
  5. import importlib.util
  6. import json
  7. from pathlib import Path
  8. import re
  9. import sqlite3
  10. import sys
  11. from lxml import html
  12. ROOT = Path(__file__).resolve().parents[1]
  13. WORK = ROOT / 'work/five-topic-collection-20261110/supplement-01'
  14. BATCH = ROOT / 'work/database-enrichment-20261110/batches/five-topics-public-02'
  15. BATCH_ID = 'ksf-five-topics-supplement-20261110-r1'
  16. sys.path.insert(0, str(ROOT / 'server/voc-library'))
  17. import structured_batch
  18. import library_channel
  19. def sha(raw): return hashlib.sha256(raw).hexdigest()
  20. def save(file, value): file.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding='utf8')
  21. def post_id(row):
  22. host, regex = {
  23. 'smzdm': ('post.smzdm.com', r'/(?:talk/)?p/([a-z0-9]+)/'),
  24. 'foodaily': ('www.foodaily.com', r'/articles/(\d+)'),
  25. 'yicai': ('www.yicai.com', r'/news/(\d+)\.html'),
  26. 'netease': ('www.163.com', r'/dy/article/([A-Z0-9]+)\.html'),
  27. 'sspai': ('sspai.com', r'/post/(\d+)'),
  28. }[row['platform']]
  29. match = re.fullmatch('https://' + re.escape(host) + regex, row['url'])
  30. assert match, 'PRECISE_PUBLISHER_ID_REQUIRED'
  31. return match.group(1)
  32. def main():
  33. review = json.loads((WORK / 'reviewed-selection.json').read_bytes())
  34. dedup = json.loads((WORK / 'dedup-result.json').read_bytes())
  35. selected = review['selected']
  36. ids = [row['platform'] + ':' + post_id(row) for row in selected]
  37. assert dedup['ok'] and dedup['existingRecords'] == 0 and set(dedup['ids']) == set(ids), 'REMOTE_DEDUP_REQUIRED'
  38. assert len(set(ids)) == len(ids)
  39. assert not BATCH.exists(), 'FRESH_SEALED_BATCH_REQUIRED'
  40. BATCH.mkdir(parents=True)
  41. (BATCH / 'raw/original').mkdir(parents=True)
  42. batch = {'schema': 'ksf.voc-agent-batch.v2', 'batchId': BATCH_ID,
  43. 'producer': 'Codex: reviewed public community and publisher articles; quoted topic passages; author claims not independently verified',
  44. 'sources': [], 'attachments': [], 'social': []}
  45. expected = []
  46. for row in selected:
  47. pid = post_id(row)
  48. stem = row['platform'] + '-' + pid
  49. receipt = row['httpReceipt']
  50. raw = (WORK / row['collectionDirectory'] / receipt['responseFile']).read_bytes()
  51. assert sha(raw) == receipt['sha256']
  52. assert sha(row['body'].encode()) == row['bodySha256']
  53. tree = html.fromstring(raw.decode(row['sourceEncoding']))
  54. nodes = tree.xpath(row['bodyExtractionSelector'])
  55. assert len(nodes) == 1, 'UNIQUE_BODY_LOCATOR_REQUIRED'
  56. spec = importlib.util.spec_from_file_location('base_replay', ROOT / 'scripts/collect-five-topic-public.py')
  57. base = importlib.util.module_from_spec(spec)
  58. spec.loader.exec_module(base)
  59. assert base.paragraphs(nodes[0]) == row['body'], 'ORIGINAL_BODY_REPLAY_MISMATCH'
  60. assert all(quote in row['body'] for t in row['topics'] for quote in t['passages'])
  61. original_path = 'raw/original/' + stem + '.html'
  62. (BATCH / original_path).write_bytes(raw)
  63. http_path = 'raw/original/' + stem + '.http.json'
  64. save(BATCH / http_path, receipt)
  65. for relative in [original_path, http_path]:
  66. batch['attachments'].append({'path': relative, 'sha256': sha((BATCH / relative).read_bytes())})
  67. scope = row.get('contentScope', 'adjacent_noodle_food' if row['url'].endswith('/37590') else 'packaged_noodles')
  68. record = {'postId': pid, 'title': row['title'], 'content': row['body'], 'url': row['url'],
  69. 'contentScope': scope, 'publishedAt': row['publishedAtRaw']}
  70. adapter = {'kind': 'explicit_html_extraction_adapter_not_upstream_json', 'record': record,
  71. 'original': {'path': original_path, 'sha256': sha(raw), 'sourceUrl': row['url'],
  72. 'resolvedUrl': receipt['finalUrl'], 'collectedAt': receipt['collectedAt'],
  73. 'selector': row['bodyExtractionSelector'], 'encoding': row['sourceEncoding']},
  74. 'researchTopics': row['topics'], 'sourceType': row['sourceType'],
  75. 'reviewNote': row['reviewNote'], 'limits': row['limits'] + review['limits']}
  76. relative = 'raw/adapter-' + stem + '.json'
  77. save(BATCH / relative, adapter)
  78. sid = 'public-' + stem
  79. batch['sources'].append({'id': sid, 'path': relative, 'sha256': sha((BATCH / relative).read_bytes()),
  80. 'platform': row['platform'], 'collectedAt': receipt['collectedAt'], 'url': row['url']})
  81. batch['social'].append({'sourceId': sid, 'pointer': '/record',
  82. 'fields': {field: '/' + field for field in record}})
  83. expected.append({'entity': 'social', 'id': row['platform'] + ':' + pid, 'postId': pid,
  84. 'platform': row['platform'], 'content_scope': scope, 'title': row['title'],
  85. 'content': row['body'], 'url': row['url'], 'published_at_raw': row['publishedAtRaw'],
  86. 'originalSha256': sha(raw), 'adapterSha256': sha((BATCH / relative).read_bytes()),
  87. 'adapterPath': relative, 'originalPath': original_path,
  88. 'topics': [t['topic'] for t in row['topics']], 'topicEvidence': row['topics'],
  89. 'sourceType': row['sourceType'], 'reviewNote': row['reviewNote']})
  90. save(BATCH / 'batch.json', batch)
  91. preview = structured_batch.build(BATCH / 'batch.json', BATCH / 'staging.sqlite', False)
  92. assert preview['ok'] and preview['readOnly'] and preview['counts']['social'] == len(selected)
  93. save(BATCH / 'validation.json', preview)
  94. built = structured_batch.build(BATCH / 'batch.json', BATCH / 'staging.sqlite', True)
  95. with sqlite3.connect(BATCH / 'staging.sqlite') as connection:
  96. connection.row_factory = sqlite3.Row
  97. rows = {r['id']: dict(r) for r in connection.execute('SELECT * FROM social_posts')}
  98. assert len(rows) == len(expected)
  99. for row in expected:
  100. actual = rows[row['id']]
  101. for field in ['platform', 'content_scope', 'title', 'content', 'url', 'published_at_raw']:
  102. assert actual[field] == row[field], 'STAGING_VALUE_MISMATCH:' + field
  103. assert actual['fulltext_available'] == 1
  104. transport, fingerprint = library_channel.pack(BATCH / 'batch.json')
  105. (BATCH / 'transport.zip').write_bytes(transport)
  106. receipt = {'kind': 'five_topic_reviewed_public_supplement', 'batchId': batch['batchId'],
  107. 'archiveSha256': fingerprint, 'archiveBytes': len(transport), 'accepted': {'social': len(expected)},
  108. 'products': [], 'sourceTopics': review['countsByTopic'], 'validation': built,
  109. 'productionImported': False, 'createdAt': datetime.now(timezone.utc).isoformat()}
  110. save(BATCH / 'receipt.json', receipt)
  111. save(BATCH / 'expected-records.json', expected)
  112. print(json.dumps({'ok': True, 'batch': BATCH.name, 'articles': len(expected),
  113. 'countsByTopic': review['countsByTopic'], 'archiveBytes': len(transport),
  114. 'archiveSha256': fingerprint}, ensure_ascii=False))
  115. if __name__ == '__main__':
  116. parser = argparse.ArgumentParser()
  117. parser.add_argument('--work', default=str(WORK))
  118. parser.add_argument('--batch', default=str(BATCH))
  119. parser.add_argument('--batch-id', default=BATCH_ID)
  120. args = parser.parse_args()
  121. WORK, BATCH, BATCH_ID = Path(args.work).resolve(), Path(args.batch).resolve(), args.batch_id
  122. assert WORK.is_relative_to(ROOT / 'work') and BATCH.is_relative_to(ROOT / 'work'), 'WORKSPACE_OUTPUT_REQUIRED'
  123. main()