在上游 MediaCrawler 之上新增一层: - 监控层 api/monitor/ —— 多博主/多笔记的定时采集、指标快照差分、报表、 企业微信通知。每轮采集写入独立目录,差分才成立。 - WebUI 登录鉴权 api/auth.py —— PBKDF2 口令 + 服务端会话,/api 全接口防护。 WebSocket 单独加依赖:BaseHTTPMiddleware 对 ws 作用域直接放行,覆盖不到。 - 全局平台切换 + 能力矩阵 —— 如实区分「爬虫模块支持」与「监控层已接线」, 未接通的平台直接拒绝建任务,而不是静默跑空。 - 监控库改用 MySQL 5.7(可回退 SQLite 供测试):逐表强制 utf8mb4 (服务端与库默认都是 latin1),启动校验所连 schema 以防写错库, 连接池 recycle + pre_ping 应对 MySQL 的 8 小时空闲断连。 修复上游缺陷: - xhs/core.py: 主页抓取失败会跳掉整个博主,导致一条作品都抓不到, 而那份资料只喂给一个空函数。改为尽力而为,失败不中断。 - xhs/login.py: cookie 登录只注入 web_session,冷启动签名会失败。 新增 INJECT_ALL_COOKIES 开关(默认关闭,原有行为不变)。 - requirements.txt: 补上 websockets。它在上游 pyproject.toml 里有声明、 这里漏了,导致 uvicorn 没有 WebSocket 能力,实时日志流从未工作。 改动过的上游文件清单及合并方式见 UPSTREAM.md。 测试:492 passed(另有 1 个既有的 Windows/gbk 上游测试失败,与本改动无关)
22 KiB
小红书监控功能使用说明
定时重复采集一批博主或笔记,与上一轮快照对比,产出新增作品 / 新增评论 / 点赞收藏评论数涨跌。
本功能是在 MediaCrawler 之上新增的一层,代码集中在 api/monitor/,不侵入原有的
media_platform/、store/ 等目录。
一、为什么需要单独一层
原项目是一次性采集:跑完即退出,没有调度、没有历史、没有差分。直接复用会遇到三个硬伤:
- 指标会被覆盖。
store/xhs/_store_impl.py::XhsDbStoreImplement.update_content()对已存在的笔记执行UPDATE ... SET liked_count = ...,历史值直接丢失。跑第二遍根本看不出"点赞从 100 涨到了 500"。 - 单进程串行。
api/services/crawler_manager.py是全局单例,同一时刻只能跑一个main.py子进程。 - 运行输出无法区分。
AsyncFileWriter的文件名只带日期(creator_contents_2026-10-07.jsonl), 同一天多次运行会追加进同一个文件。
监控层为此做了对应处理:独立的快照库(保留历史)、调度器与手动采集互斥排队、以及每轮采集写入独立目录
(复用早已存在、但 API 层从未转发的 --save_data_path 参数)。
二、快速开始
1. 准备环境
# 依赖(若未安装 uv,本项目的解释器探测会自动回退到 .venv)
python -m venv .venv
.venv/Scripts/python -m pip install -r requirements.txt
.venv/Scripts/python -m playwright install chromium # 非 CDP 模式必需
# 前端
cd webui && npm install && npm run build
2. 启动
.venv/Scripts/python -m api.main # 或 uvicorn api.main:app --port 8080
打开 http://localhost:8080,右上角切换到「监控」。
解释器探测顺序:
uv(若在 PATH)→ 项目.venv→ 当前解释器。/api/env/check使用同一套逻辑,不会出现"检测失败但其实能跑"的情况。
3. 配置登录态(无人值守的前提)
在监控页左下角「小红书登录态」粘贴 Cookie。定时监控不能每次都扫码,必须持久化登录态。
强烈建议先手动扫码登录一次,以播种
browser_data/xhs_user_data_dir, 之后再粘贴 Cookie 才可靠。原因见下方「限制」。
4. 新建监控任务
- 类型
博主:监控其作品,填博主主页链接或纯 ID笔记:批量监控指定内容,填笔记链接或纯 ID
- 目标:每行一个。建议只填纯 ID —— 链接里的
xsec_token会过期,纯 ID 永久有效。 - 间隔:最小 30 分钟。每次运行都要拉起一次浏览器并多次请求平台,间隔过短容易触发风控。
- 每篇评论抓取条数:默认 50。这个值直接决定能发现多少新评论,见下方限制。
任务创建后立即生效,也可随时点「立即运行」手动触发一轮。
二·五、报表
「报表」视图是跨任务的统计,用来回答"这批账号这段时间表现如何"。
筛选:勾选参与统计的任务(默认全选),选日期区间(或点「近 7/30/90 天」)。
两类指标,含义不同,所以分列展示:
| 列 | 含义 |
|---|---|
| 新增作品 / 新增评论 | 该日首次发现的作品数 / 评论条数 |
| 点赞 Δ / 评论 Δ / 收藏 Δ / 分享 Δ | 该日互动增量:Σ(当日末值 − 当日之前最后一次采到的值) |
增量的口径有两个要点:
- 作品首次出现的那天从 0 起算,所以新作品的全部点赞都计入其首次发现日。这样做是为了让"新作品带来了多少赞"这件事可见,而不是把它的既有数据丢掉。
- 某天没采到某篇作品,那天的增量算 0,不会把跨天的增长平摊到每一天。
底部会标明两件事:一是哪些指标无法解析(小红书可能返回 "1.2万" 这类值,解析失败的不会被当成 0 计入,否则会伪造出一个大的负增长),二是评论数受接口限制只覆盖前 N 条。
实现上聚合是在 Python 里做的,不是一条大 SQL。原因:按笔记、按天的"上一个基线值"查询是窗口操作,SQLite 表达起来很别扭,而这里的数据量很小,可读性比压榨查询计划更值钱。
二·六、企业微信通知
在「监控」视图左下角配置 Webhook 地址(企业微信群 → 添加群机器人 → 复制 Webhook 地址)。
两个设计取舍:
- 一轮只发一条汇总,不是每条事件发一条。一次跑出 20 篇新作品时,你收到的是"新增作品 20 篇"加前 10 条标题,而不是 20 条消息。
- 推送失败绝不影响采集。通知是在数据提交之后、用独立会话发送的,任何网络错误只记日志。爬虫跑成功了不会因为 webhook 挂了而被回滚。
触发时机(仅这两类):
- 任务失败 / 疑似登录态失效
- 发现新增作品
指标变化和新增评论不会推送(指标变化太频繁,评论量可能很大)。
任务范围:每个任务在编辑弹窗里有「推送企业微信通知」开关,默认关闭。这样一个 webhook 不会被一堆无关任务刷屏。
- 配置好地址后可以点「发测试」验证,也可以「保存前先测」。
- 地址里的 key 等同凭据,服务端只回传打码形式,要换只能重新粘贴(和 Cookie 一致)。
- 任务卡片上的
last_notified_at(列表接口会返回)可以回答"为什么这轮没收到推送"。
二·七、评论视图与导出
「评论」页默认按作品分组:每篇作品一个可折叠区块,默认只展开最新的一组, 避免打开就是一屏文字。切到「平铺」则是一条流,每条评论下方标注它属于哪篇作品 (封面缩略图 + 标题 + 跳原文链接)。
顶部可按作品筛选,选项里带每篇的评论数:
全部作品
烤面筋热量计算 (33)
孜卷热量计算 (3)
评论与作品的关联是后端 JOIN 出来的(
note_title/note_cover/note_url), 因为评论表本身只存note_id,光看 ID 没有任何可读性。
导出
评论页和报表页都有「导出」按钮,走浏览器下载:
| 端点 | 内容 |
|---|---|
?kind=notes |
作品表(含互动增量列) |
?kind=comments |
评论(含所属作品标题) |
?kind=report |
报表按天汇总 |
- 支持
csv与xlsx - CSV 带 UTF-8 BOM(
utf-8-sig)—— 否则 Excel 打开中文是乱码,这是最常见的投诉 - 下载是页面导航(
window.open),带不了自定义请求头,所以导出依赖 Cookie 鉴权 —— 这也是会话必须存在 Cookie 里的原因之一
二·八、登录与访问控制
面板默认要求登录 —— /api 下的所有接口都需要会话,只有 /api/health、
/api/auth/login、/api/auth/logout 例外。静态资源(页面本身、JS/CSS)不受限制,
否则登录页自己都加载不出来。
首次启动
自动生成一个随机密码并打印在启动日志里(只打印一次):
====================================================================
WebUI 首次启动,已生成登录密码:
94Shn1fMa7dV0jqF
请立即登录并修改。
====================================================================
刻意不做"打开页面让你设置密码"的流程。在局域网监听下,任何能访问到的人 都能抢先设置密码成为管理员;自动生成 + 打印避免了这种抢占,也避免了把自己锁在外面。
忘记密码
设置环境变量 MC_PASSWORD 后重启即可:
MC_PASSWORD=我的新密码 # Linux/macOS
set MC_PASSWORD=我的新密码 # Windows cmd
该变量优先级始终高于数据库里的密码,且不会被写入磁盘。登录后到设置页改成正式密码即可。
环境变量
配置写在项目根目录的 .env(已被 gitignore)。
| 变量 | 默认 | 说明 |
|---|---|---|
MC_HOST |
127.0.0.1 |
监听地址。要局域网访问须设为 0.0.0.0 |
MC_PORT |
8080 |
端口 |
MC_PASSWORD |
空 | 覆盖数据库密码,忘记密码时的恢复通道 |
MC_COOKIE_SECURE |
关 | 面板走 HTTPS 时才开。局域网明文下开启会导致浏览器丢弃 Cookie,表现为登录页反复刷新且无任何报错 |
MC_SESSION_TTL_HOURS |
336 |
登录有效期(14 天) |
MC_TRUST_PROXY |
关 | 仅在受信任的反向代理之后开启,否则 X-Forwarded-For 可被伪造以绕过登录节流 |
MC_CORS_ORIGINS |
空 | 附加的允许来源,逗号分隔 |
MC_CORS_ORIGIN_REGEX |
空 | 允许来源的正则,用于局域网里的 Vite 开发服务器 |
本项目的
.env此前从未被加载过(代码里没有任何load_dotenv调用,尽管python-dotenv一直是依赖、.env.example也一直在仓库里)。现已修复。
安全边界(请务必了解)
- 局域网是明文 HTTP,所以 Cookie 没开
Secure,SameSite=Lax。 这意味着同网段抓包能看到会话令牌。安全边界是"内网 + 密码",不是传输加密。 - 超出可信网络之外请走 HTTPS 反向代理,不要把本服务直接暴露到公网。
- 登录节流是进程内的:重启即清零。单用户单 worker 场景足够;
若日后多 worker,节流会按 worker 各算各的。反向代理下
request.client.host是代理地址, 需配合MC_TRUST_PROXY才能正确识别来源。 /docs、/redoc、/openapi.json已关闭 —— 它们默认不鉴权,等于免费公开整个 API 地图。
会话与登出
- 会话存在服务端(
auth_session表),库里只存令牌的 SHA-256,不存令牌本身 - 退出登录、修改密码都会立即失效(改密码会踢掉所有设备,并给当前设备补发一个新会话)
- 令牌可放在 Cookie(浏览器自动携带,WebSocket 与文件下载都依赖它)
或
Authorization: Bearer(方便脚本调用)
二·九、设置页
原先挤在监控页左下角的 Cookie 与 Webhook 面板已迁到这里,并补齐了采集策略、代理与账号安全。
分区与生效方式
| 分区 | 内容 | 生效时机 |
|---|---|---|
| 登录态 | 小红书 Cookie | 下一轮采集 |
| 通知 | 企业微信 Webhook | 下一条推送 |
| 采集策略 | 新任务默认间隔、默认单轮上限、默认评论条数 | 仅影响新建任务 |
| 采集策略 | 请求间隔、抓二级评论 | 下一轮采集 |
| 采集策略 | 活跃时段 | 定时任务的下一次触发 |
| 代理 | 开关、提供方、池大小、静态地址 | 下一轮采集 |
| 账号安全 | 修改密码 | 立即(其他设备全部掉线) |
活跃时段:只在此时段内触发定时采集,窗口外任务保持到期状态、不会丢失,
窗口一开照常执行。默认 0–23 即全天;也支持跨午夜(如 22–6)。
"仅影响新建任务" 的那几项是刻意的:改了默认间隔不应该把已有任务的间隔一起改掉。
设计要点
- 敏感值永不回传:Cookie 和 Webhook 的
GET只返回「是否已配置」与长度,不返回值。 表单不会把没动过的敏感项覆盖掉。 - 部分更新:只有请求里出现的 key 会被写入。表单一角改动不会清空其他设置。
- 设置项由后端声明:
api/monitor/app_settings.py里的注册表(类型、范围、选项、默认值) 是唯一事实来源,前端按它生成表单。加一个设置项不需要改前端字段清单。 - 越界即拒绝:超出范围、未知的 key、非法的枚举值都返回 400 而不是静默接受。
「扫码登录」入口尚未实现。它需要跑起爬虫子进程、捕获二维码并实时推流, 属于一个独立功能而非设置项,这里不做一个半成品。
二·十、平台切换与能力矩阵
右上角的下拉框统一切换平台,「采集 / 监控 / 报表 / 设置」全部跟着变。选择会记住, 刷新后不会跳回小红书。采集页原来那个平台下拉已移除,避免出现两个事实来源。
已接通 vs 未接通
矩阵里有两个不同的概念,混淆会误导:
| 字段 | 含义 |
|---|---|
crawler_modes / metrics / comment_levels / media |
上游爬虫模块能做什么 |
monitor_wired |
监控层是否已接线 |
7 个平台的爬虫模块都实现了 search / detail / creator,真正的差异在指标上:
| 平台 | 指标 | 评论层级 | 媒体 | 监控接线 |
|---|---|---|---|---|
| 小红书 | 点赞 / 评论 / 收藏 / 分享 | 2 | ✅ | ✅ |
| 抖音 | 点赞 / 评论 / 收藏 / 分享(无播放量) | 2 | ✅ | ❌ |
| 快手 | 点赞 / 播放(无评论、分享、收藏) | 1 | ✅ | ❌ |
| B站 | 点赞 / 播放 / 弹幕 / 评论 / 收藏 / 投币 / 分享(最全) | 2 | ✅ | ❌ |
| 微博 | 点赞 / 评论 / 转发(无收藏) | 2 | ✅ | ❌ |
| 贴吧 | 仅回复数 | 2 | ❌ | ❌ |
| 知乎 | 赞同 / 评论 | 2 | ❌ | ❌ |
要更正一个常见误解:这个代码库里抖音不存播放量(只映射点赞/收藏/评论/分享)。 有播放量的是 B 站,它还有弹幕。
未接通的平台可以选,但各页会显示明确的说明面板,并且创建任务会被直接拒绝:
400 抖音的爬虫已支持,但监控层尚未接通,暂时无法创建监控任务。
而不是接受任务、然后让它永远跑不出数据 —— 那正是之前"博主主页解析失败被误报成登录失效"的同一种静默故障。
设置的两层
| 位置 | 范围 | 内容 |
|---|---|---|
| 左侧导航「设置」 | 按平台 | 登录 Cookie、采集策略、代理 |
| 右上角「系统设置」 | 全局 | 通知、活跃时段、账号安全 |
这不是随便分的:企业微信只有一个群、调度器只有一套时段规则、密码只有一份 —— 把它们放进"小红书专属"的页面里,会让人以为它们是按平台存的。
存储上键名带作用域前缀:platform.<平台>.<项> 与 system.<项>。
旧键会在启动时自动迁移(xhs_cookie → platform.xhs.cookie),
且是幂等的:新键已存在时以新键为准,不会覆盖你后来改的值。
三、必须知道的限制
1. 「新增评论」是近似值 —— 最重要的一条
小红书评论接口 /api/sns/web/v2/comment/page 没有排序参数,只能拿到平台默认排序(热评优先)的
前 N 条。因此:
- 我们只能"每次抓前 N 条做差集",新发布但沉底的评论不会被发现
- N 调大能提高发现率,但请求量线性增长,风控风险上升
- 评论事件区分两种,UI 上也分别标注:
new_comment_posted(新评论):create_time晚于上一轮开始时间,是真·新发布new_comment_seen(新出现评论):只是本轮才进入可见窗口的历史评论
这条限制无法通过调参绕过,是该接口的固有限制。
2. Cookie 失效是「静默失败」
login_by_cookies() 只注入 web_session,而 API 签名还需要 a1 / webId 等;
更麻烦的是cookie 登录不做任何校验 —— 坏 Cookie 不会让进程报错退出,而是
退出码 0、抓到 0 条。
监控层因此把「退出码 0 且 0 条作品」判定为 suspected_auth_failure 并在 UI 上标红,
而不是当成"该博主没发新作品"。这是无人值守场景最容易误报的地方。
本实现额外做了两件事:
- 通过
--inject_all_cookies注入完整 Cookie(默认关闭,保持上游行为不变) - 通过
--cookies_file传 Cookie,避免明文出现在进程列表里
3. 作品窗口被截断
每轮最多采集作品数(默认 20)限定了"该博主的作品"到底指多少条。
UI 会把该上限显示在作品表旁,避免误以为看到了全部。
4. 昵称与用户 ID 已被上游脱敏
store/xhs/__init__.py 落库前调用 mask_nickname() 与 anonymize_user_id(),
存储的是打码昵称与哈希后的 creator_hash,没有真实昵称和 user_id。
这是上游的隐私保护设计,监控层未做改动。
5. 不发「笔记被删」事件
creator 模式只取前 N 条,笔记"消失"多半只是掉出窗口;detail 模式遇到
xsec_token 过期也会失败。二者与"真被删"无法区分,因此不产生删除事件,
改为在作品表里展示 last_seen_at。
6. 定时任务与手动采集互斥
二者共用同一个爬虫子进程。监控任务运行期间点「采集」会被拒绝(返回 400); 反之若有手动采集在跑,到期的监控任务会保持到期状态排队,不会丢失,空闲后自动补上。
四、数据存放
| 内容 | 位置 |
|---|---|
| 监控库(任务/快照/事件/评论/设置/会话) | MySQL,库名由 MYSQL_DB_NAME 指定 |
| 每轮原始 jsonl | data/monitor_runs/{task_id}/{run_id}/{platform}/jsonl/ |
爬虫每轮的原始产出仍然写独立 jsonl 目录,不进 MySQL。 这是差分机制的基础:每轮写在单独目录里,才能算出"这轮新增了什么"。 多轮数据混在同一批表里的话,这个判断就做不到了。
MySQL 配置与安全边界
连接信息写在 .env(已被 gitignore,不会进版本库):
MYSQL_DB_HOST=<数据库地址>
MYSQL_DB_PORT=3306
MYSQL_DB_USER=<账号>
MYSQL_DB_PWD=<密码>
MYSQL_DB_NAME=mediacrawler
真实凭据只写在
.env里(已被 gitignore),不要写进这个文档或任何会提交的文件。
"只操作这个库"由两层保证,缺一不可:
-
数据库授权(真正的保证)。账号应只被授予目标库的权限:
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'MediaCrawler'@'%'; GRANT ALL PRIVILEGES ON `mediacrawler`.* TO 'MediaCrawler'@'%'; FLUSH PRIVILEGES;这样该账号
SHOW DATABASES只能看到目标库,代码就算写错也碰不到别的库。 -
启动自检(防配置写错)。应用启动时会执行
SELECT DATABASE(), 与MYSQL_DB_NAME不符就拒绝启动,而不是往错误的库里写。
字符集:这台服务的服务端和库默认都是 latin1。代码在建表时逐表强制
utf8mb4,不依赖库默认值 —— 否则中文会被拒或变成问号。
连接保活:MySQL 默认 8 小时断开空闲连接,而监控服务是常驻的。
已配置 pool_recycle=3600 + pool_pre_ping,避免"server has gone away"。
表引擎:全部 InnoDB(monitor_run.exit_code 用 BIGINT —— Windows 的退出码是
无符号 32 位,0xC0000142 会溢出有符号 INT)。
从 SQLite 迁移(如有旧数据)
python -m api.monitor.migrate_from_sqlite --dry-run # 先看要迁什么
python -m api.monitor.migrate_from_sqlite # 正式迁移
保留原主键(否则 task_id 关联会错位);目标库非空时会拒绝执行,除非加 --force。
监控库中的 Cookie 为明文存储,这是当前版本的已知取舍。
五、API
所有操作都有对应的 HTTP 接口,UI 只是其中一层封装:
GET /api/monitor/overview 看板汇总
GET /api/monitor/tasks 任务列表
POST /api/monitor/tasks 新建任务
PATCH /api/monitor/tasks/{id} 修改
DELETE /api/monitor/tasks/{id} 删除
POST /api/monitor/tasks/{id}/run 立即运行(后台执行,立即返回)
GET /api/monitor/tasks/{id}/runs 运行历史
GET /api/monitor/notes 作品表(含与上一轮的 Δ)
GET /api/monitor/notes/{id}/series 单篇指标时间序列
GET /api/monitor/comments 评论流(带所属作品;?note_id= 筛选,?group_by=note 按作品分组)
GET /api/monitor/comment-notes 有评论的作品及其条数(评论筛选下拉用)
GET /api/monitor/export 导出(?kind=notes|comments|report&format=csv|xlsx)
GET /api/monitor/events 变化事件流
POST /api/monitor/events/read 标记已读
GET /api/monitor/cookie 登录态健康度(**不返回 Cookie 值**)
POST /api/monitor/cookie 保存 Cookie
DELETE /api/monitor/cookie 清除 Cookie
GET /api/config/platforms 平台能力矩阵(含 monitor_wired,前端据此渲染切换器)
GET /api/settings 设置 + 表单描述(?platform=,敏感值只回状态)
PUT /api/settings 部分更新(只写请求里出现的 key)
GET /api/auth/me 身份探测(401 即未登录)
POST /api/auth/login 登录(发 HttpOnly Cookie)
POST /api/auth/logout 退出
POST /api/auth/password 修改密码(踢掉所有其他设备)
GET /api/monitor/report 报表(?task_id=1&task_id=2&start_date=&end_date=)
GET /api/monitor/webhook 通知配置状态(**只返回打码地址**)
POST /api/monitor/webhook 保存 Webhook 地址
DELETE /api/monitor/webhook 删除 Webhook
POST /api/monitor/webhook/test 发送测试消息
task_id用重复参数而非逗号拼接(?task_id=1&task_id=2);不传表示统计全部任务。
六、故障排查
| 现象 | 原因 / 处理 |
|---|---|
| 任务一直不运行 | 未配置 Cookie(调度器会跳过并保持任务到期);或全局已有采集在跑 |
| 任务标红「疑似登录态失效」 | Cookie 过期。重新粘贴;若反复失败,先手动扫码登录一次播种浏览器 profile |
| 抓到的作品数长期为 0 | 同上;也可能是该博主确实没有作品 |
| 发现不了新评论 | 评论接口无时间排序所致,调大「每篇评论抓取条数」可缓解但无法根治 |
| 首轮没有任何"新增"事件 | 刻意设计:首轮建立基线,全部数据视为已有,不产生变化事件 |