|
|
@@ -0,0 +1,336 @@
|
|
|
+"""
|
|
|
+小红书创作者中心日报采集脚本
|
|
|
+=============================
|
|
|
+每天早上 9:00(Windows 计划任务)运行:通过 CDP 连接已登录的 Chrome,
|
|
|
+采集小红书创作者中心的账号总览 + 笔记列表数据及页面截图,
|
|
|
+推送到飞书群。
|
|
|
+
|
|
|
+技术栈:CDP(复用已在运行的 Chrome + 小红书登录态),requests,飞书 API。
|
|
|
+复用自 C:\\code\\XiaohongshuSkills\\scripts\\cdp_publish.py 的 XiaohongshuPublisher。
|
|
|
+
|
|
|
+用法:
|
|
|
+ python xhs_daily_report.py # 正常执行(连接 9222 端口 CDP)
|
|
|
+ python xhs_daily_report.py --login # 手动登录引导(保存 CDP 登录态)
|
|
|
+
|
|
|
+先决条件:
|
|
|
+ 1. 本机 Chrome 已以 --remote-debugging-port=9222 启动且已登录小红书
|
|
|
+ 2. 运营文案/xhs_daily_config.json 已填入飞书 app_id/secret/群ID
|
|
|
+"""
|
|
|
+import argparse
|
|
|
+import base64
|
|
|
+import io
|
|
|
+import json
|
|
|
+import os
|
|
|
+import re
|
|
|
+import sys
|
|
|
+import time
|
|
|
+
|
|
|
+import requests
|
|
|
+
|
|
|
+# 遵循项目规范:UTF-8 输出,避免 Windows GBK 乱码
|
|
|
+sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
|
|
+sys.stderr = sys.stdout
|
|
|
+
|
|
|
+# ---- 路径配置 ----
|
|
|
+OPERATION_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # 运营文案/
|
|
|
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) # 小红书发布/_过程脚本/
|
|
|
+XHS_DIR = os.path.dirname(SCRIPT_DIR) # 小红书发布/
|
|
|
+
|
|
|
+CONFIG_PATH = os.path.join(OPERATION_DIR, "xhs_daily_config.json")
|
|
|
+SCREENSHOT_DIR = os.path.join(XHS_DIR, "_过程文件", "截图")
|
|
|
+
|
|
|
+# 2026-08-28 起新增脚本采用轻量记录,不依赖旧版 master_tracking.csv
|
|
|
+RECORD_DIR = os.path.join(XHS_DIR, "_数据追踪")
|
|
|
+
|
|
|
+# XiaohongshuSkills 库路径
|
|
|
+XHS_SKILLS_PATH = r"C:\code\XiaohongshuSkills\scripts"
|
|
|
+sys.path.insert(0, XHS_SKILLS_PATH)
|
|
|
+
|
|
|
+CREATOR_HOME = "https://creator.xiaohongshu.com/new/home"
|
|
|
+NOTES_MANAGE_URL = "https://creator.xiaohongshu.com/publish/publish_manage"
|
|
|
+COMMENTS_URL = "https://creator.xiaohongshu.com/message/comment"
|
|
|
+
|
|
|
+
|
|
|
+def _log(msg):
|
|
|
+ print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
|
|
|
+
|
|
|
+
|
|
|
+def _load_config() -> dict:
|
|
|
+ """读取飞书配置。"""
|
|
|
+ if not os.path.exists(CONFIG_PATH):
|
|
|
+ raise FileNotFoundError(
|
|
|
+ f"配置文件不存在: {CONFIG_PATH}\n"
|
|
|
+ f"请从 xhs_daily_config.json.example 复制并填入真实值"
|
|
|
+ )
|
|
|
+ with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
|
|
+ cfg = json.load(f)
|
|
|
+ missing = [k for k in ("feishu_app_id", "feishu_app_secret", "feishu_chat_id") if not cfg.get(k)]
|
|
|
+ if missing:
|
|
|
+ raise ValueError(f"配置缺少必填字段: {', '.join(missing)}")
|
|
|
+ return cfg
|
|
|
+
|
|
|
+
|
|
|
+def _to_int(text):
|
|
|
+ """'1.2万' -> 12000, '1,234' -> 1234, '--'/'' -> 0。"""
|
|
|
+ if not text:
|
|
|
+ return 0
|
|
|
+ t = str(text).strip().replace(",", "").replace(" ", "")
|
|
|
+ if not t or t == "--":
|
|
|
+ return 0
|
|
|
+ m = re.match(r"^([\d.]+)(万|亿)?$", t)
|
|
|
+ if not m:
|
|
|
+ return 0
|
|
|
+ val = float(m.group(1))
|
|
|
+ unit = m.group(2)
|
|
|
+ if unit == "万":
|
|
|
+ val *= 10000
|
|
|
+ elif unit == "亿":
|
|
|
+ val *= 100000000
|
|
|
+ return int(val)
|
|
|
+
|
|
|
+
|
|
|
+def _clean_title(title):
|
|
|
+ """清理标题:去掉换行与多余空白。"""
|
|
|
+ if not title:
|
|
|
+ return ""
|
|
|
+ return " ".join(title.split())
|
|
|
+
|
|
|
+
|
|
|
+def collect_overview(publisher) -> dict:
|
|
|
+ """从创作者中心首页获取账号总览数据。"""
|
|
|
+ overview = {}
|
|
|
+ text = (publisher._evaluate("document.body.innerText") or "")
|
|
|
+
|
|
|
+ # 粉丝数 / 总阅读 / 总互动 / 笔记总数(宽泛匹配,兼容页面 word 差异)
|
|
|
+ patterns = [
|
|
|
+ ("fans_count", r"粉丝数\s*[::\n]\s*([\d.,万]+)"),
|
|
|
+ ("total_read", r"总阅读[量]?\s*[::\n]\s*([\d.,万]+)"),
|
|
|
+ ("total_interact", r"总互动[量]?\s*[::\n]\s*([\d.,万]+)"),
|
|
|
+ ("note_count", r"笔记总数\s*[::\n]\s*([\d.,]+)"),
|
|
|
+ ("fans_growth_30d", r"(?:近30天|近3[0-9]天)[^\d\n]*?([+-]?[\d.,万]+)"),
|
|
|
+ ]
|
|
|
+ for key, pat in patterns:
|
|
|
+ m = re.search(pat, text)
|
|
|
+ if m:
|
|
|
+ overview[key] = _to_int(m.group(1))
|
|
|
+ return overview
|
|
|
+
|
|
|
+
|
|
|
+def collect_notes_from_dom(publisher, limit=10):
|
|
|
+ """从笔记管理页 DOM 提取笔记列表(完整标题 + 数据)。"""
|
|
|
+ expr = f"""() => {{
|
|
|
+ const notes = [];
|
|
|
+ // 常见容器选择器:数据表格行 / 笔记卡片
|
|
|
+ const rows = document.querySelectorAll(
|
|
|
+ 'table tbody tr, .note-item, [class*="note-row"], [class*="note-item"], [class*="data-card"], li'
|
|
|
+ );
|
|
|
+ let count = 0;
|
|
|
+ for (const row of rows) {{
|
|
|
+ if (count >= {int(limit)}) break;
|
|
|
+ const titleEl = row.querySelector(
|
|
|
+ '[class*="title"], [class*="Title"], [class*="name"], [class*="content"], a[href*="/explore/"]'
|
|
|
+ );
|
|
|
+ const titleFull = (titleEl ? titleEl.textContent : row.childNodes[0] ? row.childNodes[0].textContent : row.textContent)
|
|
|
+ .trim();
|
|
|
+ if (!titleFull || titleFull.length < 2 || /(阅读|点赞|评论|收藏|发布)/.test(titleFull)) continue;
|
|
|
+ // 收集该行所有数字
|
|
|
+ const nums = Array.from(row.querySelectorAll('td, [class*="stat"], [class*="value"], [class*="count"]'))
|
|
|
+ .map(el => el.textContent.replace(/[, ]/g, ''))
|
|
|
+ .filter(t => /^\\d+$/.test(t))
|
|
|
+ .map(Number);
|
|
|
+ // 常见顺序: 阅读 / 点赞? / 收藏 / 评论,或不含单位。按存在数量尽力填充。
|
|
|
+ const note = {{
|
|
|
+ title: titleFull,
|
|
|
+ publish_date: '',
|
|
|
+ read_count: nums[0] || 0,
|
|
|
+ like_count: nums[1] || 0,
|
|
|
+ collect_count: nums[2] || 0,
|
|
|
+ comment_count: nums[3] || 0,
|
|
|
+ share_count: nums[4] || 0,
|
|
|
+ unreplied_comment_count: 0,
|
|
|
+ }};
|
|
|
+ // 找发布日期
|
|
|
+ const m = row.textContent.match(/(\\d{{4}}[-/]\\d{{1,2}}[-/]\\d{{1,2}})|(\\d{{1,2}}[-/]\\d{{1,2}})/);
|
|
|
+ if (m) note.publish_date = m[0];
|
|
|
+ notes.push(note);
|
|
|
+ count++;
|
|
|
+ }}
|
|
|
+ return JSON.stringify(notes);
|
|
|
+ }}"""
|
|
|
+ try:
|
|
|
+ raw = publisher._evaluate(expr)
|
|
|
+ if isinstance(raw, str):
|
|
|
+ parsed = json.loads(raw) if raw.strip().startswith("[") else []
|
|
|
+ for n in parsed:
|
|
|
+ n["title"] = _clean_title(n.get("title", ""))
|
|
|
+ return parsed
|
|
|
+ except Exception as e:
|
|
|
+ _log(f"[warn] DOM 提取笔记失败: {e}")
|
|
|
+ return []
|
|
|
+
|
|
|
+
|
|
|
+def collect_notes_fallback(publisher) -> list:
|
|
|
+ """兜底:从页面纯文本按行解析笔记数据。"""
|
|
|
+ text = publisher._evaluate("document.body.innerText") or ""
|
|
|
+ notes = []
|
|
|
+ lines = [ln.strip() for ln in text.split("\n") if ln.strip()]
|
|
|
+ i = 0
|
|
|
+ while i < len(lines) and len(notes) < 10:
|
|
|
+ line = lines[i]
|
|
|
+ # 数据行特征:含 2+ 个数字
|
|
|
+ nums = re.findall(r"[\d.,万]+", line)
|
|
|
+ if len(nums) >= 2 and not re.match(r'^\d+$', line):
|
|
|
+ # 标题 = 上一行若非数字行
|
|
|
+ title = lines[i - 1] if i > 0 and not re.fullmatch(r"[\d,\.]+", lines[i - 1]) else line
|
|
|
+ values = [_to_int(nm) for nm in nums]
|
|
|
+ note = {
|
|
|
+ "title": _clean_title(title),
|
|
|
+ "publish_date": "",
|
|
|
+ "read_count": values[0],
|
|
|
+ "like_count": values[1] if len(values) > 1 else 0,
|
|
|
+ "collect_count": values[2] if len(values) > 2 else 0,
|
|
|
+ "comment_count": values[3] if len(values) > 3 else 0,
|
|
|
+ "share_count": values[4] if len(values) > 4 else 0,
|
|
|
+ "unreplied_comment_count": 0,
|
|
|
+ }
|
|
|
+ notes.append(note)
|
|
|
+ i += 1
|
|
|
+ return notes
|
|
|
+
|
|
|
+
|
|
|
+def collect_notes(publisher, limit=10):
|
|
|
+ """采集笔记列表:先 DOM,失败则兜底文本。"""
|
|
|
+ notes = collect_notes_from_dom(publisher, limit)
|
|
|
+ if not notes:
|
|
|
+ notes = collect_notes_fallback(publisher)
|
|
|
+ return notes[:limit]
|
|
|
+
|
|
|
+
|
|
|
+def collect_unreplied_comments(publisher) -> int:
|
|
|
+ """采集未回复评论数(跳转评论消息页)。"""
|
|
|
+ publisher._navigate(COMMENTS_URL)
|
|
|
+ time.sleep(2)
|
|
|
+ try:
|
|
|
+ text = publisher._evaluate("document.body.innerText") or ""
|
|
|
+ except Exception:
|
|
|
+ return 0
|
|
|
+ m = re.search(r"未回复[^\\d]*(\\d+)", text)
|
|
|
+ if m:
|
|
|
+ return int(m.group(1))
|
|
|
+ # 备选:查未读提醒徽标 / 未回复 tab 计数
|
|
|
+ m2 = re.search(r"未回复评论[^\\d]*(\\d+)", text)
|
|
|
+ return int(m2.group(1)) if m2 else 0
|
|
|
+
|
|
|
+
|
|
|
+def screenshot_current(publisher, output_path):
|
|
|
+ """用 CDP 截取当前页面(JPEG)。"""
|
|
|
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
|
+ result = publisher._send("Page.captureScreenshot", {"format": "jpeg", "quality": 60})
|
|
|
+ if result.get("result", {}).get("data"):
|
|
|
+ img_data = base64.b64decode(result["result"]["data"])
|
|
|
+ with open(output_path, "wb") as f:
|
|
|
+ f.write(img_data)
|
|
|
+ _log(f"截图已保存: {output_path} ({len(img_data)} bytes)")
|
|
|
+ else:
|
|
|
+ raise RuntimeError(f"截图失败: {result}")
|
|
|
+
|
|
|
+
|
|
|
+def append_daily_record(date, overview, notes):
|
|
|
+ """追加当日快照到本地 JSON 记录(轻量,不依赖旧 CSV 结构)。"""
|
|
|
+ os.makedirs(RECORD_DIR, exist_ok=True)
|
|
|
+ rec_path = os.path.join(RECORD_DIR, "daily_reports.jsonl")
|
|
|
+ rec = {
|
|
|
+ "date": date,
|
|
|
+ "overview": overview,
|
|
|
+ "notes": notes,
|
|
|
+ "recorded_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
|
+ }
|
|
|
+ with open(rec_path, "a", encoding="utf-8") as f:
|
|
|
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
|
+
|
|
|
+
|
|
|
+def main():
|
|
|
+ parser = argparse.ArgumentParser(description="小红书创作者中心日报采集与飞书推送")
|
|
|
+ parser.add_argument("--login", action="store_true", help="手动登录态(需人工扫码)")
|
|
|
+ parser.add_argument("--limit", type=int, default=10, help="推送笔记条数上限")
|
|
|
+ args = parser.parse_args()
|
|
|
+
|
|
|
+ cfg = _load_config()
|
|
|
+
|
|
|
+ from cdp_publish import XiaohongshuPublisher
|
|
|
+ publisher = XiaohongshuPublisher()
|
|
|
+
|
|
|
+ if args.login:
|
|
|
+ try:
|
|
|
+ publisher.connect(reuse_existing_tab=True)
|
|
|
+ except Exception as e:
|
|
|
+ _log(f"连接 CDP 失败: {e}")
|
|
|
+ _log("请先用下列命令启动带调试端口的 Chrome 并登录小红书:")
|
|
|
+ _log('chrome.exe --remote-debugging-port=9222 --user-data-dir="%LOCALAPPDATA%\\Google\\Chrome\\User Data"')
|
|
|
+ sys.exit(1)
|
|
|
+ publisher.check_login()
|
|
|
+ publisher.disconnect()
|
|
|
+ _log("登录态已确认/缓存。之后直接运行 xhs_daily_report.py 即可。")
|
|
|
+ return
|
|
|
+
|
|
|
+ # ---- 常规日报流程 ----
|
|
|
+ date = time.strftime("%Y-%m-%d")
|
|
|
+ overview = {"fans_count": 0, "fans_growth_30d": 0, "total_read": 0,
|
|
|
+ "total_interact": 0, "note_count": 0}
|
|
|
+ try:
|
|
|
+ _log("连接 Chrome CDP (9222)...")
|
|
|
+ publisher.connect(reuse_existing_tab=True)
|
|
|
+ if not publisher.check_login():
|
|
|
+ raise RuntimeError("未登录小红书,请先运行 --login 或手动打开浏览器完成扫码")
|
|
|
+
|
|
|
+ # 1. 账号总览
|
|
|
+ _log("采集账号总览...")
|
|
|
+ publisher._navigate(CREATOR_HOME)
|
|
|
+ time.sleep(4)
|
|
|
+ overview = collect_overview(publisher)
|
|
|
+ _log(f" 粉丝数: {overview.get('fans_count')}, 总阅读: {overview.get('total_read')}, "
|
|
|
+ f"总互动: {overview.get('total_interact')}, 笔记数: {overview.get('note_count')}")
|
|
|
+
|
|
|
+ # 2. 笔记列表
|
|
|
+ _log(f"采集笔记列表(最多 {args.limit} 条)...")
|
|
|
+ publisher._navigate(NOTES_MANAGE_URL)
|
|
|
+ time.sleep(3)
|
|
|
+ notes = collect_notes(publisher, args.limit)
|
|
|
+ _log(f" 采集到 {len(notes)} 篇笔记")
|
|
|
+
|
|
|
+ # 3. 未回复评论
|
|
|
+ _log("采集未回复评论数...")
|
|
|
+ unreplied = collect_unreplied_comments(publisher)
|
|
|
+ for n in notes:
|
|
|
+ n["unreplied_comment_count"] = unreplied
|
|
|
+ _log(f" 未回复评论: {unreplied}")
|
|
|
+
|
|
|
+ # 4. 截图(回到创作者首页,截账号概览看板)
|
|
|
+ _log("截图...")
|
|
|
+ shot_path = os.path.join(SCREENSHOT_DIR, f"xhs_daily_{date}.jpg")
|
|
|
+ screenshot_current(publisher, shot_path)
|
|
|
+
|
|
|
+ # 5. 本地记录
|
|
|
+ append_daily_record(date, overview, notes)
|
|
|
+
|
|
|
+ # 6. 飞书推送
|
|
|
+ _log("推送到飞书...")
|
|
|
+ sys.path.insert(0, OPERATION_DIR)
|
|
|
+ from xhs_feishu import send_daily_report
|
|
|
+ send_daily_report(CONFIG_PATH, date, overview, notes, screenshot_path=shot_path)
|
|
|
+ _log("✅ 日报流程完成")
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ _log(f"❌ 采集失败: {e}")
|
|
|
+ import traceback
|
|
|
+ traceback.print_exc(file=sys.stderr)
|
|
|
+ sys.exit(1)
|
|
|
+ finally:
|
|
|
+ try:
|
|
|
+ publisher.disconnect()
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ main()
|