collect-five-topic-round3.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. """Supplement sparse topics using precise original pages and publisher search.
  2. Reuse preserved discovery receipts, stop a host when a challenge page appears,
  3. and never count search snippets or recommended content as original evidence.
  4. """
  5. from datetime import datetime
  6. import importlib.util
  7. import json
  8. from pathlib import Path
  9. import re
  10. import time
  11. import urllib.parse
  12. from lxml import html
  13. ROOT = Path(__file__).resolve().parents[1]
  14. PARENT = ROOT / 'work/five-topic-collection-20261110'
  15. WORK = PARENT / 'supplement-02'
  16. PUBLIC = WORK / 'sources'
  17. SEARCH = WORK / 'search'
  18. for directory in [WORK, PUBLIC, SEARCH]: directory.mkdir(parents=True, exist_ok=True)
  19. spec = importlib.util.spec_from_file_location('public_base_round3', ROOT / 'scripts/collect-five-topic-public.py')
  20. base = importlib.util.module_from_spec(spec)
  21. spec.loader.exec_module(base)
  22. spec = importlib.util.spec_from_file_location('industry_base_round3', ROOT / 'scripts/collect-five-topic-industry-supplement.py')
  23. industry = importlib.util.module_from_spec(spec)
  24. spec.loader.exec_module(industry)
  25. QUERIES = ['中式养生', '药食同源', '草本', '拉面说', '保质期', '五连包', '组合装', '家庭装']
  26. DISCOVERY = [('wellness', '人参 老母鸡 方便面 测评'),
  27. ('stockpile', '方便面 囤货 保质期 测评'),
  28. ('wellness', '当归 花雕鸡 面 统一'),
  29. ('premium', '速达面馆 牛肉 测评')]
  30. STATE = {'schema': 'ksf.five-topic.round3-collection.v1', 'startedAt': base.now(), 'status': 'running',
  31. 'searches': [], 'sources': [], 'rejected': [], 'hostStops': {}, 'productionWrites': 0}
  32. def persist():
  33. STATE['updatedAt'] = base.now()
  34. base.save(WORK / 'collection-result.json', STATE)
  35. def existing_urls():
  36. rows = []
  37. for name in ['five-topics-public-01', 'five-topics-public-02']:
  38. rows.extend(json.loads((ROOT / 'work/database-enrichment-20261110/batches' / name / 'expected-records.json').read_bytes()))
  39. return {row['url'] for row in rows}
  40. def capture(url, name, target):
  41. # Reuse a prior exact response if present in this round's initial probes.
  42. for receipt_file in (WORK / 'probes').glob('*.http.json'):
  43. receipt = json.loads(receipt_file.read_bytes())
  44. if receipt['requestedUrl'] == url and receipt['httpStatus'] == 200:
  45. raw = (receipt_file.parent / receipt['responseFile']).read_bytes()
  46. assert base.digest(raw) == receipt['sha256']
  47. copied = {**receipt, 'responseFile': name + '.body',
  48. 'reusedReceipt': str(receipt_file.relative_to(ROOT))}
  49. (target / copied['responseFile']).write_bytes(raw)
  50. base.save(target / (name + '.http.json'), copied)
  51. return raw, copied
  52. return base.fetch(url, name, target)
  53. def challenge(raw, receipt, root):
  54. title = root.xpath('string(//title)')
  55. if re.search(r'/geetest|captcha|/verify', receipt.get('finalUrl', ''), re.I): return True
  56. if receipt['httpStatus'] in (202, 403, 412, 429) or re.search(r'安全验证|访问验证|验证码|访问异常|验证中心', title): return True
  57. if len(raw) < 10000 and re.search(rb'aliyun|captcha|acw_sc__|var seqid\s*=', raw, re.I): return True
  58. return False
  59. def collect(url, discovery):
  60. host = urllib.parse.urlparse(url).hostname
  61. if host in STATE['hostStops']: raise ValueError('HOST_STOPPED_AFTER_CHALLENGE')
  62. key = base.digest(url.encode())[:24]
  63. raw, receipt = capture(url, key, PUBLIC)
  64. root = html.fromstring(raw.decode('utf8', errors='replace'))
  65. if challenge(raw, receipt, root):
  66. STATE['hostStops'][host] = {'reason': 'VERIFICATION_RESPONSE', 'url': url,
  67. 'httpStatus': receipt['httpStatus'], 'sha256': receipt['sha256']}
  68. raise ValueError('VERIFICATION_RESPONSE')
  69. if receipt['httpStatus'] != 200: raise ValueError('HTTP_' + str(receipt['httpStatus']))
  70. date = None
  71. if host == 'post.smzdm.com':
  72. match = re.fullmatch(r'https://post\.smzdm\.com/(?:talk/)?p/([a-z0-9]+)/', receipt['finalUrl'])
  73. if not match: raise ValueError('CANONICAL_ORIGINAL_POST_REQUIRED')
  74. platform, pid = 'smzdm', match.group(1)
  75. selector = '//article'
  76. nodes = root.xpath(selector)
  77. dates = root.xpath('//meta[@property="article:published_time"]/@content')
  78. if not dates and len(nodes) == 1:
  79. match_date = re.search(r'\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?', nodes[0].text_content()[:1000])
  80. dates = [match_date.group(0)] if match_date else []
  81. date = dates[0] if dates else None
  82. elif host == 'sspai.com':
  83. match = re.fullmatch(r'https://sspai\.com/post/(\d+)', receipt['finalUrl'])
  84. if not match: raise ValueError('CANONICAL_ORIGINAL_POST_REQUIRED')
  85. platform, pid = 'sspai', match.group(1)
  86. selector = '//*[contains(concat(" ",normalize-space(@class)," ")," article-body ")]'
  87. nodes = root.xpath(selector)
  88. dates = root.xpath('//*[contains(concat(" ",normalize-space(@class)," ")," article__header__date ")]/text()')
  89. date = dates[0].strip() if dates else None
  90. elif host == 'www.foodaily.com':
  91. title, body, selector, date, encoding = industry.extract(raw, receipt)
  92. platform, pid = 'foodaily', urllib.parse.urlparse(url).path.split('/')[-1]
  93. nodes = root.xpath(selector)
  94. else: raise ValueError('PUBLISHER_NOT_IN_CONTROLLED_EXTRACTORS')
  95. if len(nodes) != 1: raise ValueError('UNIQUE_ORIGINAL_BODY_REQUIRED')
  96. body = base.paragraphs(nodes[0])
  97. if len(body) < 60: raise ValueError('INSUFFICIENT_SOURCE_BODY')
  98. if not re.search(r'方便面|泡面|速食面|拉面说|速达面馆|满汉|即食面', body): raise ValueError('NOT_PACKAGED_NOODLE_CONTENT')
  99. title = root.xpath('string(//title)').strip()
  100. h1 = root.xpath('//article//h1')
  101. if h1: title = re.sub(r'\s+', ' ', h1[0].text_content()).strip() or title
  102. if not date: raise ValueError('ORIGINAL_DATE_NOT_LOCATED')
  103. date_match = re.search(r'(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})', date)
  104. if date_match:
  105. normal = '-'.join([date_match.group(1), date_match.group(2).zfill(2), date_match.group(3).zfill(2)])
  106. if normal > datetime.now().strftime('%Y-%m-%d'): raise ValueError('FUTURE_PUBLICATION_DATE')
  107. row = {'schema': 'ksf.five-topic.public-source.v1', 'platform': platform, 'postId': pid,
  108. 'sourceId': platform + ':' + pid, 'sourceType': 'public_industry_article_not_consumer_review' if platform == 'foodaily' else 'public_article_not_ecommerce_review',
  109. 'url': receipt['finalUrl'], 'title': title, 'body': body, 'bodySha256': base.digest(body.encode()),
  110. 'publishedAtRaw': date, 'collectedAt': receipt['collectedAt'], 'httpReceipt': receipt,
  111. 'sourceEncoding': 'utf8', 'bodyExtractionSelector': selector, 'collectionDirectory': 'sources',
  112. 'searchDiscovery': discovery, 'topics': [], 'publishedToDatabase': False,
  113. 'limits': ['保留作者、媒体原文和日期;不等同独立购买评价或已验证消费者身份',
  114. '原文报价、营养、工艺及功效声明未独立验证,不写为当前报价或功效结论',
  115. '原文可能含推广或转载;独立URL不等于独立证据,关联主题需按实际段落复核']}
  116. base.save(PUBLIC / (key + '.source.json'), row)
  117. return row
  118. def prior_discoveries():
  119. old = PARENT / 'supplement-01'
  120. finals = {}
  121. for file in (old / 'public-sources').glob('*redirect-*.http.json'):
  122. row = json.loads(file.read_bytes())
  123. if 'post.smzdm.com/' in row['finalUrl']:
  124. finals[file.stem.split('-redirect')[0]] = row['finalUrl']
  125. result = {}
  126. for index in [2, 3, 4]:
  127. file = old / 'search' / ('query-' + str(index).zfill(2) + '.results.json')
  128. for row in json.loads(file.read_bytes())['results']:
  129. key = base.digest(row['url'].encode())[:24]
  130. url = finals.get(key)
  131. if not url or not re.fullmatch(r'https://post\.smzdm\.com/(?:talk/)?p/([a-z0-9]+)/?', url): continue
  132. if not url.endswith('/'): url += '/'
  133. # Discard general emergency stock and non-noodle alternatives at discovery.
  134. if re.search(r'方便面终结者|多囤5样|2千字', row['title']): continue
  135. result[url] = {**row, 'previousDiscoveryReceipt': str(file.relative_to(ROOT))}
  136. return result
  137. def publisher_search(index, query):
  138. url = 'https://www.foodaily.com/search/articles?' + urllib.parse.urlencode({'search': query})
  139. key = 'publisher-' + str(index + 1).zfill(2)
  140. raw, receipt = base.fetch(url, key, SEARCH)
  141. root = html.fromstring(raw.decode('utf8'))
  142. if challenge(raw, receipt, root): raise ValueError('PUBLISHER_SEARCH_VERIFICATION')
  143. candidates = {}
  144. for anchor in root.xpath('//a[@href]'):
  145. link = anchor.get('href')
  146. title = re.sub(r'\s+', ' ', anchor.text_content()).strip()
  147. if not re.fullmatch(r'https://www\.foodaily\.com/articles/\d+', link) or not title: continue
  148. if not re.search(r'泡面|方便面|拉面|面饼|速食|面条|汤面|拌面', title): continue
  149. candidates[link] = {'url': link, 'title': title, 'query': query, 'receipt': key + '.http.json'}
  150. base.save(SEARCH / (key + '.results.json'), {'query': query, 'receipt': receipt, 'results': list(candidates.values())})
  151. STATE['searches'].append({'kind': 'publisher-search', 'query': query, 'count': len(candidates), 'sha256': receipt['sha256']})
  152. return candidates
  153. def general_search(index, topic, query):
  154. url = 'https://www.so.com/s?' + urllib.parse.urlencode({'q': query})
  155. key = 'web-' + str(index + 1).zfill(2)
  156. raw, receipt = base.fetch(url, key, SEARCH)
  157. root = html.fromstring(raw.decode('utf8'))
  158. if challenge(raw, receipt, root): raise ValueError('WEB_SEARCH_VERIFICATION')
  159. result = []
  160. for anchor in root.xpath('//a[@data-mdurl]'):
  161. link, title = anchor.get('data-mdurl'), re.sub(r'\s+', ' ', anchor.text_content()).strip()
  162. if not link or len(title) < 8: continue
  163. if re.fullmatch(r'https://(?:post\.smzdm\.com/(?:talk/)?p/[a-z0-9]+/?|sspai\.com/post/\d+)', link):
  164. result.append({'url': link if 'sspai.com' in link or link.endswith('/') else link + '/',
  165. 'title': title, 'topic': topic, 'query': query, 'receipt': key + '.http.json'})
  166. base.save(SEARCH / (key + '.results.json'), {'query': query, 'receipt': receipt, 'results': result})
  167. STATE['searches'].append({'kind': 'web-search', 'query': query, 'count': len(result), 'sha256': receipt['sha256']})
  168. return result
  169. def main():
  170. existing = existing_urls()
  171. candidates = prior_discoveries()
  172. candidates['https://sspai.com/post/73701'] = {'type': 'specific_original_review', 'topic': 'wellness'}
  173. for index, (topic, query) in enumerate(DISCOVERY):
  174. try:
  175. for row in general_search(index, topic, query): candidates.setdefault(row['url'], row)
  176. except Exception as error: STATE['rejected'].append({'query': query, 'reason': str(error)})
  177. persist()
  178. time.sleep(1.3)
  179. for index, query in enumerate(QUERIES):
  180. try: candidates.update(publisher_search(index, query))
  181. except Exception as error: STATE['rejected'].append({'query': query, 'reason': str(error)})
  182. persist()
  183. time.sleep(1.1)
  184. # Aim at sparse topics first. A host's verification response stops it.
  185. candidates = {url: row for url, row in candidates.items() if url not in existing}
  186. STATE['candidateCount'] = len(candidates)
  187. print(json.dumps({'stage': 'discovery', 'newCandidates': len(candidates)}, ensure_ascii=False), flush=True)
  188. for index, (url, discovery) in enumerate(candidates.items()):
  189. try:
  190. result = collect(url, discovery)
  191. STATE['sources'].append(result)
  192. except Exception as error: STATE['rejected'].append({'url': url, 'reason': str(error)})
  193. persist()
  194. print(json.dumps({'stage': 'body', 'processed': index + 1, 'target': len(candidates),
  195. 'sources': len(STATE['sources']), 'rejected': len(STATE['rejected'])}, ensure_ascii=False), flush=True)
  196. time.sleep(1.5)
  197. STATE['status'] = 'collection-complete-awaiting-semantic-review'
  198. STATE['finishedAt'] = base.now()
  199. STATE['uniqueSources'] = len(STATE['sources'])
  200. persist()
  201. base.save(WORK / 'review-candidates.json', [{'url': r['url'], 'platform': r['platform'], 'title': r['title'],
  202. 'date': r['publishedAtRaw'], 'body': r['body']} for r in STATE['sources']])
  203. print(json.dumps({'status': STATE['status'], 'sources': len(STATE['sources']), 'hostStops': STATE['hostStops']}, ensure_ascii=False), flush=True)
  204. if __name__ == '__main__': main()