collect-five-topic-supplement.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. """Supplement sparse noodle topics with archived and public original articles.
  2. Search results are discovery only. Short community posts keep their full actual
  3. body; no minimum word count is padded with recommendations or page furniture.
  4. """
  5. from concurrent.futures import ThreadPoolExecutor, as_completed
  6. from datetime import datetime, timezone
  7. import hashlib
  8. import importlib.util
  9. import json
  10. from pathlib import Path
  11. import re
  12. import time
  13. import urllib.parse
  14. from lxml import html
  15. ROOT = Path(__file__).resolve().parents[1]
  16. ORIGINAL_WORK = ROOT / 'work/five-topic-collection-20261110'
  17. WORK = ORIGINAL_WORK / 'supplement-01'
  18. PUBLIC = WORK / 'public-sources'
  19. SEARCH = WORK / 'search'
  20. for directory in [WORK, PUBLIC, SEARCH]: directory.mkdir(parents=True, exist_ok=True)
  21. spec = importlib.util.spec_from_file_location('base_public', ROOT / 'scripts/collect-five-topic-public.py')
  22. base = importlib.util.module_from_spec(spec)
  23. spec.loader.exec_module(base)
  24. base.PUBLIC = PUBLIC
  25. POST = re.compile(r'https://post\.smzdm\.com/(?:talk/)?p/([a-z0-9]+)/?$')
  26. QUERIES = [
  27. ('wellness', '菌菇 方便面 测评 site:post.smzdm.com'),
  28. ('premium', '拉面说 牛肉 评测 site:post.smzdm.com'),
  29. ('stockpile', '方便面 囤货 site:post.smzdm.com'),
  30. ('wellness', '蘑菇 鲜蔬 方便面 site:post.smzdm.com'),
  31. ('premium', '满汉大餐 试吃 site:post.smzdm.com'),
  32. ('stockpile', '泡面 家里 常备 site:post.smzdm.com'),
  33. ('wellness', '五谷道场 非油炸 测评 site:post.smzdm.com'),
  34. ('premium', '速达面馆 牛肉 试吃 site:post.smzdm.com'),
  35. ('stockpile', '泡面 整箱 囤 site:post.smzdm.com'),
  36. ('novelty', '方便面 冬阴功 尝鲜 site:post.smzdm.com'),
  37. ('wellness', '方便面 枸杞 山药 site:post.smzdm.com'),
  38. ('premium', '拉面说 满汉宴 真肉 site:post.smzdm.com'),
  39. ('stockpile', '方便面 囤货 保质期 site:post.smzdm.com'),
  40. ('novelty', '方便面 藤椒 香菜 测评 site:post.smzdm.com'),
  41. ('wellness', '方便面 低钠 减盐 site:post.smzdm.com'),
  42. ('stockpile', '泡面 家庭 囤货 组合 site:post.smzdm.com'),
  43. ]
  44. STATE = {'schema': 'ksf.five-topic.supplement-collection.v1', 'startedAt': base.now(),
  45. 'status': 'running', 'searches': [], 'sources': [], 'rejected': [], 'productionWrites': 0}
  46. def persist():
  47. STATE['updatedAt'] = base.now()
  48. base.save(WORK / 'collection-result.json', STATE)
  49. def canonical(url):
  50. parsed = urllib.parse.urlparse(url)
  51. if parsed.hostname != 'post.smzdm.com': return None
  52. value = 'https://post.smzdm.com' + parsed.path
  53. return value if POST.fullmatch(value) else None
  54. def decode(raw, receipt):
  55. declared = re.search(r'charset\s*=\s*([^;\s]+)', receipt.get('contentType', ''), re.I)
  56. encoding = declared.group(1).strip('"\'') if declared else 'utf8'
  57. try: return raw.decode(encoding), encoding
  58. except (LookupError, UnicodeDecodeError):
  59. try: return raw.decode('utf8'), 'utf8'
  60. except UnicodeDecodeError: return raw.decode('gb18030'), 'gb18030'
  61. def extract(raw, receipt):
  62. text, encoding = decode(raw, receipt)
  63. root = html.fromstring(text)
  64. title = re.sub(r'\s+', ' ', root.xpath('string(//title)')).strip()
  65. if re.search(r'验证|验证码|页面不存在|访问异常|系统繁忙|404', title):
  66. raise ValueError('VERIFICATION_OR_ERROR_PAGE')
  67. articles = root.xpath('//article')
  68. if len(articles) != 1: raise ValueError('ORIGINAL_ARTICLE_NOT_UNIQUE')
  69. article = articles[0]
  70. heading = article.xpath('.//h1')
  71. if heading: title = re.sub(r'\s+', ' ', heading[0].text_content()).strip() or title
  72. selector = '//article'
  73. body = base.paragraphs(article)
  74. # SMZDM talk posts can be short but complete. Exclude a title-only shell.
  75. body_only = body.removeprefix(title).strip()
  76. # This only rejects an empty shell; semantic review decides usefulness.
  77. if len(body_only) < 50: raise ValueError('INSUFFICIENT_ACTUAL_ARTICLE_BODY')
  78. if not re.search(r'方便面|泡面|即食面|速食面|拌面|火鸡面|拉面说|速达面馆|满汉大餐|满汉宴', title + '\n' + body):
  79. raise ValueError('NOT_PACKAGED_NOODLE_EXPERIENCE')
  80. dates = root.xpath('//meta[@property="article:published_time" or @name="pubdate" or @name="weibo:article:create_at"]/@content')
  81. if not dates:
  82. match = re.search(r'\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?', article.text_content()[:1200])
  83. dates = [match.group(0)] if match else []
  84. published = dates[0] if dates else None
  85. if published:
  86. match = re.match(r'(\d{4}-\d{2}-\d{2})', published)
  87. if match and match.group(1) > datetime.now().strftime('%Y-%m-%d'):
  88. raise ValueError('FUTURE_PUBLICATION_DATE')
  89. return title, body, selector, published, encoding
  90. def make_source(raw, receipt, candidate, reused):
  91. url = canonical(receipt['finalUrl'])
  92. if not url: raise ValueError('NOT_CANONICAL_SMZDM_ORIGINAL')
  93. title, body, selector, published, encoding = extract(raw, receipt)
  94. matches = []
  95. for topic in base.PLAN['topics']:
  96. hits = [term for term in topic['keywords'] if term in title + '\n' + body]
  97. if hits: matches.append({'topic': topic['id'], 'matchedTerms': hits})
  98. return {'schema': 'ksf.five-topic.public-source.v1', 'platform': 'smzdm',
  99. 'sourceType': 'public_article_not_ecommerce_review', 'sourceId': 'url-sha256:' + base.digest(url.encode()),
  100. 'url': url, 'title': title, 'body': body, 'bodySha256': base.digest(body.encode()),
  101. 'publishedAtRaw': published, 'collectedAt': receipt['collectedAt'], 'httpReceipt': receipt,
  102. 'sourceEncoding': encoding, 'bodyExtractionSelector': selector, 'topics': matches,
  103. 'searchDiscovery': candidate, 'previouslyArchivedOriginalReused': reused,
  104. 'publishedToDatabase': False,
  105. 'limits': ['公开社区作者原文,不等同电商购买评价或独立消费者',
  106. '作者功效、营养、原料、工艺及价格声明未在本轮独立验证',
  107. '保留原日期,不把历史原文当作当前趋势']}
  108. def archived_sources(existing):
  109. rows = {}
  110. for file in sorted((ORIGINAL_WORK / 'public-sources').glob('*.http.json')):
  111. receipt = json.loads(file.read_bytes())
  112. url = canonical(receipt['finalUrl'])
  113. if not url or url in existing or receipt['httpStatus'] != 200: continue
  114. raw = (file.parent / receipt['responseFile']).read_bytes()
  115. if base.digest(raw) != receipt['sha256']: raise ValueError('ARCHIVED_ORIGINAL_SHA_MISMATCH')
  116. key = 'archive-' + base.digest(url.encode())[:24]
  117. (PUBLIC / (key + '.body')).write_bytes(raw)
  118. copied = {**receipt, 'responseFile': key + '.body', 'previousReceipt': str(file.relative_to(ROOT))}
  119. base.save(PUBLIC / (key + '.http.json'), copied)
  120. try:
  121. record = make_source(raw, copied, {'type': 'prior_preserved_original'}, True)
  122. base.save(PUBLIC / (key + '.source.json'), record)
  123. rows[url] = record
  124. except ValueError as error:
  125. STATE['rejected'].append({'url': url, 'reason': str(error), 'archiveReused': True})
  126. return rows
  127. def search(index, topic, query):
  128. key = 'query-' + str(index + 1).zfill(2)
  129. url = 'https://www.sogou.com/web?' + urllib.parse.urlencode({'query': query})
  130. raw, receipt = base.fetch(url, key, SEARCH)
  131. decoded, _ = decode(raw, receipt)
  132. root = html.fromstring(decoded)
  133. title = root.xpath('string(//title)')
  134. if receipt['httpStatus'] != 200 or re.search(r'验证|验证码|访问异常', title):
  135. raise ValueError('SEARCH_UNAVAILABLE_OR_VERIFICATION')
  136. rows = []
  137. for heading in root.xpath('//h3'):
  138. links = heading.xpath('.//a/@href')
  139. if not links: continue
  140. text = re.sub(r'\s+', ' ', heading.text_content()).strip()
  141. if re.search(r'高清大图|做法步骤|菜谱大全|价格表|批发|青团', text): continue
  142. rows.append({'topic': topic, 'query': query, 'title': text,
  143. 'url': urllib.parse.urljoin(receipt['finalUrl'], links[0]), 'searchReceipt': key + '.http.json'})
  144. base.save(SEARCH / (key + '.results.json'), {'query': query, 'receipt': receipt, 'results': rows})
  145. STATE['searches'].append({'query': query, 'topic': topic, 'candidateCount': len(rows), 'sha256': receipt['sha256']})
  146. return rows
  147. def collect(candidate):
  148. key = base.digest(candidate['url'].encode())[:24]
  149. cached = PUBLIC / (key + '.source.json')
  150. if cached.exists():
  151. result = json.loads(cached.read_bytes())
  152. raw = (PUBLIC / result['httpReceipt']['responseFile']).read_bytes()
  153. assert base.digest(raw) == result['httpReceipt']['sha256']
  154. return result
  155. raw, receipt = base.fetch(candidate['url'], key, PUBLIC)
  156. if receipt['httpStatus'] != 200: raise ValueError('HTTP_' + str(receipt['httpStatus']))
  157. raw, receipt = base.resolve(raw, receipt, key)
  158. if receipt['httpStatus'] != 200: raise ValueError('ORIGINAL_HTTP_' + str(receipt['httpStatus']))
  159. result = make_source(raw, receipt, candidate, False)
  160. base.save(cached, result)
  161. return result
  162. def main():
  163. prior = json.loads((ORIGINAL_WORK / 'reviewed-public-sources.json').read_bytes())
  164. existing = {r['url'] for r in prior['selected']}
  165. unique = archived_sources(existing)
  166. STATE['sources'] = list(unique.values())
  167. persist()
  168. print(json.dumps({'stage': 'archive', 'recoveredOriginalBodies': len(unique)}, ensure_ascii=False), flush=True)
  169. candidates = {}
  170. for index, (topic, query) in enumerate(QUERIES):
  171. try:
  172. for row in search(index, topic, query): candidates.setdefault(row['url'], row)
  173. except Exception as error:
  174. STATE['searches'].append({'query': query, 'topic': topic, 'error': str(error)})
  175. if str(error) == 'SEARCH_UNAVAILABLE_OR_VERIFICATION':
  176. STATE['searchStopReason'] = str(error)
  177. break
  178. persist()
  179. print(json.dumps({'stage': 'search', 'done': index + 1, 'candidates': len(candidates)}, ensure_ascii=False), flush=True)
  180. time.sleep(1.3)
  181. STATE['candidateCount'] = len(candidates)
  182. with ThreadPoolExecutor(max_workers=2) as pool:
  183. tasks = {pool.submit(collect, row): row for row in candidates.values()}
  184. for task in as_completed(tasks):
  185. row = tasks[task]
  186. try:
  187. result = task.result()
  188. if result['url'] not in existing: unique.setdefault(result['url'], result)
  189. except Exception as error:
  190. STATE['rejected'].append({'candidate': row, 'reason': str(error)})
  191. STATE['sources'] = list(unique.values())
  192. persist()
  193. print(json.dumps({'stage': 'body', 'originalBodies': len(unique), 'rejected': len(STATE['rejected'])}, ensure_ascii=False), flush=True)
  194. STATE['status'] = 'collection-complete-awaiting-semantic-review'
  195. STATE['finishedAt'] = base.now()
  196. STATE['uniqueSources'] = len(unique)
  197. persist()
  198. review = [{'index': index, 'url': row['url'], 'title': row['title'], 'date': row['publishedAtRaw'],
  199. 'body': row['body'], 'keywordTopics': row['topics']} for index, row in enumerate(STATE['sources'])]
  200. base.save(WORK / 'review-candidates.json', review)
  201. print(json.dumps({'status': STATE['status'], 'uniqueSources': len(unique)}, ensure_ascii=False), flush=True)
  202. if __name__ == '__main__': main()