xhs_daily_report.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. """
  2. 小红书创作者中心日报采集脚本
  3. =============================
  4. 每天早上 9:00(Windows 计划任务)运行:通过 CDP 连接已登录的 Chrome,
  5. 采集小红书创作者中心的账号总览 + 笔记列表数据及页面截图,
  6. 推送到飞书群。
  7. 技术栈:CDP(复用已在运行的 Chrome + 小红书登录态),requests,飞书 API。
  8. 复用自 C:\\code\\XiaohongshuSkills\\scripts\\cdp_publish.py 的 XiaohongshuPublisher。
  9. 用法:
  10. python xhs_daily_report.py # 正常执行(连接 9222 端口 CDP)
  11. python xhs_daily_report.py --login # 手动登录引导(保存 CDP 登录态)
  12. 先决条件:
  13. 1. 本机 Chrome 已以 --remote-debugging-port=9222 启动且已登录小红书
  14. 2. 运营文案/xhs_daily_config.json 已填入飞书 app_id/secret/群ID
  15. """
  16. import argparse
  17. import base64
  18. import io
  19. import json
  20. import os
  21. import re
  22. import sys
  23. import time
  24. import requests
  25. # 遵循项目规范:UTF-8 输出,避免 Windows GBK 乱码
  26. sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
  27. sys.stderr = sys.stdout
  28. # ---- 路径配置 ----
  29. SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) # 小红书发布/_过程脚本/
  30. XHS_DIR = os.path.dirname(SCRIPT_DIR) # 小红书发布/
  31. OPERATION_DIR = os.path.dirname(XHS_DIR) # 运营文案/
  32. CONFIG_PATH = os.path.join(OPERATION_DIR, "xhs_daily_config.json")
  33. SCREENSHOT_DIR = os.path.join(XHS_DIR, "_过程文件", "截图")
  34. # 2026-08-28 起新增脚本采用轻量记录,不依赖旧版 master_tracking.csv
  35. RECORD_DIR = os.path.join(XHS_DIR, "_数据追踪")
  36. # XiaohongshuSkills 库路径
  37. XHS_SKILLS_PATH = r"C:\code\XiaohongshuSkills\scripts"
  38. sys.path.insert(0, XHS_SKILLS_PATH)
  39. if OPERATION_DIR not in sys.path:
  40. sys.path.insert(0, OPERATION_DIR)
  41. CREATOR_HOME = "https://creator.xiaohongshu.com/new/home"
  42. NOTES_MANAGE_URL = "https://creator.xiaohongshu.com/new/note-manager"
  43. def _log(msg):
  44. print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
  45. def _load_config() -> dict:
  46. """读取飞书配置。"""
  47. if not os.path.exists(CONFIG_PATH):
  48. raise FileNotFoundError(
  49. f"配置文件不存在: {CONFIG_PATH}\n"
  50. f"请从 xhs_daily_config.json.example 复制并填入真实值"
  51. )
  52. with open(CONFIG_PATH, "r", encoding="utf-8") as f:
  53. cfg = json.load(f)
  54. missing = [k for k in ("feishu_app_id", "feishu_app_secret", "feishu_chat_id") if not cfg.get(k)]
  55. if missing:
  56. raise ValueError(f"配置缺少必填字段: {', '.join(missing)}")
  57. return cfg
  58. def _to_int(text):
  59. """'1.2万' -> 12000, '1,234' -> 1234, '--'/'' -> 0。"""
  60. if not text:
  61. return 0
  62. t = str(text).strip().replace(",", "").replace(" ", "")
  63. if not t or t == "--":
  64. return 0
  65. m = re.match(r"^([\d.]+)(万|亿)?$", t)
  66. if not m:
  67. return 0
  68. val = float(m.group(1))
  69. unit = m.group(2)
  70. if unit == "万":
  71. val *= 10000
  72. elif unit == "亿":
  73. val *= 100000000
  74. return int(val)
  75. def _clean_title(title):
  76. """清理标题:去掉换行与多余空白。"""
  77. if not title:
  78. return ""
  79. return " ".join(title.split())
  80. def collect_overview(publisher) -> dict:
  81. """
  82. 从创作者中心首页 /new/home 获取账号总览数据。
  83. 页面 innerText 结构(实测):
  84. - 账号级字段: "数字\\n标签"(如 "20\\n粉丝数", "551\\n获赞与收藏")
  85. - 周期数据: "标签\\n数字\\n环比xx%"(如 "观看数\\n209\\n环比-86%")
  86. - 默认显示近7日,点击「近30日」tab 抓第二组
  87. 返回 dict:
  88. fans_count / follow_count / interact_count(账号级)
  89. period_7d / period_30d: 各含 exposure/view/like/comment/collect/share/net_fans
  90. """
  91. overview = {}
  92. def _parse_period(text):
  93. """解析周期数据(标签\\n数字\\n环比%)。返回 dict[label] = (值, 原始字符串)"""
  94. p = {}
  95. # 匹配 "标签\n数字\n环比xx%" 或 "标签\n数字\n环比-"
  96. pattern = re.compile(
  97. r"^(曝光数|观看数|封面点击率|视频完播率|点赞数|评论数|收藏数|分享数|净涨粉|新增关注|取消关注|主页访客)"
  98. r"\n([\d.,万%]+)\n环比([+-]?\d*%?)", re.M)
  99. for m in pattern.finditer(text):
  100. p[m.group(1)] = m.group(2)
  101. return p
  102. # --- 账号级字段:数字在标签前 ---
  103. text = (publisher._evaluate("document.body.innerText") or "")
  104. for key, label in (("fans_count", "粉丝数"), ("follow_count", "关注数"),
  105. ("interact_count", "获赞与收藏")):
  106. m = re.search(r"([\d.,万]+)\n" + re.escape(label), text)
  107. if m:
  108. overview[key] = _to_int(m.group(1))
  109. else:
  110. overview[key] = 0
  111. # --- 近7日(默认 tab) ---
  112. period_7d = _parse_period(text)
  113. # --- 近30日:点击 tab 抓第二组 ---
  114. period_30d = {}
  115. try:
  116. js = (
  117. "(() => {"
  118. " const els = [...document.querySelectorAll('*')] ;"
  119. " const t = els.find(e => e.children.length === 0 && e.textContent.trim() === '近30日');"
  120. " if (t) { t.click(); return 'CLICKED'; }"
  121. " return 'NOT_FOUND';"
  122. "})()"
  123. )
  124. r = publisher._evaluate(js)
  125. if r == "CLICKED":
  126. time.sleep(6) # 等待数据刷新
  127. text2 = publisher._evaluate("document.body.innerText") or ""
  128. period_30d = _parse_period(text2)
  129. _log(f" 近30日: 观看={period_30d.get('观看数')} 净涨粉={period_30d.get('净涨粉')}")
  130. if not period_30d:
  131. _log(" [WARN] 近30日解析为空,重试一次")
  132. time.sleep(4)
  133. text2 = publisher._evaluate("document.body.innerText") or ""
  134. period_30d = _parse_period(text2)
  135. _log(f" 近30日(重试): 观看={period_30d.get('观看数')}")
  136. else:
  137. _log(f" [WARN] 切换近30日 tab 失败: {r}")
  138. except Exception as e:
  139. _log(f" [WARN] 抓近30日失败: {e}")
  140. overview["period_7d"] = period_7d
  141. overview["period_30d"] = period_30d
  142. return overview
  143. def _count_unreplied_comments(publisher, note_id: str, xsec_token: str) -> int:
  144. """打开笔记详情页读取评论,统计未回复的父评论数。
  145. 判定规则:父评论下所有楼中楼(subComments)中,
  146. 若没有 userId 等于作者 userId 的子评论 → 计为未回复。
  147. """
  148. if not xsec_token or not note_id:
  149. return 0
  150. try:
  151. res = publisher.get_feed_detail(
  152. feed_id=note_id,
  153. xsec_token=xsec_token,
  154. load_all_comments=True,
  155. limit=60,
  156. click_more_replies=True,
  157. reply_limit=10,
  158. )
  159. except Exception as e:
  160. _log(f" [WARN] 笔记 {note_id} 详情页读取失败: {e}")
  161. return 0
  162. detail = res.get("detail") or {}
  163. note_obj = detail.get("note")
  164. # note.user 可能是 dict(图文笔记)或 str(视频笔记的 userId 字段)
  165. user_obj = (note_obj or {}).get("user") if isinstance(note_obj, dict) else None
  166. if isinstance(user_obj, str):
  167. author_id = user_obj
  168. elif isinstance(user_obj, dict):
  169. author_id = str(user_obj.get("userId") or "")
  170. else:
  171. author_id = ""
  172. if not author_id:
  173. return 0
  174. comments = (detail.get("comments") or {}).get("list") or []
  175. unreplied = 0
  176. for c in comments:
  177. subs = c.get("subComments") or []
  178. replied = any(
  179. str((s.get("userInfo") or {}).get("userId") or "") == author_id
  180. for s in subs
  181. )
  182. if not replied:
  183. unreplied += 1
  184. return unreplied
  185. def collect_notes(publisher, limit=10):
  186. """
  187. 从笔记管理页采集笔记列表,并统计每篇未回复评论数。
  188. 优先使用 posted API(含 xsec_token),失败时退回 innerText 解析。
  189. 对每篇有评论的笔记打开详情页,统计作者尚未回复的父评论数。
  190. """
  191. # ── 优先:posted API(精确数据 + xsec_token) ──────────────────────
  192. raw_notes = []
  193. try:
  194. from xhs_unreplied_comments import capture_posted_notes
  195. raw_notes = capture_posted_notes(publisher, max_wait=20.0)
  196. _log(f" [posted] 捕获 {len(raw_notes)} 篇笔记")
  197. except Exception as e:
  198. _log(f" [WARN] posted API 捕获失败,退回 innerText 解析: {e}")
  199. raw_notes = []
  200. notes = []
  201. if raw_notes:
  202. for n in raw_notes[:limit]:
  203. entry = {
  204. "title": n.get("title", ""),
  205. "publish_date": (n.get("publish_time") or "")[:10],
  206. "read_count": n.get("view_count", 0),
  207. "comment_count": n.get("comments_count", 0),
  208. "like_count": n.get("likes", 0),
  209. "collect_count": n.get("collected_count", 0),
  210. "share_count": n.get("shared_count", 0),
  211. "unreplied_comment_count": 0,
  212. "_xsec_token": n.get("xsec_token", ""),
  213. "_note_id": n.get("id", ""),
  214. }
  215. notes.append(entry)
  216. else:
  217. # fallback:innerText 解析(原逻辑,无法获取 xsec_token,unreplied 固定为 0)
  218. text = publisher._evaluate("document.body.innerText") or ""
  219. lines = [ln.strip() for ln in text.split("\n")]
  220. dates = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$")
  221. i = 0
  222. while i < len(lines) and len(notes) < limit:
  223. ln = lines[i]
  224. if dates.match(ln) and i + 5 < len(lines):
  225. title = _clean_title(lines[i - 1]) if i >= 1 else ""
  226. nums = []
  227. j = i + 1
  228. while j < len(lines) and len(nums) < 5 and lines[j].isdigit():
  229. nums.append(int(lines[j]))
  230. j += 1
  231. if len(nums) >= 5 and title:
  232. notes.append({
  233. "title": title,
  234. "publish_date": ln[:10],
  235. "read_count": nums[0],
  236. "comment_count": nums[1],
  237. "like_count": nums[2],
  238. "collect_count": nums[3],
  239. "share_count": nums[4],
  240. "unreplied_comment_count": 0,
  241. "_xsec_token": "",
  242. "_note_id": "",
  243. })
  244. i = j if j > i + 1 else i + 1
  245. else:
  246. i += 1
  247. # ── 逐篇统计未回复评论 ─────────────────────────────────────────────
  248. for entry in notes:
  249. if entry["comment_count"] <= 0:
  250. continue
  251. unreplied = _count_unreplied_comments(
  252. publisher, entry["_note_id"], entry["_xsec_token"]
  253. )
  254. entry["unreplied_comment_count"] = unreplied
  255. _log(
  256. f" [笔记] {entry['title'][:30]} "
  257. f"评论={entry['comment_count']} "
  258. f"未回复={unreplied}"
  259. )
  260. # 清理内部字段(保持对外接口干净)
  261. for entry in notes:
  262. entry.pop("_xsec_token", None)
  263. entry.pop("_note_id", None)
  264. return notes[:limit]
  265. def screenshot_current(publisher, output_path, full_page: bool = True):
  266. """用 CDP 截取当前页面(JPEG),默认整页截图。
  267. full_page=True 时用 captureBeyondViewport 截取整个可滚动页面。
  268. """
  269. os.makedirs(os.path.dirname(output_path), exist_ok=True)
  270. params = {"format": "jpeg", "quality": 60}
  271. if full_page:
  272. params["captureBeyondViewport"] = True
  273. result = publisher._send("Page.captureScreenshot", params)
  274. data = result.get("data") or (result.get("result") or {}).get("data")
  275. if data:
  276. img_data = base64.b64decode(data)
  277. with open(output_path, "wb") as f:
  278. f.write(img_data)
  279. _log(f"截图已保存: {output_path} ({len(img_data)} bytes)")
  280. else:
  281. raise RuntimeError(f"截图失败: {result}")
  282. # 账号概览页 URL(创作者中心数据看板)
  283. ACCOUNT_OVERVIEW_URL = "https://creator.xiaohongshu.com/statistics/account/v2"
  284. def capture_three_screenshots(publisher, shot_dir, prefix="xhs_bot"):
  285. """依次截取三张整页截图:首页 / 笔记管理 / 账号概览。
  286. Returns:
  287. list[str]: 三张截图的文件路径(按顺序:首页, 笔记管理, 账号概览)
  288. """
  289. os.makedirs(shot_dir, exist_ok=True)
  290. ts = int(time.time())
  291. shots = []
  292. targets = [
  293. (CREATOR_HOME, f"{prefix}_{ts}_home.jpg"),
  294. (NOTES_MANAGE_URL, f"{prefix}_{ts}_notes.jpg"),
  295. (ACCOUNT_OVERVIEW_URL, f"{prefix}_{ts}_overview.jpg"),
  296. ]
  297. for url, fname in targets:
  298. try:
  299. publisher._navigate(url)
  300. time.sleep(5)
  301. path = os.path.join(shot_dir, fname)
  302. screenshot_current(publisher, path, full_page=True)
  303. shots.append(path)
  304. except Exception as e:
  305. _log(f"[WARN] 截图失败 {url}: {e}")
  306. return shots
  307. def append_daily_record(date, overview, notes):
  308. """追加当日快照到本地 JSON 记录(轻量,不依赖旧 CSV 结构)。"""
  309. os.makedirs(RECORD_DIR, exist_ok=True)
  310. rec_path = os.path.join(RECORD_DIR, "daily_reports.jsonl")
  311. rec = {
  312. "date": date,
  313. "overview": overview,
  314. "notes": notes,
  315. "recorded_at": time.strftime("%Y-%m-%d %H:%M:%S"),
  316. }
  317. with open(rec_path, "a", encoding="utf-8") as f:
  318. f.write(json.dumps(rec, ensure_ascii=False) + "\n")
  319. def main():
  320. parser = argparse.ArgumentParser(description="小红书创作者中心日报采集与飞书推送")
  321. parser.add_argument("--login", action="store_true", help="手动登录态(需人工扫码)")
  322. parser.add_argument("--limit", type=int, default=10, help="推送笔记条数上限")
  323. args = parser.parse_args()
  324. cfg = _load_config()
  325. from cdp_publish import XiaohongshuPublisher
  326. publisher = XiaohongshuPublisher()
  327. if args.login:
  328. try:
  329. publisher.connect(reuse_existing_tab=True)
  330. except Exception as e:
  331. _log(f"连接 CDP 失败: {e}")
  332. _log("请先用下列命令启动带调试端口的 Chrome 并登录小红书:")
  333. _log('chrome.exe --remote-debugging-port=9222 --user-data-dir="%LOCALAPPDATA%\\Google\\Chrome\\User Data"')
  334. sys.exit(1)
  335. publisher.check_login()
  336. publisher.disconnect()
  337. _log("登录态已确认/缓存。之后直接运行 xhs_daily_report.py 即可。")
  338. return
  339. # ---- 常规日报流程 ----
  340. date = time.strftime("%Y-%m-%d")
  341. overview = {"fans_count": 0, "fans_growth_30d": 0, "total_read": 0,
  342. "total_interact": 0, "note_count": 0}
  343. try:
  344. _log("连接 Chrome CDP (9222)...")
  345. publisher.connect(reuse_existing_tab=True)
  346. if not publisher.check_login():
  347. raise RuntimeError("未登录小红书,请先运行 --login 或手动打开浏览器完成扫码")
  348. # 1. 账号总览
  349. _log("采集账号总览...")
  350. publisher._navigate(CREATOR_HOME)
  351. time.sleep(4)
  352. overview = collect_overview(publisher)
  353. _log(f" 粉丝数: {overview.get('fans_count')}, 总阅读: {overview.get('total_read')}, "
  354. f"总互动: {overview.get('total_interact')}, 笔记数: {overview.get('note_count')}")
  355. _log(f"采集笔记列表(最多 {args.limit} 条)...")
  356. publisher._navigate(NOTES_MANAGE_URL)
  357. time.sleep(4)
  358. notes = collect_notes(publisher, args.limit)
  359. _log(f" 采集到 {len(notes)} 篇笔记")
  360. _log("截图(首页/笔记管理/账号概览 三张整页)...")
  361. shot_paths = capture_three_screenshots(publisher, SCREENSHOT_DIR, prefix=f"xhs_daily_{date}")
  362. _log(f" 截图 {len(shot_paths)} 张")
  363. append_daily_record(date, overview, notes)
  364. _log("推送到飞书...")
  365. sys.path.insert(0, OPERATION_DIR)
  366. from xhs_feishu import send_daily_report
  367. send_daily_report(CONFIG_PATH, date, overview, notes, screenshot_paths=shot_paths)
  368. _log("✅ 日报流程完成")
  369. except Exception as e:
  370. _log(f"❌ 采集失败: {e}")
  371. import traceback
  372. traceback.print_exc(file=sys.stderr)
  373. sys.exit(1)
  374. finally:
  375. try:
  376. publisher.disconnect()
  377. except Exception:
  378. pass
  379. if __name__ == "__main__":
  380. main()