collect-five-topic-industry-supplement.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. """Collect public industry articles via their publisher's ordinary search form."""
  2. from concurrent.futures import ThreadPoolExecutor, as_completed
  3. from datetime import datetime
  4. import importlib.util
  5. import json
  6. from pathlib import Path
  7. import re
  8. import time
  9. import urllib.parse
  10. from lxml import html
  11. ROOT = Path(__file__).resolve().parents[1]
  12. WORK = ROOT / 'work/five-topic-collection-20261110/supplement-01'
  13. PUBLIC = WORK / 'industry-sources'
  14. SEARCH = WORK / 'industry-search'
  15. PUBLIC.mkdir(exist_ok=True)
  16. SEARCH.mkdir(exist_ok=True)
  17. spec = importlib.util.spec_from_file_location('public_base', ROOT / 'scripts/collect-five-topic-public.py')
  18. base = importlib.util.module_from_spec(spec)
  19. spec.loader.exec_module(base)
  20. JOBS = [('方便面', page) for page in range(1, 5)] + [
  21. ('非油炸', 1), ('菌菇', 1), ('囤货', 1), ('拉面', 1), ('五谷道场', 1), ('养生', 1)]
  22. STATE = {'schema': 'ksf.five-topic.industry-supplement.v1', 'status': 'running',
  23. 'startedAt': base.now(), 'searches': [], 'sources': [], 'rejected': [], 'productionWrites': 0}
  24. def persist():
  25. STATE['updatedAt'] = base.now()
  26. base.save(WORK / 'industry-collection-result.json', STATE)
  27. def search(index, query, page):
  28. key = 'search-' + str(index + 1).zfill(2)
  29. url = 'https://www.foodaily.com/search/articles?' + urllib.parse.urlencode({'search': query, 'page': page})
  30. raw, receipt = base.fetch(url, key, SEARCH)
  31. if receipt['httpStatus'] != 200: raise ValueError('SEARCH_HTTP_' + str(receipt['httpStatus']))
  32. root = html.fromstring(raw.decode('utf8'))
  33. if re.search(r'验证|访问异常', root.xpath('string(//title)')): raise ValueError('SEARCH_VERIFICATION')
  34. rows = {}
  35. for anchor in root.xpath('//a[@href]'):
  36. link, title = anchor.get('href'), re.sub(r'\s+', ' ', anchor.text_content()).strip()
  37. if not re.fullmatch(r'https://www\.foodaily\.com/articles/\d+', link) or not title: continue
  38. # Publisher search results, not evidence. Read each selected actual article.
  39. if not re.search(r'方便面|泡面|速食|拉面|非油炸|面条|汤面|面饼|拌面|高汤|菌菇面', title): continue
  40. if re.search(r'冰淇淋|奶茶|生鲜传奇|咖啡|可口可乐|茶饮', title) and not re.search(r'泡面|面条|速食面|方便面|拉面', title): continue
  41. rows[link] = {'url': link, 'title': title, 'query': query, 'page': page,
  42. 'searchUrl': url, 'searchReceipt': key + '.http.json'}
  43. record = {'query': query, 'page': page, 'httpReceipt': receipt, 'results': list(rows.values())}
  44. base.save(SEARCH / (key + '.results.json'), record)
  45. STATE['searches'].append({'query': query, 'page': page, 'count': len(rows), 'sha256': receipt['sha256']})
  46. return list(rows.values())
  47. def extract(raw, receipt):
  48. title, body, selector, date, encoding = base.extract(raw, receipt)
  49. root = html.fromstring(raw.decode(encoding))
  50. if not date:
  51. dates = [re.sub(r'\s+', ' ', node.text_content()).strip() for node in root.xpath('//*[contains(concat(" ",normalize-space(@class)," ")," float-right ")]')]
  52. dates = [value for value in dates if re.fullmatch(r'\d{4}\.\d{2}\.\d{2}', value)]
  53. date = dates[0] if len(set(dates)) == 1 else None
  54. if date:
  55. normal = date[:10].replace('.', '-').replace('/', '-')
  56. if normal > datetime.now().strftime('%Y-%m-%d'): raise ValueError('FUTURE_PUBLICATION_DATE')
  57. return title, body, selector, date, encoding
  58. def collect(candidate):
  59. key = base.digest(candidate['url'].encode())[:24]
  60. cached = PUBLIC / (key + '.source.json')
  61. if cached.exists():
  62. row = json.loads(cached.read_bytes())
  63. assert base.digest((PUBLIC / row['httpReceipt']['responseFile']).read_bytes()) == row['httpReceipt']['sha256']
  64. return row
  65. raw, receipt = base.fetch(candidate['url'], key, PUBLIC)
  66. if receipt['httpStatus'] != 200: raise ValueError('SOURCE_HTTP_' + str(receipt['httpStatus']))
  67. if not re.fullmatch(r'https://www\.foodaily\.com/articles/\d+', receipt['finalUrl']):
  68. raise ValueError('NOT_REQUESTED_PUBLISHER_ARTICLE')
  69. title, body, selector, published, encoding = extract(raw, receipt)
  70. topics = []
  71. for topic in base.PLAN['topics']:
  72. hits = [term for term in topic['keywords'] if term in title + '\n' + body]
  73. passages = []
  74. for term in hits:
  75. positions = [m.start() for m in re.finditer(re.escape(term), body)]
  76. for start in positions[:2]: passages.append({'term': term, 'text': body[max(0, start - 90):start + 180]})
  77. if hits: topics.append({'topic': topic['id'], 'matchedTerms': hits, 'passages': passages[:8]})
  78. row = {'schema': 'ksf.five-topic.public-source.v1', 'platform': 'foodaily',
  79. 'sourceType': 'public_industry_article_not_consumer_review',
  80. 'sourceId': 'url-sha256:' + base.digest(receipt['finalUrl'].encode()),
  81. 'url': receipt['finalUrl'], 'title': title, 'body': body, 'bodySha256': base.digest(body.encode()),
  82. 'publishedAtRaw': published, 'collectedAt': receipt['collectedAt'], 'httpReceipt': receipt,
  83. 'sourceEncoding': encoding, 'bodyExtractionSelector': selector, 'topics': topics,
  84. 'searchDiscovery': candidate, 'publishedToDatabase': False,
  85. 'limits': ['行业媒体文章,含转载时保留文内来源署名,不冒称独立消费者或购买评价',
  86. '报道中的统计、功效、制造工艺和价格仅为报道声明,未在本轮逐一独立验证',
  87. '原文保留原日期;堂食/鲜面段落不作为包装即食面产品事实']}
  88. base.save(cached, row)
  89. return row
  90. def main():
  91. candidates = {}
  92. for index, (query, page) in enumerate(JOBS):
  93. try:
  94. for row in search(index, query, page): candidates.setdefault(row['url'], row)
  95. except Exception as error:
  96. STATE['searches'].append({'query': query, 'page': page, 'error': str(error)})
  97. if str(error) == 'SEARCH_VERIFICATION': break
  98. persist()
  99. print(json.dumps({'stage': 'publisher-search', 'completed': index + 1, 'candidates': len(candidates)}, ensure_ascii=False), flush=True)
  100. time.sleep(1)
  101. STATE['candidateCount'] = len(candidates)
  102. with ThreadPoolExecutor(max_workers=2) as pool:
  103. tasks = {pool.submit(collect, row): row for row in candidates.values()}
  104. for task in as_completed(tasks):
  105. candidate = tasks[task]
  106. try: STATE['sources'].append(task.result())
  107. except Exception as error: STATE['rejected'].append({'candidate': candidate, 'reason': str(error)})
  108. persist()
  109. print(json.dumps({'stage': 'publisher-body', 'sources': len(STATE['sources']), 'rejected': len(STATE['rejected'])}, ensure_ascii=False), flush=True)
  110. STATE['status'] = 'collection-complete-awaiting-semantic-review'
  111. STATE['finishedAt'] = base.now()
  112. STATE['uniqueSources'] = len(STATE['sources'])
  113. persist()
  114. reviews = []
  115. for row in STATE['sources']:
  116. reviews.append({'url': row['url'], 'title': row['title'], 'date': row['publishedAtRaw'],
  117. 'length': len(row['body']), 'start': row['body'][:450], 'topics': row['topics']})
  118. base.save(WORK / 'industry-review-candidates.json', reviews)
  119. print(json.dumps({'status': STATE['status'], 'sources': len(reviews)}, ensure_ascii=False), flush=True)
  120. if __name__ == '__main__': main()