猫眼票房监控系统实战:z3 约束求解破解字体混淆反爬
项目地址(Gitee):https://gitee.com/Touari/boxoffice-monitor.git
项目定位:破解猫眼专业版字体混淆反爬 ,实时抓取解码票房,提供监控/报表/API 全链路服务
技术栈:Python 3.11 · Playwright 1.62 · z3-solver 5.1 · FastAPI 0.133 · SQLite
所有数据均为实测(2026-08-20)
免责声明
⚠️ 免责声明
- 本项目(boxoffice-monitor)仅用于技术学习与学术研究,目的是研究网页反爬机制与约束求解算法,请勿用于任何商业用途或非法用途。
- 本项目抓取的数据(票房信息)版权归**猫眼(北京猫眼文化传媒有限公司)**及相关权利方所有,请勿对抓取数据进行二次分发或商业化利用。
- 请遵守目标网站(piaofang.maoyan.com)的**服务条款(ToS)**与相关法律法规,控制抓取频率,勿对目标网站造成访问压力。
- 使用本项目产生的任何后果由使用者自行承担,作者不对因使用本项目而导致的任何直接或间接损失负责。
- 如权利方认为本项目侵犯其合法权益,请联系作者删除相关内容。
一、项目概述
1.1 做什么
破解猫眼专业版(piaofang.maoyan.com)的字体混淆反爬,实时抓取并解码电影票房数据,支持:
- ✅ 单次查询(
one_shot):一条命令查全部/单部电影实时票房 - ✅ 定时监控(
watch):每 N 秒抓一帧自动存库 - ✅ 历史存储(SQLite):票房快照按时间累积
- ✅ 趋势报表(
report):时间窗口统计 + Mermaid 折线图 - ✅ HTTP API(FastAPI):REST 接口供其他程序/Agent 调用
- ✅ 数据库查询(
db_query):分组/过滤/原始三种视图 - ✅ CSV 导出(
export_csv):单帧 + 全历史合并 + 增量追加
1.2 为什么难(三道反爬防线)
| 防线 | 说明 | 破解方案 |
|---|---|---|
| ① 字体混淆 | 票房数字显示为私有区字符 (U+E000~U+F8FF),每次请求随机换一套字体 | 不碰字体文件,改走数据源:明文大盘做锚点 + z3 约束求解 |
| ② 签名接口 | 数据接口 dashboard-ajax 带动态 signKey 签名,curl 直连必失败 |
Playwright 驱动真实浏览器,签名由浏览器自动生成,只拦截响应 |
| ③ 实时刷新 | 页面 5 秒自动刷新,数据时刻敏感 | 拦截时 not _RAW 保证只取第一帧,大盘与电影列表同帧 |
1.3 破解思路(一句话)
不碰字体文件 (每次变,无法离线破解),改走数据源 :用真实浏览器拦截接口响应,用明文大盘做锚点 ,用 z3 约束求解把 "占比 × 大盘 = 票房" 这个恒等式变成方程组,自动解出每个乱码字符代表的数字。
1.4 实测结果概览
2026-08-20 全天实测,两帧样例:
| 时刻 | 大盘(综合) | 牛来票房 | 冠军电影 |
|---|---|---|---|
| 12:10(参考项目帧) | 4977.5 万 | 188.81 万 | 欢迎来龙餐馆 1533.26 万 |
| 17:38(本项目实测) | 10763.7 万 | 351.91 万 | 欢迎来龙餐馆 4147.59 万 |
| 18:53(本项目实测) | 12330.4 万 | 384.66 万 | 欢迎来龙餐馆 4907.45 万 |
| 19:25(本项目实测) | 13131.3 万 | ~393 万 | 欢迎来龙餐馆 5313.39 万 |
全部电影解码自洽(真实占比均落在显示占比的 floor 区间内),17 部电影 20+ 时间帧 340+ 条记录。
二、系统架构
#mermaid-svg-4V725MfRUIvTZpQR{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4V725MfRUIvTZpQR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4V725MfRUIvTZpQR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4V725MfRUIvTZpQR .error-icon{fill:#552222;}#mermaid-svg-4V725MfRUIvTZpQR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-4V725MfRUIvTZpQR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4V725MfRUIvTZpQR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4V725MfRUIvTZpQR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4V725MfRUIvTZpQR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4V725MfRUIvTZpQR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4V725MfRUIvTZpQR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4V725MfRUIvTZpQR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-4V725MfRUIvTZpQR .marker.cross{stroke:#333333;}#mermaid-svg-4V725MfRUIvTZpQR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4V725MfRUIvTZpQR p{margin:0;}#mermaid-svg-4V725MfRUIvTZpQR .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-4V725MfRUIvTZpQR .cluster-label text{fill:#333;}#mermaid-svg-4V725MfRUIvTZpQR .cluster-label span{color:#333;}#mermaid-svg-4V725MfRUIvTZpQR .cluster-label span p{background-color:transparent;}#mermaid-svg-4V725MfRUIvTZpQR .label text,#mermaid-svg-4V725MfRUIvTZpQR span{fill:#333;color:#333;}#mermaid-svg-4V725MfRUIvTZpQR .node rect,#mermaid-svg-4V725MfRUIvTZpQR .node circle,#mermaid-svg-4V725MfRUIvTZpQR .node ellipse,#mermaid-svg-4V725MfRUIvTZpQR .node polygon,#mermaid-svg-4V725MfRUIvTZpQR .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-4V725MfRUIvTZpQR .rough-node .label text,#mermaid-svg-4V725MfRUIvTZpQR .node .label text,#mermaid-svg-4V725MfRUIvTZpQR .image-shape .label,#mermaid-svg-4V725MfRUIvTZpQR .icon-shape .label{text-anchor:middle;}#mermaid-svg-4V725MfRUIvTZpQR .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-4V725MfRUIvTZpQR .rough-node .label,#mermaid-svg-4V725MfRUIvTZpQR .node .label,#mermaid-svg-4V725MfRUIvTZpQR .image-shape .label,#mermaid-svg-4V725MfRUIvTZpQR .icon-shape .label{text-align:center;}#mermaid-svg-4V725MfRUIvTZpQR .node.clickable{cursor:pointer;}#mermaid-svg-4V725MfRUIvTZpQR .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-4V725MfRUIvTZpQR .arrowheadPath{fill:#333333;}#mermaid-svg-4V725MfRUIvTZpQR .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-4V725MfRUIvTZpQR .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-4V725MfRUIvTZpQR .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4V725MfRUIvTZpQR .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-4V725MfRUIvTZpQR .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4V725MfRUIvTZpQR .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-4V725MfRUIvTZpQR .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-4V725MfRUIvTZpQR .cluster text{fill:#333;}#mermaid-svg-4V725MfRUIvTZpQR .cluster span{color:#333;}#mermaid-svg-4V725MfRUIvTZpQR div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-4V725MfRUIvTZpQR .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-4V725MfRUIvTZpQR rect.text{fill:none;stroke-width:0;}#mermaid-svg-4V725MfRUIvTZpQR .icon-shape,#mermaid-svg-4V725MfRUIvTZpQR .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4V725MfRUIvTZpQR .icon-shape p,#mermaid-svg-4V725MfRUIvTZpQR .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-4V725MfRUIvTZpQR .icon-shape .label rect,#mermaid-svg-4V725MfRUIvTZpQR .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4V725MfRUIvTZpQR .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-4V725MfRUIvTZpQR .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-4V725MfRUIvTZpQR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 服务层
存储层
解码层
采集层
数据源(外部)
dashboard-ajax
同帧响应
猫眼专业版
piaofang.maoyan.com
fetcher.py
Playwright 无头浏览器
parser.py
HTML实体→码点token
decoder.py
z3 约束求解
models.py
MovieBox / Frame
database.py
SQLite 快照表
exporter.py
CSV / Markdown
api/server.py
FastAPI
scripts/*.py
5 个 CLI
分层原则:采集 → 解码 → 存储 → 服务,单向依赖,下层不依赖上层。
三、核心算法:z3 约束求解解码(项目灵魂)
3.1 问题形式化
一帧数据(同一接口响应)里:
- 每部电影有一个乱码票房串 ,如牛来:
.- 整数部分
f11c ef28 ea6f ef28(4 位,单位万) - 小数部分
f11c f7d2(2 位)
- 整数部分
- 每个码点(如
f11c)代表一个数字 0-9,但映射未知且非双射 (实测数字 5 有两个码点ef74/e274) - 每部电影有显示占比 rate(如 38.1%,floor 截断显示)
- 接口里有明文大盘 total_box(如 12330.4 万)
3.2 三个关键事实(全部实测验证)
- 同帧内同一码点 = 同一数字(一个 glyph 不可能表示两个数字)
- 占比是 floor 截断显示 :真实占比 ∈ [显示值, 显示值 + 0.1)。例:牛来真实 3.793% → 显示 3.7%(不是四舍五入的 3.8!)
- 票房 = 大盘 × 真实占比 → 票房落在明确区间
3.3 z3 建模(数学表达)
把"猜字符含义"变成"解约束方程组":
变量:Vc∈{0,1,...,9},∀c∈码点集合整数位展开:Im=∑i10len−i−1⋅Vti小数位展开:Fm=∑j10len−j−1⋅Vtj票房:boxm=Im+Fm/100(单位:万)约束:T⋅ratem−1 ≤ 100Im+Fm < T⋅(ratem+0.1)+1首位约束:Vt0≠0 \begin{aligned} & \text{变量}:\quad V_c \in \{0,1,\dots,9\}, \quad \forall c \in \text{码点集合} \\ & \text{整数位展开}:\quad I_m = \sum_{i} 10^{len-i-1} \cdot V_{t_i} \\ & \text{小数位展开}:\quad F_m = \sum_{j} 10^{len-j-1} \cdot V_{t_j} \\ & \text{票房}:\quad box_m = I_m + F_m / 100 \quad (\text{单位:万}) \\ & \text{约束}:\quad T \cdot rate_m - 1 \;\le\; 100 I_m + F_m \;<\; T \cdot (rate_m + 0.1) + 1 \\ & \text{首位约束}:\quad V_{t_0} \neq 0 \end{aligned} 变量:Vc∈{0,1,...,9},∀c∈码点集合整数位展开:Im=i∑10len−i−1⋅Vti小数位展开:Fm=j∑10len−j−1⋅Vtj票房:boxm=Im+Fm/100(单位:万)约束:T⋅ratem−1≤100Im+Fm<T⋅(ratem+0.1)+1首位约束:Vt0=0
代码实现:
python
from z3 import Int, Solver, sat
def decode_frame(total_box: float, movies: list[dict]) -> dict[str, MovieBox]:
chars = sorted({tok for mv in movies for tok in mv["ip"] + mv["fp"]})
s = Solver()
V: dict[str, BoolRef] = {c: Int(c) for c in chars}
for v in V.values():
s.add(v >= 0, v <= 9) # 非双射:不要求 Distinct
for mv in movies:
ie = 0
for tok in mv["ip"]:
ie = ie * 10 + V[tok]
fe = 0
for tok in mv["fp"]:
fe = fe * 10 + V[tok]
s.add(V[mv["ip"][0]] != 0) # 整数首位非 0
lo = total_box * mv["rate"] - 1
hi = total_box * (mv["rate"] + 0.1) + 1
s.add(100 * ie + fe >= lo, 100 * ie + fe < hi)
if s.check() != sat:
raise DecodeError("z3 无解:数据可能跨帧(页面 5 秒刷新),请重新抓取")
m = s.model()
result: dict[str, MovieBox] = {}
for mv in movies:
iv = int("".join(str(m[V[t]].as_long()) for t in mv["ip"]))
fv = int("".join(str(m[V[t]].as_long()) for t in mv["fp"]))
box = iv + fv / 100.0
rate_real = box / total_box * 100
result[mv["name"]] = MovieBox(
name=mv["name"], box=box,
rate_display=mv["rate"], rate_real=rate_real,
sum_box=mv["sum_box"], rank=mv["rank"],
)
return result
3.4 为什么"每次刷新换字体"也不怕
约束里不依赖任何固定码点值 ,只依赖 "票房 = 大盘 × 占比" 这个恒等式。每次刷新后重新抓帧、重新求解,自动适应新映射。实测两次抓帧码点完全不同(e583/ec4b/eba2/f16b vs f11c/ef28/ea6f),z3 照样秒解。
3.5 为什么必须"同帧"
nationBoxInfo(大盘)和 movieList(电影列表)如果来自不同请求(间隔 5 秒),大盘数值与占比不同步 → 约束矛盾 → z3 无解。抓帧器只拦截同一次响应里的两个字段 (not _RAW 保证只取第一帧)。
3.6 解码器验证(三把尺子)
| 验证项 | 断言 | 意义 |
|---|---|---|
| 锚点验证 | 牛来 ≈ 188.81 万(样例帧) | 解码结果与已知真值一致 |
| floor 自洽 | 全部电影 ratedisplay≤ratereal<ratedisplay+0.1rate_{display} \le rate_{real} < rate_{display}+0.1ratedisplay≤ratereal<ratedisplay+0.1 | 解码结果符合"floor 截断"规则 |
| 排名单调 | 解码票房与页面排名单调一致 | 防止"解出来但排序错" |
四、抓帧器设计(fetcher.py)
python
def fetch_raw_json(timeout_ms: int = 15000) -> dict:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
def on_response(resp):
if "dashboard-ajax" in resp.url and resp.status == 200 and not _RAW:
try:
_RAW["data"] = resp.json()
except Exception:
pass
page.on("response", on_response)
page.goto(DASHBOARD_URL, wait_until="networkidle")
page.wait_for_timeout(timeout_ms)
browser.close()
return _RAW["data"]
设计要点:
| 要点 | 说明 |
|---|---|
| 不自己发请求 | 签名由浏览器完成,只拦截 |
not _RAW |
只存第一帧,避免 5 秒自动刷新覆盖 |
try/except pass |
非 JSON 响应(重定向/空响应)静默跳过 |
模块级 _RAW |
回调闭包与主流程共享数据的容器 |
| 自动检测浏览器目录 | _BROWSER_CANDIDATES 依次尝试环境变量/D:\ai/E:\ai,无需手动配置 |
| headless=True | 无头模式(服务器场景标配) |
实测单次抓帧耗时 4.4~4.8 秒 :浏览器启动 ~1s + 页面加载 ~2s + 拦截等待 ~1s。
timeout_ms默认 15000 是"兜底超时",实际networkidle阶段请求就已拦截。
执行时序:
猫眼服务器 Chromium(无头) fetch_raw_json() 猫眼服务器 Chromium(无头) fetch_raw_json() #mermaid-svg-uG9V00V8w9n0C7R8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uG9V00V8w9n0C7R8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uG9V00V8w9n0C7R8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uG9V00V8w9n0C7R8 .error-icon{fill:#552222;}#mermaid-svg-uG9V00V8w9n0C7R8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uG9V00V8w9n0C7R8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uG9V00V8w9n0C7R8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uG9V00V8w9n0C7R8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uG9V00V8w9n0C7R8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uG9V00V8w9n0C7R8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uG9V00V8w9n0C7R8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uG9V00V8w9n0C7R8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uG9V00V8w9n0C7R8 .marker.cross{stroke:#333333;}#mermaid-svg-uG9V00V8w9n0C7R8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uG9V00V8w9n0C7R8 p{margin:0;}#mermaid-svg-uG9V00V8w9n0C7R8 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-uG9V00V8w9n0C7R8 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-uG9V00V8w9n0C7R8 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-uG9V00V8w9n0C7R8 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-uG9V00V8w9n0C7R8 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-uG9V00V8w9n0C7R8 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-uG9V00V8w9n0C7R8 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-uG9V00V8w9n0C7R8 .sequenceNumber{fill:white;}#mermaid-svg-uG9V00V8w9n0C7R8 #sequencenumber{fill:#333;}#mermaid-svg-uG9V00V8w9n0C7R8 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-uG9V00V8w9n0C7R8 .messageText{fill:#333;stroke:none;}#mermaid-svg-uG9V00V8w9n0C7R8 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-uG9V00V8w9n0C7R8 .labelText,#mermaid-svg-uG9V00V8w9n0C7R8 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-uG9V00V8w9n0C7R8 .loopText,#mermaid-svg-uG9V00V8w9n0C7R8 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-uG9V00V8w9n0C7R8 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-uG9V00V8w9n0C7R8 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-uG9V00V8w9n0C7R8 .noteText,#mermaid-svg-uG9V00V8w9n0C7R8 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-uG9V00V8w9n0C7R8 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-uG9V00V8w9n0C7R8 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-uG9V00V8w9n0C7R8 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-uG9V00V8w9n0C7R8 .actorPopupMenu{position:absolute;}#mermaid-svg-uG9V00V8w9n0C7R8 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-uG9V00V8w9n0C7R8 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-uG9V00V8w9n0C7R8 .actor-man circle,#mermaid-svg-uG9V00V8w9n0C7R8 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-uG9V00V8w9n0C7R8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 启动浏览器page.goto(dashboard, networkidle)请求页面+资源(含 dashboard-ajax)响应(含签名 JSON)on_response 回调触发 → _RAW"data"=jsonwait_for_timeout(timeout_ms) 兜底等待关闭浏览器返回 _RAW"data"
五、解析器与数据模型
5.1 parser.py ------ 解析器
python
_ENTITY_RE = re.compile(r"&#x([0-9a-f]+);", re.I)
def html_entities_to_tokens(s: str) -> list[str]:
return [m.group(1).lower() for m in _ENTITY_RE.finditer(s)]
def parse_box_num(num: str) -> tuple[list[str], list[str]]:
ip_part, fp_part = num.split(".")
return html_entities_to_tokens(ip_part), html_entities_to_tokens(fp_part)
def parse_rate(rate_str: str) -> float | None:
r = rate_str.rstrip("%").strip()
if r.startswith("<"):
return None
return float(r)
def parse_dashboard_json(j: dict) -> dict:
d = j["movieList"]["data"]
total_box = float(d["nationBoxInfo"]["nationBoxSplitUnit"]["num"])
movies = []
for idx, mv in enumerate(d["list"], start=1):
rate = parse_rate(mv["boxRate"])
if rate is None:
continue # <0.1% 跳过(对求解无约束力)
ip, fp = parse_box_num(mv["boxSplitUnit"]["num"])
movies.append({"name": mv["movieInfo"]["movieName"], "ip": ip,
"fp": fp, "rate": rate, "sum_box": mv["sumBoxDesc"], "rank": idx})
return {"total_box": total_box, "movies": movies}
要点 :全程用码点十六进制字符串做字符名,绝不用肉眼抄字符(终端里私有区字符显示成一样的方块,肉眼抄必错------实战踩过的大坑)。
5.2 models.py ------ 数据模型
python
@dataclass
class MovieBox:
name: str
box: float # 今日实时票房(综合,万)
rate_display: float # 页面显示占比(floor 截断值)
rate_real: float # 真实占比(解码后算出)
sum_box: str = ""
rank: int = 0
ts: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
def to_dict(self) -> dict: ...
@dataclass
class Frame:
ts: str
total_box: float # 大盘(综合,万)------ 明文锚点
total_split_box: float = 0.0 # 分账大盘(备用)
movies: list = field(default_factory=list)
关键设计 :rate_display 与 rate_real 分离------前者是页面 floor 截断值(z3 输入),后者是解码后真实占比(验证输出)。Frame 是"同帧一致性"的数据结构保证------大盘与电影列表绑定在同一对象里,从类型层面杜绝"跨帧数据"进入解码器。
六、SQLite 持久化设计
sql
CREATE TABLE IF NOT EXISTS boxoffice_snapshot (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL, -- 抓取时刻 'YYYY-MM-DD HH:MM:SS'
name TEXT NOT NULL, -- 电影名
rank INTEGER,
box REAL, -- 今日实时票房(综合,万)
rate_display REAL, -- 页面显示占比
rate_real REAL, -- 真实占比
sum_box TEXT, -- 累计票房描述
total_box REAL -- 当时大盘(万)
);
CREATE INDEX IF NOT EXISTS idx_ts ON boxoffice_snapshot(ts);
CREATE INDEX IF NOT EXISTS idx_name ON boxoffice_snapshot(name);
设计决策:
| 决策 | 理由 |
|---|---|
ts 存 TEXT 而非 DATETIME |
ISO 格式字符串字典序=时间序 ,ORDER BY ts DESC 天然正确;可读性好 |
冗余存 total_box |
查询趋势时单表直接出,无需关联"哪帧对应哪个大盘" |
| 每帧多行(非一帧一行 JSON) | 按电影查询(WHERE name LIKE ?)走索引,快 |
AUTOINCREMENT |
严格保证 id 单调,便于增量导出判断 |
| 双索引 ts + name | 时间窗口查询和电影查询都走索引 |
三个"自动" :自动建目录(mkdir(parents=True))、自动建库文件(sqlite3.connect)、自动建表(CREATE TABLE IF NOT EXISTS)。
七、HTTP API(FastAPI)
| 接口 | 方法 | 参数 | 说明 |
|---|---|---|---|
/frame |
GET | 无 | 实时抓一帧(约 5 秒),返回全部电影 |
/box |
GET | name |
实时查单部电影(模糊匹配),404 兜底 |
/history |
GET | name, limit |
查数据库历史趋势,404 兜底 |
SQLite maoyan 核心 FastAPI :8000 客户端(curl/Python/Agent) SQLite maoyan 核心 FastAPI :8000 客户端(curl/Python/Agent) #mermaid-svg-ne1lvUSQeHTPPmo2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ne1lvUSQeHTPPmo2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ne1lvUSQeHTPPmo2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ne1lvUSQeHTPPmo2 .error-icon{fill:#552222;}#mermaid-svg-ne1lvUSQeHTPPmo2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ne1lvUSQeHTPPmo2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ne1lvUSQeHTPPmo2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ne1lvUSQeHTPPmo2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ne1lvUSQeHTPPmo2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ne1lvUSQeHTPPmo2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ne1lvUSQeHTPPmo2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ne1lvUSQeHTPPmo2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ne1lvUSQeHTPPmo2 .marker.cross{stroke:#333333;}#mermaid-svg-ne1lvUSQeHTPPmo2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ne1lvUSQeHTPPmo2 p{margin:0;}#mermaid-svg-ne1lvUSQeHTPPmo2 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ne1lvUSQeHTPPmo2 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ne1lvUSQeHTPPmo2 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ne1lvUSQeHTPPmo2 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ne1lvUSQeHTPPmo2 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ne1lvUSQeHTPPmo2 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ne1lvUSQeHTPPmo2 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ne1lvUSQeHTPPmo2 .sequenceNumber{fill:white;}#mermaid-svg-ne1lvUSQeHTPPmo2 #sequencenumber{fill:#333;}#mermaid-svg-ne1lvUSQeHTPPmo2 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ne1lvUSQeHTPPmo2 .messageText{fill:#333;stroke:none;}#mermaid-svg-ne1lvUSQeHTPPmo2 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ne1lvUSQeHTPPmo2 .labelText,#mermaid-svg-ne1lvUSQeHTPPmo2 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ne1lvUSQeHTPPmo2 .loopText,#mermaid-svg-ne1lvUSQeHTPPmo2 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ne1lvUSQeHTPPmo2 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ne1lvUSQeHTPPmo2 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ne1lvUSQeHTPPmo2 .noteText,#mermaid-svg-ne1lvUSQeHTPPmo2 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ne1lvUSQeHTPPmo2 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ne1lvUSQeHTPPmo2 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ne1lvUSQeHTPPmo2 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ne1lvUSQeHTPPmo2 .actorPopupMenu{position:absolute;}#mermaid-svg-ne1lvUSQeHTPPmo2 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ne1lvUSQeHTPPmo2 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ne1lvUSQeHTPPmo2 .actor-man circle,#mermaid-svg-ne1lvUSQeHTPPmo2 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ne1lvUSQeHTPPmo2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} GET /box?name=牛来fetch_raw_json() 抓帧原始 JSONparse+decode{movies:...}200 JSON / 404 {"detail":"未找到电影"}GET /history?name=牛来query_movie()历史点200 {"points":...}
API 实测:
| 请求 | 结果 |
|---|---|
GET /history?name=牛来 |
200,19 个历史点 |
GET /frame |
200,17 部电影 |
GET /box?name=牛来 |
200,牛来 393.0 万 排名 6 |
GET /box?name=不存在的电影XYZ |
404,{"detail":"未找到电影: 不存在的电影XYZ"} |
八、实测数据:牛来 6 小时票房趋势
report.py 实测输出(Mermaid 折线图):
#mermaid-svg-ll6dG5SyraStcxSf{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ll6dG5SyraStcxSf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ll6dG5SyraStcxSf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ll6dG5SyraStcxSf .error-icon{fill:#552222;}#mermaid-svg-ll6dG5SyraStcxSf .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ll6dG5SyraStcxSf .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ll6dG5SyraStcxSf .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ll6dG5SyraStcxSf .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ll6dG5SyraStcxSf .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ll6dG5SyraStcxSf .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ll6dG5SyraStcxSf .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ll6dG5SyraStcxSf .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ll6dG5SyraStcxSf .marker.cross{stroke:#333333;}#mermaid-svg-ll6dG5SyraStcxSf svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ll6dG5SyraStcxSf p{margin:0;}#mermaid-svg-ll6dG5SyraStcxSf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 牛来 票房趋势(最近6小时) 17:3817:4417:5118:0218:1218:2218:3218:4318:53 385380375370365360355350 票房(万)
注意 18:22(382.14)→ 18:27(364.18)的回落:同日大盘与其他电影同期均平稳上涨,仅牛来回落,判定为单部电影数据回刷/退票导致的瞬时抖动,非系统错误。
数据库实测(截至 2026-08-20 19:25):
| 指标 | 数值 |
|---|---|
| 总记录 | 340 条 |
| 时间帧 | 20 个 |
| 电影数 | 17 部 |
| 数据库文件 | data/boxoffice.db(~20KB) |
CSV 导出实测:
| 场景 | 结果 |
|---|---|
| 单帧导出 | 17 行 + 表头,utf-8-sig 带 BOM |
--all 合并 |
323 行 / 19 时间帧,ts 升序 + 组内 rank 升序 |
--append 追加 |
追加 17 行(1 新时间帧);重复运行报"没有新数据"(幂等) |
九、踩坑清单(实战血泪,全项目最重要的部分)
| # | 坑 | 现象 | 解法 |
|---|---|---|---|
| 1 | 手抄字符必错 | 终端把私有区字符显示成相同方块,抄错一帧导致求解失败 | 全程 codePointAt 提取十六进制码点,用码点字符串做字符名 |
| 2 | 双射假设陷阱 | 以为 10 字符 ↔ 0-9;实际数字 5 有两个码点(ef74/e274) | z3 建模不加 Distinct;先数清字符池 |
| 3 | rate 是 floor 不是四舍五入 | 3.793% 显示 3.7%;按四舍五入建模永远无解 | 用区间 [rate, rate+0.1),测试专门断言 floor 自洽 |
| 4 | 数据不同刻 = 无解 | 页面 5 秒刷新,旧大盘配新电影数据,z3 unsat | 大盘与电影列表必须来自同一次响应(fetcher 只取第一帧) |
| 5 | 贪心赋值漏解 | 顺序给字符赋值后固定,后续约束冲突时不回溯 | 用 z3 或完整回溯,不要贪心 |
| 6 | 签名接口 curl 直连失败 | signKey 动态生成 | Playwright 拦截响应,浏览器自己完成签名 |
| 7 | 单位口径 | 综合票房(含服务费)≠ 分账票房;大盘也有两个 | 全程用一个口径(默认综合 boxSplitUnit) |
| 8 | git-bash 路径转换 | Windows 下 /d/ai/... 被转成 \d\ai 找不到 exe |
用 Windows 格式 D:\ai\playwright-browsers |
| 9 | Windows sys.path | __file__.rsplit("/") 在 Windows 无效 |
用 os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| 10 | PowerShell 中文提交乱码 | git commit -m "中文" 按 GBK 传参乱码 |
write_file 写 UTF-8 消息文件 + git commit -F <文件> |
| 11 | PowerShell 嵌套引号 | python -c "..." 内嵌引号转义混乱 |
写临时 .py 文件运行,不用命令行内嵌 |
| 12 | PyCharm SQL 误报 | 无法解析表 boxoffice_snapshot |
静态检查器没运行代码,表运行时自动建 |
| 13 | update-ref 后暂存区残留 | git update-ref -d HEAD 后文件还在暂存区,commit 全提交 |
补 git reset 清空暂存区 |
| 14 | 导出 path 语义混淆 | export_csv(path=目录) 把目录当文件打开 → PermissionError |
CLI 层把目录和文件名拼接成完整路径 |
| 15 | 数据回刷/瞬时抖动 | 单帧票房短暂回落(如牛来 18:22 382→18:27 364) | 实时票房会因退票/口径修正/延迟上报回刷;看趋势用多帧,不迷信单帧 |
| 16 | 测试数据污染生产库 | 离线自测的假帧 ts 比真实新,污染 latest_frame | 测试用独立 db_path(如 data/test.db),或测后删库重建 |
十、关键设计决策(为什么这么做)
| # | 决策 | 备选方案 | 为什么选它 |
|---|---|---|---|
| 1 | Playwright 拦截而非 requests 直连 | 逆向 signKey 签名 | 签名动态生成且带风控参数,逆向成本高且易失效;浏览器自己签名,免维护 |
| 2 | z3 约束求解而非贪心赋值 | 顺序赋值+回溯 | 贪心局部最优可能漏解;z3 全局求解保证一致性 |
| 3 | 不加 Distinct(非双射) | 假设码点互异 | 实测数字 5 有两个码点,加 Distinct 必 unsat |
| 4 | floor 区间而非精确值 | 四舍五入建模 | 页面显示是 floor 截断,精确值建模无解 |
| 5 | 同帧(not _RAW 只取第一帧) | 多次请求拼接 | 跨帧数据约束矛盾,z3 unsat |
| 6 | 码点 token 而非字符 | 直接用私有区字符 | 终端显示歧义,肉眼抄必错 |
| 7 | 100×整数+小数(纯整数运算) | 直接浮点乘法 | 浮点误差导致区间边界判断不可靠 |
| 8 | ts 存 ISO 字符串 | SQLite DATETIME | 字典序=时间序,可读性好,无时区问题 |
| 9 | 冗余 total_box | 规范化设计(关联查询) | 单表直接出趋势,查询简单快 |
| 10 | csv utf-8-sig | 普通 utf-8 | Excel 打开中文不乱码(BOM 头) |
| 11 | 增量导出靠"时间帧集合去重" | 依赖 id 自增 | 幂等(重复运行不重复追加),文件损坏可全量重建 |
十一、性能与扩展路线
11.1 实测性能
| 指标 | 实测值 |
|---|---|
| 单次抓帧耗时 | 4.4~4.8 秒 |
| 解码耗时 | <1 秒(z3 秒级出解) |
| 单帧数据量 | 17 部电影 / 约 5KB JSON |
| 数据库体积 | 340 条 ≈ 20KB |
| API 启动内存 | ~200MB(FastAPI+uvicorn) |
| 浏览器内存 | 每次抓帧约 3GB(chromium 进程,用完即关) |
11.2 扩展路线图
- 核心链路(抓帧+解析+解码+CLI)
- SQLite 持久化 + 报表导出
- 定时监控
- 趋势报表(含 Mermaid 折线图)
- HTTP API
- (自主)db_query 查询工具
- (自主)CSV 合并导出 + 增量追加
- 票房曲线图(matplotlib/echarts)
- 每日 9 点票房战报推送(cron / 微信机器人)
- 多目标电影对比(牛来 vs 欢迎来龙餐馆)
- 结合"想看"数/豆瓣评分预测次日票房
- pytest 单元测试
- 服务化(后台常驻 / Docker 容器化)
小结
这个项目的核心价值在于**"不正面硬刚反爬,而是绕道用数学解题"**:
- 字体混淆 → 不碰字体文件,用明文大盘 + z3 约束求解,把"占比 × 大盘 = 票房"恒等式变成方程组------每次换字体也能秒解
- 签名接口 → 不逆向 signKey,用 Playwright 驱动真实浏览器自动签名,只拦截响应
- 数据一致性 → 同帧拦截 + 码点 token 化 + floor 区间建模,三个关键事实全部实测验证
从 340+ 条实测记录、16 个踩坑、13 项设计决策中沉淀出的经验是:面对强反爬,最高效的破解路径往往不是"逆向",而是"换一个数据源视角 + 用数学约束求解"。
完整代码与数据见项目仓库:https://gitee.com/Touari/boxoffice-monitor.git