新番时间表这东西,追番党都知道,每周最怕的就是"今天周几、更新了没"这种灵魂拷问。我一开始用的是土办法,手动把每部番的播出时间抄进表格里,定好闹钟去刷。坚持了三个星期就崩了——不是忘了写,是根本刷不过来。后来我索性换了个思路:与其人肉盯,不如写个 Python 爬虫,把某弹幕视频站的新番时间表和热度数据全都拉到本地,自动落库、定时对比、异常了直接推消息到手机上。这篇文章就是把这套"API 逆向版"的全链路拆开讲清楚:从浏览器开发者工具的网络面板里找出时间表背后真正的 JSON 接口,到请求签名参数怎么还原,再到数据怎么建模、监控告警怎么搭。整个项目落地的核心代码加起来三百行出头,不需要申请任何开放平台的开发者资质,纯靠前端接口分析就能跑起来。适合想练爬虫逆向、又不想碰登录态和复杂风控的 Python 初学者,也适合想把追番仪式感做成数据面板的玩家。
1. 找数据源:时间表页面上那些看不见的 JSON 接口
很多人第一次写爬虫会直接用 requests 去抓 HTML,然后把页面里写死的标题、日期用正则或者 XPath 抠出来。这个方案在新番时间表这种页面上基本走不通,因为页面里的渲染内容大部分是 JavaScript 动态拼出来的,源码里要么只有几个空的容器标签,要么就是一堆压缩过的脚本逻辑。真正有价值的数据,全藏在浏览器悄悄发出的 XHR 请求里。
1.1 网络面板三分钟定位真实数据链路
打开目标平台的时间表页面,按 F12 切到开发者工具,选 Network 标签,然后在筛选栏里勾上 Fetch/XHR。这一步很关键,因为页面上所有异步数据请求都会被归到这个类别,过滤掉图片、字体、样式这些噪音,剩下能看的就那么几条。此时刷新一下页面,观察请求列表,你会发现有几个响应类型是 JSON 的接口,其中数据量最大的那个基本就是时间表数据源。
我把鼠标移到时间表按"今天""明天""本周"切换时,网络面板会同步弹出新的请求,这就是典型的按日期维度查询的接口。点开这条请求的 Preview 或者 Response,能看到一个结构清晰的 JSON 对象,外层一般有两个字段用于区分业务状态,真正的数据在 data 里。里面又会按日期把条目分组:每一天对应一个数组,每个数组里是当天的番剧列表,每个条目带着番剧 ID、标题、简介、播出状态、开播时间戳这些字段。拿到这一步,爬虫的数据源就算锁定了。
我踩过的一个坑是:一开始图省事,想直接从 DOM 节点里提取数据,因为页面上看着清清楚楚。结果发现页面上展示的"开播时间"是被格式化过的字符串,比如"周三 22:30",但接口里给的是 Unix 时间戳,维度完全不同。页面字段还经常带一些运营标签和渲染用的样式类,混在数据里特别脏。所以我强烈建议,凡是遇到动态渲染的页面,优先从网络面板里找 JSON 接口,而不是去解析渲染后的 DOM。
1.2 两个核心接口的返回结构拆解
这套监控体系需要的接口不多,核心就两个。一个是时间表接口,返回的是当日或者一周的番剧排期;另一个是热度详情接口,输入某个具体视频的 ID(比如某部番剧某一集的标识),返回播放量、弹幕数、点赞数、投币数、收藏数之类的指标。热度接口往往还不是一次请求拿全,可能需要根据视频携带的分组 ID 去拉取详情,但返回结构都大差不差。
时间表接口的条目长这样(做了字段精简,但结构保持一致):
{ "season_id": 9988, "title": "虚构异世界冒险", "pubtime": 1732958400, "week": 3, "episode": "第3集", "status": "播出中" }热度接口的返回会更细一些,一个视频下面通常带着几十个指标字段,但真正高频关注的也就是那五六个。我这个项目只挑了播放(view)、弹幕(danmaku)、点赞(like)、投币(coin)、收藏(favorite),再加一个评分(score),其他字段忽略掉,既减少存储压力又够用。
看返回结构时有一个细节值得注意:有些接口会返回嵌套的分组,比如先把番剧按"连载中""已完结"分组,再在组内给条目列表;热度数据也可能同时返回"当日统计"和"累计统计"两套数值。我在写解析函数时没有写死层级,而是先把 JSON 整体打印一次,手工确认关键字段在哪个位置,然后用逐级 get 的方式取数据。这样万一后续平台调整字段,至少报错的地方会很容易定位。
import requests API_BASE = "https://api.<目标站点域名>" def fetch_timeline(date_str: str) -> list[dict]: resp = requests.get( f"{API_BASE}/x/web-interface/timeline", params={"date": date_str}, headers={"User-Agent": "Mozilla/5.0 ...", "Referer": "https://www.<目标站点域名>/"}, timeout=10, ) json_body = resp.json() if json_body.get("code") != 0: raise RuntimeError(f"接口返回异常: {json_body}") return json_body["data"]["timeline"]这个方法里的请求头先粗略给了一个可用的 UA 和 Referer,真实项目中请求头的完整度比你想象的重要得多,后面会专门展开讲。第一步先把接口跑通,能打印出数据,逆向框架就已经立住了。
2. 身份伪装与签名计算:为什么直接请求会返回风控提示
你以为把接口地址抄下来、直接 requests.get 一下就能拿到数据?太天真了。我第一次请求就吃了个闭门羹,返回的错误码是参数校验失败,接口文案大致意思是"请求参数错误或者签名不合法"。这说明平台不是简单地把数据放在公网接口里等大家抓,而是对请求身份和参数做了双重校验,其中一道防线的核心就是动态签名。
2.1 请求头与 Cookie 的"浏览器气味"
服务端风控引擎的第一道关卡不是签名,而是请求头的"气味":你的 User-Agent 像不像一个真实浏览器、Referer 是否来自时间表页面、有没有平台下发的设备标识 Cookie。如果是用 Python 原生的 requests 默认 UA 去请求,那种"来自代码"的指纹太明显了,通常在签名校验之前就会被直接拦下。
我在项目里维护了一个完整的浏览器请求头模板,包含这些内容:
HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", "Referer": "https://www.<目标站点域名>/", "Origin": "https://www.<目标站点域名>", "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }同时用 requests.Session 来持有 Cookie,而不是每次裸请求。第一次访问站点时,服务端会在首页种下一个设备标识 Cookie,这个值虽然不参与签名,但会作为风控模型的参考坐标。用同一个 Session 连续请求,Cookie 会越来越像一个自然用户,风控给的宽松度也高一些。
有一个很容易忽略的点:如果本地机器的时间和服务器时间偏差太大,签名接口的时间戳校验会直接不通过。我在排查时发现自己本机时区设置没问题但系统时间快了十分钟,接口连续报错,后来把时间同步了才恢复。建议正式跑之前先ntpdate一次系统时钟。
2.2 参数签名算法还原:一次完整实现
比请求头更硬的一道防线是签名参数。我在 Network 面板里看到每个请求的最后都挂着一串奇怪的参数,比如wts和w_rid,前者是一个秒级 Unix 时间戳,后者是一段 32 位的十六进制字符串。单独看这俩参数看不出门道,但只要把浏览器请求的参数按字典序重新排列,再把一串固定的字符拼到末尾,做一次 MD5,顿时就明白了——这就是一个典型的"参数排序 + 混淆盐 + 哈希摘要"签名。
盐值不是写死的,而是由平台另外开放的一个导航接口下发。请求导航接口时,服务端会返回两段随机字符,分别叫它img_key和sub_key。前端拿到这两串字符后,中间再拼接一段固定的置换表,从拼接结果里抽取 32 位,得到最终的盐字符串。整个过程可以完整写成 Python 函数:
import hashlib import time from urllib.parse import urlencode, quote MIXIN_KEY_ORDER = [46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 15, 44, 33, 52, 39, 25, 49, 6, 9, 59, 4, 38, 26, 16] def mixin_key(keys: str) -> str: """把 img_key 和 sub_key 拼接后,按固定置换表抽出最终盐串。""" return "".join(keys[i] for i in MIXIN_KEY_ORDER if i < len(keys)) def sign_params(params: dict, img_key: str, sub_key: str) -> dict: p = dict(params) p["wts"] = int(time.time()) ordered = dict(sorted(p.items(), key=lambda item: item[0])) raw = urlencode(ordered) + mixin_key(img_key + sub_key) p["w_rid"] = hashlib.md5(raw.encode()).hexdigest() return p看到这里你应该能明白,这套算法的核心在于置换表。平台只要不想让人爬,随时可以更新置换表,那么旧抓包工具就全线失效。所以正规做法是不要把置换表硬编码到业务代码里,而是通过首屏页面的脚本资源动态解析出来。我这个项目为了快速上线,先硬编码了一份当前生效的表,但在代码注释里标清楚了更新来源,后续如果接口报签名错误,第一步就是去页面脚本里重新抓一份新的置换表。
完整的请求构建如下:
session = requests.Session() session.headers.update(HEADERS) session.get("https://www.<目标站点域名>/", timeout=10) nav = session.get(f"{API_BASE}/x/web-interface/nav", timeout=10).json() img_key = nav["data"]["wbi_img"]["img_url"].rsplit("/", 1)[-1].split(".")[0] sub_key = nav["data"]["wbi_img"]["sub_url"].rsplit("/", 1)[-1].split(".")[0] params = sign_params({"season_id": 9988, "page": 1}, img_key, sub_key) resp = session.get(f"{API_BASE}/x/web-interface/season_view", params=params, timeout=10)2.3 签名失效的常见原因
这套流程里出错率最高的环节,排第一的是参数排序。很多人在构造签名时拿原始的params直接拼接,却忽略了请求发出时requests库可能会对字典做一些编码调整,尤其是有中文参数的情况——拼签名的字符串和实际发送的字符串对不上,哈希值自然就不对。解决办法是签名用的排序和编码,必须和真正发送请求时用完全一样的规则,我全部统一进了sign_params函数里,避免两套逻辑。
排第二的是时间戳窗口。平台通常只接受两分钟内的wts,所以签名函数里要用当前时间实时生成,不能缓存一个"万能签名"到处用。我用脚本连续循环测试了一下,同一个签名参数在五分钟之后请求,返回的就是风控错误码。
排第三的是轮换 Cookie。如果是把别人的请求头直接抄进代码里,客户端本地没有任何浏览行为累积,风控会把这种"孤立请求"标成高风险,再正确的签名也会被拦下来。所以一定先用session.get(首页)把设备标识 Cookie 养出来,再开始拉数据。
排第四的是网络环境本身的出口干净程度。数据中心 IP 的请求特征和住宅 IP 差异很大,平台对前者会明显严格。如果你的脚本部署在云主机上,协商一个合理的请求频率更重要,哪怕慢一点也别把出口 IP 的声誉搞坏。
3. 数据建模:把时间表与热度存成可分析的时间序列
接口拿到了,能打印出数据了,但这离"监控"还差十万八千里。监控的前提是历史数据:你得知道上周这集视频的播放量是多少,今天涨了多少,涨幅是否异常。如果每次只抓当前值然后丢到终端里看一眼,那不叫监控,那叫刷新页面。
3.1 番剧表与热度快照表的设计思路
我建了两张表,一张存番剧的基本信息,另一张存热度快照。番剧表的主键直接用平台的 season_id,避免重复。热度快照表单独一张,每次抓取不覆盖历史,而是追加一行带crawled_at时间戳的记录。这样做的好处是:你可以画出任何一部番剧的播放量、弹幕数随时间变化的曲线,也可以做周同比、日环比的差值分析。
CREATE TABLE IF NOT EXISTS bangumis ( season_id INTEGER PRIMARY KEY, title TEXT NOT NULL, pub_week INTEGER, pub_time TEXT, update_status TEXT, season_follow INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS heat_snapshots ( id INTEGER PRIMARY KEY AUTOINCREMENT, season_id INTEGER NOT NULL, ep_id INTEGER NOT NULL, view_count INTEGER, danmaku_count INTEGER, like_count INTEGER, coin_count INTEGER, favorite_count INTEGER, score REAL, crawled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );表结构看着简单,但有几个细节是实际用下来才想明白的。第一,热度快照必须带ep_id,因为一部番剧每周更新一集,不同集的播放量基数天差地别,只拿 season_id 聚合会把不同集的数字混在一起,趋势完全失真。第二,crawled_at别交给应用层生成,直接让数据库写了默认时间,这样不管脚本重试几次、数据插入延迟多久,记录的时间都是插入时刻的真实时间。
3.2 入库与增量更新的实现骨架
入库用 Python 自带的 sqlite3 就能跑,不需要一开始就上 MySQL。SQLite 单文件、零运维、出结果快,这套流程跑通之后再迁移也容易。写入时我用了INSERT ... ON CONFLICT做幂等处理,番剧信息重复抓取时不会插入重复行,而是更新标题和状态这些可变字段。
import sqlite3 def upsert_bangumi(conn: sqlite3.Connection, item: dict) -> None: conn.execute( """ INSERT INTO bangumis (season_id, title, pub_week, pub_time, update_status) VALUES (:season_id, :title, :pub_week, :pub_time, :update_status) ON CONFLICT(season_id) DO UPDATE SET title = excluded.title, update_status = excluded.update_status """, item, ) conn.commit() def insert_heat(conn: sqlite3.Connection, rows: list[tuple]) -> None: conn.executemany( """ INSERT INTO heat_snapshots (season_id, ep_id, view_count, danmaku_count, like_count, coin_count, favorite_count, score) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, rows, ) conn.commit()最开始写增量逻辑时我犯过一个愚蠢的错误:每次拉完数据,先用SELECT查一遍数据库里是否已经有当天数据,有就跳过,没有才插入。结果在并发任务多跑几次之后,出现重复记录。后来才想明白,"先查再插"天然存在竞态条件,应该直接依靠数据库的唯一约束去重,而不是靠应用层的判断。给heat_snapshots表加了一个联合唯一索引(ep_id, crawled_at),重复的插入直接命中冲突,彻底杜绝了重复行。
3.3 从快照数据里能挖出的几个信号
数据落库之后,最直接的产出就是一张"热度排行"查询。比如想知道最近一周每部番剧的单集播放增量,一条 SQL 就能算出来:
SELECT h.season_id, b.title, MAX(h.view_count) - MIN(h.view_count) AS view_delta FROM heat_snapshots h JOIN bangumis b ON b.season_id = h.season_id WHERE h.ep_id = (SELECT MAX(ep_id) FROM heat_snapshots WHERE season_id = h.season_id) AND h.crawled_at >= datetime('now', '-7 day') GROUP BY h.season_id ORDER BY view_delta DESC;但监控的价值不只是排名,而是发现"异常"。我在本地做了个很简单的异常检测:用前七天的快照算出一个移动平均和标准差,如果当天某个指标上涨幅度超过三倍标准差,就认为发生了热度突刺。这个办法虽糙,但对付新番开播、完结撒花、口碑发酵这些常见场景足够用了。真遇到作品崩盘、全网都在吐槽的时候,热度突刺通常发生在弹幕数和评论数上,播放量反而滞后,所以我实际是把播放增量、弹幕增量两个指标一起看,任何一个触发告警都会记录,但不急着推送。
4. 定时任务与告警通道:把爬虫变成真正的"监控"
数据管得再好,如果爬虫脚本只能手动执行,那就还是半个残废。好项目讲究的是"无人值守":定时拉数据、定时比对、异常自动通知到手机。这部分看着简单,却是整个项目里最考验工程细节的环节。
4.1 调度选型:APScheduler 还是系统 cron
我先后试过两种调度方案。第一种是在 Python 进程里用 APScheduler,好处是任务逻辑和脚本主体在同一个进程里,方便共享 Session 和数据库连接;坏处是进程一旦被系统杀掉,调度也不跟着没了,需要额外维护一个守护进程。
我的最终选择是把任务拆成独立命令行入口 + 系统 cron 或者 systemd timer 来触发。脚本本身是一个main()函数,接收--fetch、--diff、--notify这样的参数,每个参数对应一个独立动作。然后 crontab 里写上:
*/10 * * * * cd /path/to/project && /usr/bin/python3 main.py --fetch */10 * * * * cd /path/to/project && /usr/bin/python3 main.py --diff这样的好处是每个任务挂掉都不会影响其他任务,而且日志直接输出到 stdout,由 cron 统一处理重定向,排查问题只盯一份日志文件就行。时间表接口十到十五分钟拉一次完全足够,热度数据也不需要秒级更新,太频繁反而容易触发风控。
4.2 差异对比与消息推送的实现
光拉数据存库还不够,监控的核心动作是"对比"。每次抓完时间表之后,把新一轮的数据和数据库里上一次记录做 diff,发现某个番剧的update_status从"未开播"变成了"播出中",或者集数从第 3 集变成了第 4 集,立刻把这个变化交给推送模块。
推送通道我选的是通用的 webhook 方案。现在各种群里都能建机器人,机器人提供一个 webhook 地址,你用 POST 请求提交一条 JSON 就能把消息推进群,我的操作界面简单粗暴:
import json import requests def notify_webhook(message: str) -> None: payload = {"msgtype": "text", "text": {"content": message}} requests.post( WEBHOOK_URL, headers={"Content-Type": "application/json"}, data=json.dumps(payload), timeout=5, )实际使用时发现,推送失败是个很容易被忽视的坑。webhook 地址偶尔会有网络抖动或者被限流,如果推送模块抛异常,会连累主流程直接卡住。我的处理方案是通知函数单独用 try-except 包住,推送失败只记录日志,不让它影响下次抓取。毕竟监控的意义是长期稳定运行,推送偶尔丢一两次影响不大,抓取和落库的完整性才是第一位的。
4.3 告警阈值怎么定才不会天天被骚扰
告警这件事,最怕的就是"狼来了"。阈值设得太低,每天推几十条消息,很快就免疫了;阈值设得太高,异常事件又容易漏。我第一个版本把告警阈值写成固定数值,比如播放增量超过 10 万就告警,结果完全没法用——热门番剧和冷门番剧的量级差了几百倍,固定阈值要么对热门无效,要么对冷门天天误报。
后来改成按番剧自身的量级来算,以最近七天同一集的平均热度为基线,超过基线的某个百分比才触发。新番开播第一集的基线是零,就直接跳过该规则,另外做一个"首集热度突破平台前百分之多少"的排名类告警。后面又加了一条硬经验:任何告警规则触发后,不立即推送,先等下一次采样再确认一次,连续两次都超阈值才推。这样能过滤掉平台侧临时数据校正造成的假突刺,虽然告警会延迟几分钟,但换来了高得多的准确率。
5. 上线后踩过的坑和补丁方案
这部分是我最想讲的。很多东西文档里不会写,只有把脚本挂在服务器上跑个三五天才会暴露。
5.1 时区与发布时间:你以为的对其实差八小时
时间表接口返回的发布时间是 Unix 时间戳,本身没有时区概念。问题出在我本地解析和展示的时候,直接用了datetime.fromtimestamp(),这个函数默认读取操作系统本地时区。我的服务器时区设的是 UTC,而平台页面上展示的时间是东八区,于是所有"几点更新"都差了八个小时。排查了十分钟才反应过来,这个锅只能自己背。
统一的做法是在项目里全局用 UTC 存储和处理,只在输出展示或者推送消息时转成东八区:
from datetime import datetime, timezone, timedelta CST = timezone(timedelta(hours=8)) def to_display_time(ts: int) -> str: return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone(CST).strftime("%Y-%m-%d %H:%M")时间字段还有第二个坑:平台返回的"播出时间"有时是计划的排期时间,实际播出可能早几分钟或晚几分钟,比如电视放送经常因为插播广告延迟。所以我做差异对比时,对"播出中"状态的判断不是严格等于某个时间点,而是只要发现集数变化就算更新。
5.2 业务字段的"暗中变化"与容错设计
平台接口最大的特点就是它不会因为在跟你的爬虫搞合同就提前通知——字段说变就变。我遇过一次比较轻的,某个番剧热度接口把favorite字段从整数变成了字符串,解析入库时 int() 直接抛异常。还有一次更隐蔽,某部番剧没有评分,接口返回的不是"空"而是直接省略了这个 key,我当时字典取了data["score"],在没有评分的那一集直接炸了。
所以写解析逻辑时,对所有字段都用dict.get(key, 0)或者 try-except 包一层,确保个别数据异常不会拖垮整个任务。数据入库前用类型转换函数统一处理:
def safe_int(value, default=0) -> int: try: return int(value) except (TypeError, ValueError): return default进阶一点,可以在项目里挂一个 JSON Schema 校验,响应结构变化时先报错而不是默默吞掉。我用的是最轻量的做法:抓包响应进来后,先手动assert几个关键字段存在,不满足就写日志并跳过本轮,避免因为一次异常字段变化把历史数据写脏。
5.3 抓取频率、频率限制与自我调节
新番时间表的内容变化很慢,十分钟拉一次足够,超过这个频率纯粹是在给自己制造风控风险。热度接口稍微复杂一点,因为每部番剧都是一个独立请求,如果要监控几十部番剧,一轮下来就是几十个请求。我选择了全局限速:每两个请求之间 sleep 两到三秒,单轮任务不超过三分钟。看起来慢,但完全可以接受,毕竟这是监控而不是实时直播。
当一个请求返回风控错误码时,立刻停下手里的循环,切到指数退避策略:第一次等三十秒,第二次等一分半,第三次等五分钟,还不行就直接停止本轮任务并发出告警。千万别在风控触发时继续加速请求,那是在火上浇油。我见过有人在遇到频率限制后反而调高并发,结果就是 IP 被封得更彻底,恢复时间以天计。
另外一个容易被忽略的调节项是缓存:时间表接口一天只请求几次即可,热度接口则做一次内存级去重,同一个视频短时间内重复拉跳过。这样就算调度配置写错了导致任务每五分钟跑一次,也不会真的对平台产生太大压力。
这套监控跑了一个多月,最大的感受不是代码多难写,而是"持续运转"这件事本身比写代码难。定时任务挂掉、接口字段变更、推送通道抖动、时区换算错误,每一个问题单独看都很小,叠加在一起却能吞噬大量精力。我现在已经把脚本做成了 systemd 服务,日志自动轮转,每轮任务结束在数据库里写一条心跳记录,再跑一个看心跳的判断脚本——如果连续两轮没有心跳,就说明任务挂了,这时候才轮到人出场。
最后分享一个后续扩展的方向:热度快照有了,时间表有了,接下来可以给每部番剧计算"弹幕情感倾向",把评论区或者弹幕里高频出现的关键词作为热度预警的前置信号。如果你也打算给自己的小工具加上类似能力,建议一开始就在热度表里预留一个ext字段,存 JSON 字符串,后面想加新特征时不用重新跑历史数据。做监控系统,最值钱的就是时间序列本身,越早开始积累越好。