collect_douyin.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. # -*- coding: utf-8 -*-
  2. """
  3. 抖音门店公开信息采集器 v0.1
  4. 不装任何第三方库,直接调用本机 Chrome 无头模式,读取渲染后的页面。
  5. 只采集前台公开可见的信息(评分、评价数、团购商品与已售数),不登录、不破解、不采个人信息。
  6. 用法(在 对标试点 目录下执行):
  7. python 02_采集脚本/collect_douyin.py --dump
  8. 读取 01_对标店清单.csv,逐个打开门店链接,把渲染后的页面存到 02_采集脚本/dump/
  9. 每页间隔约 4 秒,控制频率
  10. python 02_采集脚本/collect_douyin.py --parse
  11. 解析 dump/ 里已保存的页面,生成 03_分析/采集快照_<日期>.csv
  12. 注意: 解析规则需要先用 1-2 个真实页面校准,第一次跑请只放 1-2 条链接
  13. """
  14. import csv
  15. import glob
  16. import hashlib
  17. import os
  18. import re
  19. import subprocess
  20. import sys
  21. import time
  22. from datetime import date
  23. BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  24. DUMP_DIR = os.path.join(BASE, '02_采集脚本', 'dump')
  25. LIST_CSV = os.path.join(BASE, '01_对标店清单.csv')
  26. SNAP_DIR = os.path.join(BASE, '03_分析')
  27. CHROME_CANDIDATES = [
  28. r'C:\Program Files\Google\Chrome\Application\chrome.exe',
  29. r'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe',
  30. ]
  31. CHROME = next((p for p in CHROME_CANDIDATES if os.path.exists(p)), None)
  32. def load_links():
  33. """从清单里取出所有不重复的门店链接"""
  34. if not os.path.exists(LIST_CSV):
  35. sys.exit('找不到 01_对标店清单.csv')
  36. seen, out = set(), []
  37. with open(LIST_CSV, encoding='utf-8-sig') as f:
  38. for r in csv.DictReader(f):
  39. for key in ('本店POI链接', '竞对POI链接'):
  40. link = (r.get(key) or '').strip()
  41. if link and link not in seen:
  42. seen.add(link)
  43. out.append((r.get('本店编码', ''), r.get('竞对门店名', ''), link))
  44. return out
  45. def dump_pages():
  46. if not CHROME:
  47. sys.exit('本机没有找到 Chrome / Edge,请确认安装路径')
  48. os.makedirs(DUMP_DIR, exist_ok=True)
  49. links = load_links()
  50. print(f'共 {len(links)} 个待采集链接')
  51. for i, (code, name, link) in enumerate(links, 1):
  52. fp = os.path.join(DUMP_DIR, f'{code}_{hashlib.md5(link.encode()).hexdigest()[:8]}.html')
  53. if os.path.exists(fp) and os.path.getsize(fp) > 50000:
  54. print(f'[{i}/{len(links)}] 已存在,跳过 {name or code}')
  55. continue
  56. print(f'[{i}/{len(links)}] {name or code} {link}')
  57. try:
  58. subprocess.run([CHROME, '--headless=new', '--disable-gpu',
  59. '--window-size=1200,2400', '--virtual-time-budget=8000',
  60. '--dump-dom', link], capture_output=True, timeout=90)
  61. # dump-dom 输出到 stdout,这里重跑一次拿文本
  62. res = subprocess.run([CHROME, '--headless=new', '--disable-gpu',
  63. '--window-size=1200,2400', '--virtual-time-budget=8000',
  64. '--dump-dom', link], capture_output=True, timeout=90)
  65. html = (res.stdout or b'').decode('utf-8', 'ignore')
  66. if len(html) < 20000:
  67. print(' 页面内容过短,可能需要登录或被风控,请人工核对这个链接')
  68. with open(fp, 'w', encoding='utf-8') as f:
  69. f.write(html)
  70. except subprocess.TimeoutExpired:
  71. print(' 超时,跳过')
  72. time.sleep(4)
  73. def num_cn(s):
  74. """把 '1.2万' '1000+' 转成数字,解析不了返回 None"""
  75. s = s.replace('+', '').replace(',', '').strip()
  76. m = re.match(r'^([\d.]+)(万|w)?$', s)
  77. if not m:
  78. return None
  79. v = float(m.group(1))
  80. if m.group(2):
  81. v *= 10000
  82. return v
  83. def parse_page(html):
  84. """首次校准前的草稿规则。跑一次真实 dump 后按实际页面结构重写这里。"""
  85. items = []
  86. rating = None
  87. m = re.search(r'(\d\.\d)\s*分', html)
  88. if m:
  89. rating = float(m.group(1))
  90. reviews = None
  91. m = re.search(r'(\d+)\s*条评价', html)
  92. if m:
  93. reviews = int(m.group(1))
  94. # 团购商品: 匹配 "商品名 ... 已售 xxx" 的松散结构,实际结构以真实 dump 为准
  95. for blk in re.findall(r'<div[^>]*>([^<>]{2,30})</div>.{0,600}?已售\s*([\d.万亿w+]*)', html, re.S):
  96. name, sold = blk[0].strip(), num_cn(blk[1] or '')
  97. items.append((name, sold))
  98. return rating, reviews, items
  99. def parse_dumps():
  100. today = date.today().isoformat()
  101. files = sorted(glob.glob(os.path.join(DUMP_DIR, '*.html')))
  102. if not files:
  103. sys.exit('dump/ 目录为空,先执行 --dump')
  104. out = os.path.join(SNAP_DIR, f'采集快照_{today.replace("-", "")}.csv')
  105. with open(out, 'w', encoding='utf-8-sig', newline='') as f:
  106. w = csv.writer(f)
  107. w.writerow(['采集日期', '门店编码', '门店名', '门店类型', 'POI链接',
  108. '商品名', '原价', '团购价', '已售', '评分', '评价数'])
  109. for fp in files:
  110. stem = os.path.splitext(os.path.basename(fp))[0]
  111. code = stem.split('_')[0]
  112. with open(fp, encoding='utf-8') as fh:
  113. html = fh.read()
  114. rating, reviews, items = parse_page(html)
  115. print(f'{stem}: 评分={rating} 评价={reviews} 商品={len(items)}')
  116. w.writerow([today, code, stem, '本店', '', '', '', '', '', rating or '', reviews or ''])
  117. for name, sold in items:
  118. w.writerow([today, code, stem, '本店', '', name, '', '', sold or '', rating or '', reviews or ''])
  119. print(f'快照已写入: {out}')
  120. print('提示: 字段解析规则是草稿,请人工抽查几行,再决定是否重写 parse_page()')
  121. if __name__ == '__main__':
  122. if '--dump' in sys.argv:
  123. dump_pages()
  124. elif '--parse' in sys.argv:
  125. parse_dumps()
  126. else:
  127. print(__doc__)