collect-five-topic-public.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. """Collect readable public source pages, keeping search results out of evidence counts."""
  2. from concurrent.futures import ThreadPoolExecutor, as_completed
  3. from datetime import datetime, timezone
  4. import hashlib
  5. import json
  6. from pathlib import Path
  7. import re
  8. import threading
  9. import urllib.error
  10. import urllib.parse
  11. import urllib.request
  12. from lxml import html
  13. WORK = Path(__file__).resolve().parents[1] / 'work/five-topic-collection-20261110'
  14. PLAN = json.loads((WORK / 'collection-plan.json').read_bytes())
  15. PUBLIC = WORK / 'public-sources'
  16. PUBLIC.mkdir(exist_ok=True)
  17. SEARCH = WORK / 'public-search'
  18. QUERIES = {
  19. 'wellness': ['方便面 中式养生 当归', '方便面 药食同源', '菌菇汤 方便面 非油炸',
  20. '养生方便面 site:thepaper.cn', '方便面 菌菇 测评 site:post.smzdm.com'],
  21. 'student': ['大学生 宿舍 泡面 测评', '学生 夜宵 方便面', '宿舍 方便面 不用锅',
  22. '宿舍 泡面 site:post.smzdm.com', '方便面 夜宵 site:thepaper.cn'],
  23. 'premium': ['高端 方便面 真实肉块', '方便面 升级 满汉大餐', '方便面 中产 拉面说',
  24. '高端 方便面 site:thepaper.cn', '拉面说 满汉大餐 测评 site:post.smzdm.com'],
  25. 'stockpile': ['方便面 囤货 测评', '家庭 囤货 方便面 保质期', '方便面 整箱 组合装',
  26. '方便面 囤货 site:post.smzdm.com', '方便面 囤货 保质期 site:sohu.com'],
  27. 'novelty': ['方便面 联名 新品', '方便面 新口味 测评', '泡面 尝鲜 冬阴功 香菜',
  28. '方便面 联名 site:thepaper.cn', '方便面 新口味 测评 site:post.smzdm.com'],
  29. }
  30. NOODLES = re.compile(r'方便面|泡面|即食面|速食面|杯面|桶面|碗面|方便拌面|速食拌面|火鸡面')
  31. PLATFORMS = {'sohu.com': 'sohu', 'thepaper.cn': 'thepaper', 'smzdm.com': 'smzdm',
  32. 'zhihu.com': 'zhihu', 'weixin.qq.com': 'wechat', 'xinhuanet.com': 'xinhua',
  33. 'news.cn': 'xinhua', 'people.com.cn': 'people', 'chinanews.com.cn': 'chinanews',
  34. 'chinanews.com': 'chinanews', 'cctv.com': 'cctv', 'foodaily.com': 'foodaily',
  35. 'foodtalks.cn': 'foodtalks', 'cbndata.com': 'cbndata', 'jiemian.com': 'jiemian',
  36. 'stcn.com': 'stcn', 'yicai.com': 'yicai', 'cnr.cn': 'cnr', 'sina.com.cn': 'sina',
  37. '163.com': 'netease', 'news.qq.com': 'tencent'}
  38. STATE = {'schema': 'ksf.five-topic.public-collection.v1', 'status': 'running', 'startedAt': None,
  39. 'searches': [], 'sources': [], 'rejected': [], 'productionWrites': 0}
  40. GUARD = threading.Lock()
  41. def now(): return datetime.now(timezone.utc).isoformat()
  42. def digest(raw): return hashlib.sha256(raw).hexdigest()
  43. def save(path, data):
  44. temporary = path.with_suffix(path.suffix + '.next')
  45. temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf8')
  46. temporary.replace(path)
  47. def persist():
  48. STATE['updatedAt'] = now()
  49. save(WORK / 'public-collection-result.json', STATE)
  50. def platform(url):
  51. host = urllib.parse.urlparse(url).hostname or ''
  52. return next((value for suffix, value in PLATFORMS.items() if host == suffix or host.endswith('.' + suffix)), None)
  53. def fetch(url, name, target):
  54. previous = target / (name + '.http.json')
  55. if previous.exists():
  56. receipt = json.loads(previous.read_bytes())
  57. body = target / receipt['responseFile']
  58. if receipt['requestedUrl'] == url and body.exists() and digest(body.read_bytes()) == receipt['sha256']:
  59. return body.read_bytes(), receipt
  60. request = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
  61. try:
  62. with urllib.request.urlopen(request, timeout=25) as response:
  63. raw = response.read(6000001)
  64. status, final, content_type = response.status, response.url, response.headers.get('Content-Type', '')
  65. except urllib.error.HTTPError as error:
  66. raw, status, final, content_type = error.read(6000001), error.code, error.url, error.headers.get('Content-Type', '')
  67. if len(raw) > 6000000: raise ValueError('SOURCE_TOO_LARGE')
  68. body = name + '.body'
  69. (target / body).write_bytes(raw)
  70. receipt = {'requestedUrl': url, 'finalUrl': final, 'httpStatus': status, 'contentType': content_type,
  71. 'collectedAt': now(), 'responseFile': body, 'bytes': len(raw), 'sha256': digest(raw)}
  72. save(target / (name + '.http.json'), receipt)
  73. return raw, receipt
  74. def search(topic, index, query):
  75. key = topic + '-sogou-' + str(index + 1)
  76. file = SEARCH / (key + '.results.json')
  77. if file.exists(): return json.loads(file.read_bytes())
  78. url = 'https://www.sogou.com/web?' + urllib.parse.urlencode({'query': query})
  79. raw, receipt = fetch(url, key, SEARCH)
  80. rows = []
  81. if receipt['httpStatus'] == 200:
  82. root = html.fromstring(raw)
  83. for heading in root.xpath('//h3'):
  84. links = heading.xpath('.//a[@href]')
  85. if not links: continue
  86. title = re.sub(r'\s+', ' ', heading.text_content()).strip()
  87. # Search snippets alone never count as source bodies.
  88. if not title or len(title) < 6: continue
  89. rows.append({'title': title, 'url': urllib.parse.urljoin(receipt['finalUrl'], links[0].get('href')),
  90. 'topic': topic, 'query': query, 'searchReceipt': key + '.http.json'})
  91. result = {'topic': topic, 'query': query, 'receipt': receipt, 'results': rows}
  92. save(file, result)
  93. return result
  94. def resolve(raw, receipt, key):
  95. for step in range(2):
  96. host = urllib.parse.urlparse(receipt['finalUrl']).hostname or ''
  97. if not host.endswith('sogou.com'): return raw, receipt
  98. root = html.fromstring(raw)
  99. urls = root.xpath('//meta[translate(@http-equiv,"REFSH","refsh")="refresh"]/@content')
  100. target = None
  101. for value in urls:
  102. found = re.search(r'url\s*=\s*[\'\"]?(https?://[^\'\"\s>]+)', value, re.I)
  103. if found: target = found.group(1)
  104. if not target:
  105. text = raw.decode('utf8', errors='replace')
  106. found = re.search(r'(?:location\.(?:replace|assign)\s*\(|(?:window\.)?location(?:\.href)?\s*=\s*)[\'\"](https?://[^\'\"\s]+)', text)
  107. if found: target = found.group(1).replace('\\/', '/')
  108. if not target: raise ValueError('SEARCH_REDIRECT_NOT_ORIGINAL_PAGE')
  109. raw, receipt = fetch(target, key + '-redirect-' + str(step + 1), PUBLIC)
  110. return raw, receipt
  111. def paragraphs(node):
  112. clone = html.fromstring(html.tostring(node, encoding='unicode'))
  113. for element in clone.xpath('.//script|.//style|.//noscript|.//nav|.//footer|.//form|.//button|.//*[contains(concat(" ",normalize-space(@class)," ")," recommend-tab ")]'):
  114. element.drop_tree()
  115. nodes = clone.xpath('.//p|.//h1|.//h2|.//h3|.//blockquote|.//li')
  116. chunks = [re.sub(r'\s+', ' ', element.text_content()).strip() for element in nodes]
  117. chunks = [part for part in chunks if part]
  118. if not chunks: chunks = [re.sub(r'\s+', ' ', clone.text_content()).strip()]
  119. return '\n'.join(dict.fromkeys(chunks))
  120. def extract(raw, receipt):
  121. declared = re.search(r'charset\s*=\s*([^;\s]+)', receipt.get('contentType', ''), re.I)
  122. encoding = declared.group(1).strip('"\'') if declared else 'utf8'
  123. try: decoded = raw.decode(encoding)
  124. except (LookupError, UnicodeDecodeError):
  125. try: decoded = raw.decode('utf8'); encoding = 'utf8'
  126. except UnicodeDecodeError: decoded = raw.decode('gb18030'); encoding = 'gb18030'
  127. root = html.fromstring(decoded)
  128. # Never treat a verification/login response as an article.
  129. title = ''.join(root.xpath('//meta[@property="og:title"]/@content')[:1]) or root.xpath('string(//title)')
  130. title = re.sub(r'\s+', ' ', title).strip()
  131. if re.search(r'安全验证|访问验证|验证码|系统繁忙|访问异常|扫码登录|页面不存在|404|没有找到', title, re.I):
  132. raise ValueError('VERIFICATION_OR_ERROR_PAGE')
  133. selectors = [
  134. '//*[@id="js_content"]', '//*[@id="artibody"]',
  135. '//*[contains(concat(" ",normalize-space(@class)," ")," article-content ")]',
  136. '//*[contains(concat(" ",normalize-space(@class)," ")," article_content ")]',
  137. '//*[contains(concat(" ",normalize-space(@class)," ")," article-main ")]',
  138. '//*[@id="detail_content"]', '//*[@id="article-content"]',
  139. '//*[contains(concat(" ",normalize-space(@class)," ")," rich_media_content ")]',
  140. '//article', '//*[@itemprop="articleBody"]',
  141. '//*[contains(concat(" ",normalize-space(@class)," ")," article_content_wrapper ")]',
  142. '//*[contains(concat(" ",normalize-space(@class)," ")," post_text ")]',
  143. '//*[contains(concat(" ",normalize-space(@class)," ")," entry-content ")]',
  144. ]
  145. body, selector = None, None
  146. for candidate in selectors:
  147. texts = [paragraphs(node) for node in root.xpath(candidate)]
  148. texts = [value for value in texts if len(value) >= 300]
  149. if texts: body, selector = max(texts, key=len), candidate; break
  150. if not body: raise ValueError('ARTICLE_BODY_NOT_LOCATED')
  151. if not NOODLES.search(title + '\n' + body): raise ValueError('NOT_PACKAGED_NOODLE_CONTENT')
  152. date_values = root.xpath('//meta[@property="article:published_time" or @name="pubdate" or @name="publishdate" or @name="publish-date" or @name="PublishDate" or @name="weibo:article:create_at"]/@content')
  153. dates = [value for value in date_values if re.search(r'\d{4}[-/]\d{1,2}[-/]\d{1,2}', value)]
  154. if not dates:
  155. headers = root.xpath('//article')
  156. header = headers[0].text_content()[:1100] if headers else ''
  157. visible = re.search(r'\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?', header)
  158. if visible: dates = [visible.group(0)]
  159. return title, body, selector, dates[0] if dates else None, encoding
  160. def collect(candidate):
  161. key = digest(candidate['url'].encode())[:24]
  162. record_file = PUBLIC / (key + '.source.json')
  163. if record_file.exists(): return json.loads(record_file.read_bytes())
  164. raw, receipt = fetch(candidate['url'], key, PUBLIC)
  165. if receipt['httpStatus'] != 200: raise ValueError('HTTP_' + str(receipt['httpStatus']))
  166. raw, receipt = resolve(raw, receipt, key)
  167. site = platform(receipt['finalUrl'])
  168. if not site: raise ValueError('UNSUPPORTED_OR_SEARCH_RESULT_HOST')
  169. title, body, selector, published, encoding = extract(raw, receipt)
  170. source_topics = []
  171. for topic in PLAN['topics']:
  172. hit = [term for term in topic['keywords'] if term in title + '\n' + body]
  173. if not hit: continue
  174. passages = []
  175. for term in hit:
  176. start = body.find(term)
  177. if start >= 0: passages.append(body[max(0, start - 65):start + 150])
  178. source_topics.append({'topic': topic['id'], 'matchedTerms': hit, 'passages': passages[:5]})
  179. if not source_topics: raise ValueError('NO_REQUESTED_TOPIC_IN_SOURCE_BODY')
  180. # Drop referrer/tracking fields from the identity; preserve the exact URL in the receipt.
  181. parsed = urllib.parse.urlparse(receipt['finalUrl'])
  182. query = urllib.parse.parse_qs(parsed.query)
  183. stable_query = urllib.parse.urlencode({k: query[k][0] for k in ['__biz', 'mid', 'idx'] if k in query})
  184. canonical = urllib.parse.urlunparse((parsed.scheme, parsed.netloc, parsed.path, '', stable_query, ''))
  185. record = {'schema': 'ksf.five-topic.public-source.v1', 'sourceType': 'public_article_not_ecommerce_review',
  186. 'sourceId': 'url-sha256:' + digest(canonical.encode()), 'identityBasis': 'canonical_original_url_sha256',
  187. 'platform': site, 'title': title, 'url': canonical, 'originalResolvedUrl': receipt['finalUrl'],
  188. 'publishedAtRaw': published, 'collectedAt': receipt['collectedAt'], 'body': body,
  189. 'bodySha256': digest(body.encode()), 'bodyExtractionSelector': selector, 'sourceEncoding': encoding, 'httpReceipt': receipt,
  190. 'searchDiscovery': candidate, 'topics': source_topics,
  191. 'verification': 'readable_original_article_with_preserved_http_bytes',
  192. 'publishedToDatabase': False,
  193. 'limits': ['原作者/媒体观点,不当作电商购买评价', '文中健康或市场数字未在此轮独立验证',
  194. '保留原文字段与原件,不将采集时刻写成发布时间']}
  195. save(record_file, record)
  196. return record
  197. def main():
  198. STATE['startedAt'] = now()
  199. candidates = {}
  200. for topic, queries in QUERIES.items():
  201. for index, query in enumerate(queries):
  202. try:
  203. result = search(topic, index, query)
  204. STATE['searches'].append({'topic': topic, 'query': query, 'candidateCount': len(result['results']),
  205. 'sha256': result['receipt']['sha256']})
  206. # Prefer text articles and actual experiences, excluding obvious recipe/video/category pages.
  207. filtered = [row for row in result['results'] if not re.search(r'做法步骤|菜谱大全|怎么做好吃|视频大全|中毒|健康知识|价格表|批发价格', row['title'])]
  208. for row in filtered[:10]: candidates.setdefault(row['url'], row)
  209. except Exception as error:
  210. STATE['searches'].append({'topic': topic, 'query': query, 'error': type(error).__name__})
  211. persist()
  212. print(json.dumps({'stage': 'search', 'topic': topic, 'query': query, 'candidates': len(candidates)}, ensure_ascii=False), flush=True)
  213. with ThreadPoolExecutor(max_workers=3) as pool:
  214. tasks = {pool.submit(collect, row): row for row in candidates.values()}
  215. for task in as_completed(tasks):
  216. candidate = tasks[task]
  217. try:
  218. record = task.result()
  219. with GUARD: STATE['sources'].append(record)
  220. except Exception as error:
  221. with GUARD: STATE['rejected'].append({'candidate': candidate, 'reason': str(error) if isinstance(error, ValueError) else type(error).__name__})
  222. with GUARD: persist()
  223. print(json.dumps({'stage': 'body', 'accepted': len(STATE['sources']), 'rejected': len(STATE['rejected']),
  224. 'candidates': len(candidates)}, ensure_ascii=False), flush=True)
  225. unique = {}
  226. for row in STATE['sources']:
  227. key = row['sourceId']
  228. if key in unique:
  229. known = {item['topic'] for item in unique[key]['topics']}
  230. unique[key]['topics'].extend(item for item in row['topics'] if item['topic'] not in known)
  231. else: unique[key] = row
  232. STATE['sources'] = list(unique.values())
  233. STATE['status'] = 'complete'
  234. STATE['finishedAt'] = now()
  235. STATE['uniqueSources'] = len(STATE['sources'])
  236. STATE['countsByTopic'] = {topic['id']: sum(any(item['topic'] == topic['id'] for item in row['topics']) for row in STATE['sources']) for topic in PLAN['topics']}
  237. STATE['databaseImported'] = 0
  238. persist()
  239. print(json.dumps({'status': STATE['status'], 'uniqueSources': STATE['uniqueSources'],
  240. 'countsByTopic': STATE['countsByTopic'], 'databaseImported': 0}, ensure_ascii=False), flush=True)
  241. if __name__ == '__main__': main()