资讯动态

词达人抓包与SQLite落库:学习记录导出和错题统计实战

发布时间:2026/10/24 2:44:20 来源:尧图企业网站定制
简介面向英语词汇学习工具「词达人」的抓包分析资源包适合具备一定网络基础、希望观察应用与服务器之间请求响应过程的学习者与调试人员。包内以Fiddler调试代理为核心配有插件、证书与回环辅助程序、脚本配置、可执行工具及音频提示文件可用于查看HTTP/HTTPS通信、分析单词查询与学习进度同步等数据流并借助脚本扩展调试能力。压缩包共92个文件以dll动态库、dat数据文件、pdb调试符号、exe可执行程序为主另有wav音频、config配置、txt说明及少量ico、cs、bat、url、js等整体约7.4MB。已有8938人学习。其价值在于把抓包代理、证书处理、脚本插件与词达人工具程序打包在一起便于读者搭建贴近真实交互的分析环境理解请求头、状态码与响应内容进而优化词汇学习策略并排查通信问题。1. 拿到「词达人工具.zip」先搞清它解的是哪道题搜「词达人 抓包」找过来的基本是同一类人单词背了大半年生词本散在客户端里导不出来想知道每次答题的判分到底看什么又不甘心把学习记录锁死在一个 App 里。这个 zip 冲的就是这件事——它不是题库也不是自动打卡脚本而是一套围绕客户端请求做分析、把学习记录落到本地数据库的小工具集。我在一台闲置笔记本上完整跑了一遍从证书安装到词表落库中间翻过两次车。适合谁会一点 Python、能自己开终端、愿意为几百个生词折腾一晚上的从业者只想点两下拿成品词表的这份资源会让你失望。2. 抓包看协议从一次答题请求里读出六个关键字段2.1 为什么先动协议层而不是先读客户端代码大多数人拿到这类工具包的第一反应是去反编译客户端。我一般不这么干原因很实在客户端做了加固和混淆字符串加密、方法抽离、控制流平坦化一套下来光是把入口找出来就要一两天而且版本一更新全部作废。协议层是整条链路上最薄的一层请求从手机发出去字段名、字段顺序、编码方式全都明明白白摆在明文里成本差了一个数量级。抓包能回答四个问题这四个问题决定了后面所有工作值不值得做。第一鉴权靠什么是 Cookie、Token 还是签名头签名里有没有时间戳和随机数。第二一次答题提交带了多少字段哪个是题目 ID哪个是作答内容哪个是耗时。第三响应里除了对错还回了什么有没有下一题的预取、有没有进度和掌握度。第四接口是分页的还是增量的有没有游标。这四条摸清楚本地词表能不能建、能建多细基本就定了。工具选型上我用 mitmproxy不用 Charles 或 Fiddler。不是它们不好而是 mitmproxy 支持写 Python 插件可以把「过滤哪些请求」「怎么落盘」「怎么脱敏」全部写成代码跑第二遍的时候不用再靠手点。血泪经验是手工抓包抓三次以上就一定会漏字段、漏请求脚本化之后才有可复现性。提示这套流程只处理你自己账号产生的流量导出的也是你自己的学习记录。不替他人提交、不修改服务端任何数据这是整条链路的前提越界了就不是技术问题了。2.2 代理环境搭建证书、端口与设备边界环境本身不复杂一台电脑当代理手机和电脑连同一个局域网手机手动填 HTTP 代理指向电脑的 IP 和端口。真正卡人的是证书。HTTP 是明文HTTPS 走 TLS代理要解密就必须让设备信任 mitmproxy 的根证书否则你看到的只有一串 TLS 握手失败的日志。启动代理先跑起来# -p 指定监听端口--set 打开透明模式的写入方便后续插件落盘 mitmproxy -p 8080 --set save_stream_file/tmp/raw.mitm # 不想用交互界面就用 mitmdump配合插件跑批量 mitmdump -p 8080 -s ./addon_dump.py-p 8080是监听端口手机端代理里填的就是这个-s挂载 Python 插件把过滤和落盘逻辑放进去save_stream_file会把原始流量整体存一份方便事后回放代价是文件涨得快抓半小时能到几百 MB磁盘紧就别开。手机上装证书这一步不同系统的入口不一样安卓在「安全」里装用户证书iOS 要在「关于本机」里装描述文件再去「证书信任设置」里手动打开开关——这一步最容易被跳过跳过之后所有 HTTPS 请求照样失败但浏览器能通非常迷惑。还有一个绕不过去的边界部分客户端只信任系统级证书用户证书装了也白装日志里表现为 TLS 握手直接断。这种情况我不建议硬啃能不能看到明文取决于设备和版本已经超出这份工具包的定位。判断方法很简单装完证书打开客户端随便点两下mitmdump里能看到明文响应就继续只看到 CONNECT 没有后续就换设备或者放弃这条路。2.3 一次答题请求的字段拆解代理通了之后最该看的是提交答案那一条。我在自己的流量里看到的典型结构是这样路径和字段名以你实际抓到的为准不同版本会变{ book_id: b_1042, word_id: w_88213, answer: abandon, cost_ms: 2140, seq: 37, client_ts: 1719302418123, sign: 9f2c...e1 }逐个说。book_id是词书标识决定了你后面建表要不要按词书分库word_id是题目主键比单词拼写更可靠因为同一个拼写在不同词书里 ID 不同answer是作答内容选择题里可能是选项序号而不是单词本身这一点必须先确认否则导出的「答案」全是数字cost_ms是作答耗时做错题率分析时这个字段比对错本身还有价值seq是序号天然的增量游标client_ts是客户端时间戳注意它是毫秒还是秒混用会导致排序彻底乱掉sign是签名通常由前面几个字段加一个盐值算出来这个字段你不需要复现只需要知道它存在——它意味着任何手工构造的请求都可能被拒。响应侧一般会回这几样对错、正确答案、下一题、当前进度。有些版本还会回一个掌握度或熟练度分值这个值如果你能拿到复习排期的质量会高一大截因为它已经是服务端算过的结果比你自己统计错题次数更准。字段拆完一个判断要先立住签名机制存在就不要想着批量提交。工具包的定位是只读——把已有的记录拉下来不是往服务端写东西。这个边界一旦模糊后面所有代码都白写。3. 响应落库把 JSON 拆成三张表让词表能查能算3.1 响应结构还原与字段映射抓到的原始响应是一堆嵌套 JSON直接扔进 Excel 你会发现字段深浅不一有的记录有掌握度有的没有有的时间戳是字符串。先做一层归一化把「接口给什么」翻译成「表里存什么」这一步决定了后面查询写起来累不累。我一般按三张表来切词条表存单词本身和词书归属记录表存每一次作答状态表存同步游标。为什么不合成一张大表因为一条词条会被答很多次合表之后一个单词占几十行去重和统计每次都要写DISTINCT查询会越写越丑。归一化的重点是兜底。接口返回的字段经常缺比如新词还没算出掌握度就是null直接写库会在聚合时炸掉。我的做法是给每个数值字段一个默认值并且在记录里保留原始 JSON 的一个片段出问题时能回溯。后悔药这东西能提前备就提前备。# normalize.py —— 把一条原始响应拍平成可以入表的字典 import json, time def to_ms(ts): 接口里毫秒和秒混用是常态按量级判断别硬编码 if ts is None: return None ts int(ts) return ts if ts 10**12 else ts * 1000 def flatten(raw: dict) - dict: data raw.get(data) or {} return { word_id: data.get(word_id), book_id: data.get(book_id), spelling: (data.get(word) or ).strip().lower(), correct: 1 if data.get(correct) else 0, cost_ms: max(0, int(data.get(cost_ms) or 0)), seq: int(data.get(seq) or 0), answered_at: to_ms(data.get(client_ts)), mastery: float(data.get(mastery) or 0.0), # 留一段原始数据字段对不上时能回查 raw: json.dumps(data, ensure_asciiFalse)[:2000], }to_ms里的分界线是 10 的 12 次方毫秒时间戳量级在 1e12 以上秒级在 1e9两者不会打架。flatten里所有get都带默认值spelling统一小写去空格是为了后面按拼写做聚合时不被大小写坑到。mastery缺省给 0.0 而不是None是因为 SQLite 的AVG遇到NULL会直接忽略结果看起来正常其实样本数不对这个坑我踩过一次排查了半小时。3.2 表结构与批量写入建表这部分没什么玄学但索引和主键的选择会直接影响后面几千上万行时的体验-- schema.sql CREATE TABLE IF NOT EXISTS words ( word_id TEXT PRIMARY KEY, book_id TEXT NOT NULL, spelling TEXT NOT NULL, first_seen INTEGER, raw_json TEXT ); CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, word_id TEXT NOT NULL, correct INTEGER NOT NULL, cost_ms INTEGER NOT NULL, seq INTEGER NOT NULL, answered_at INTEGER, mastery REAL DEFAULT 0, -- 同一词书同一序号只允许一条这条唯一约束是防重复抓包的第一道闸 UNIQUE(word_id, seq) ); CREATE TABLE IF NOT EXISTS sync_state ( book_id TEXT PRIMARY KEY, last_seq INTEGER DEFAULT 0, updated_at INTEGER ); CREATE INDEX IF NOT EXISTS idx_records_word ON records(word_id); CREATE INDEX IF NOT EXISTS idx_words_book ON words(book_id);UNIQUE(word_id, seq)是关键。同一道题重复抓两次INSERT OR IGNORE会自动吞掉第二条不需要在 Python 里写去重逻辑。idx_records_word服务于后面的错题统计没有这个索引一张五万行的记录表做分组查询会从几十毫秒涨到一秒以上。写入用executemany加显式事务别一条一条 commitimport sqlite3 def bulk_write(db_path: str, rows: list[dict]) - int: conn sqlite3.connect(db_path) try: with conn: # 隐式事务异常自动回滚 conn.executemany( INSERT OR IGNORE INTO records (word_id, correct, cost_ms, seq, answered_at, mastery) VALUES (:word_id, :correct, :cost_ms, :seq, :answered_at, :mastery), rows, ) return conn.execute(SELECT changes()).fetchone()[0] finally: conn.close()with conn这块是事务边界中途抛异常会整体回滚不会留下写了一半的表。返回值拿的是本次实际插入的行数跟len(rows)一比就知道有多少是重复被忽略的这个数字在调试时非常有用——如果连续几次都是 0说明游标没往前走后面的增量逻辑就白设计了。3.3 增量同步用游标代替全量重拉全量重拉看着简单实际代价是每次都要把整本书的记录翻一遍请求数随时间线性增长抓包日志也会迅速膨胀到没法看。正确做法是维护一个游标也就是sync_state.last_seq每轮只拉seq last_seq的部分。游标的选择有两个候选seq和answered_at。我优先用seq因为它是服务端生成的单调序号比客户端时间戳可靠得多——客户端时钟可以被手动改seq不会。只有在接口不返回seq的时候才退而用时间戳并且要留出几十秒的重叠窗口防止边界记录漏掉。同步流程写成幂等的读游标 → 拉一批 → 归一化 → 批量写 → 事务内更新游标。五步里任何一步失败游标不动下次重跑还是从老位置开始最多重复几条靠唯一约束兜住。这套模型不新鲜但它是唯一能在网络抖动下活下来的写法。def sync_once(conn, fetch_page, book_id: str, page_size: int 200) - int: last_seq conn.execute( SELECT last_seq FROM sync_state WHERE book_id?, (book_id,) ).fetchone() cursor last_seq[0] if last_seq else 0 total 0 while True: # fetch_page 由你实现带上 cursor 和 page_size 去请求返回已归一化的行 rows fetch_page(book_id, cursor, page_size) if not rows: break with conn: conn.executemany( INSERT OR IGNORE INTO records (word_id, correct, cost_ms, seq, answered_at, mastery) VALUES (:word_id, :correct, :cost_ms, :seq, :answered_at, :mastery), rows, ) cursor max(r[seq] for r in rows) # 游标和业务数据在同一个事务里更新避免写了一半的中间态 conn.execute( INSERT INTO sync_state(book_id, last_seq, updated_at) VALUES (?, ?, strftime(%s,now)*1000) ON CONFLICT(book_id) DO UPDATE SET last_seqexcluded.last_seq, updated_atexcluded.updated_at, (book_id, cursor), ) total len(rows) if len(rows) page_size: break return total循环退出条件有两个返回空列表或者返回条数小于页大小。后者能省掉一次空请求。ON CONFLICT ... DO UPDATE是 SQLite 3.24 之后支持的 upsert 写法老版本得拆成UPDATE加判断跑之前sqlite3.sqlite_version看一眼不吃亏。4. 跑通工具包依赖、参数与一次完整的复现记录4.1 环境与依赖锁定解压之后先别急着pip install -r requirements.txt先把目录看清楚。这类工具包常见的结构是src/放主逻辑、scripts/放一次性脚本、data/放样例、config.example.yaml放配置模板。配置模板一定要复制成config.yaml再改直接在模板上改下次更新覆盖掉就白干。Python 版本我建议 3.10 以上原因是代码里大概率用了X | None这种新式类型注解3.9 跑起来会在 import 阶段就报TypeError而且报错信息指向的是typing很容易误判成依赖没装好。依赖这块只锁三层HTTP 客户端、数据库驱动、解析工具。requests和sqlite3标准库自带基本够用mitmproxy只在抓包阶段需要跑离线分析时不装也行。我一般额外加一个tenacity做重试比手写for sleep干净。python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -q requests tenacity # 抓包阶段再加 mitmproxy python -c import sqlite3; print(sqlite3.sqlite_version) # 确认 3.24 cp config.example.yaml config.yaml最后那行版本检查别嫌麻烦。upsert 语法在 3.24 之前不可用而很多老系统自带的 SQLite 就是 3.22报错是near ON: syntax error看着像 SQL 写错了其实是运行时版本问题。4.2 参数逐个说明工具包的命令行参数通常有一堆挑真正影响结果的几个说清楚参数类型默认作用与调法--input路径无抓包落盘的目录或单文件。指向目录时会递归扫.json指向文件只处理一个调试期用文件批量用目录--db路径./data/cidian.dbSQLite 落库位置。想对比两次抓包结果换个 db 路径比清表安全--book-id字符串全部只处理指定词书。多本书混在一个库里时这个参数能防止串数据--since整数0起始seq等价于手工指定游标。重跑历史区间时用--dry-run开关关只解析不写库输出统计。第一次跑一定先开这个--timeout秒15单请求超时。网络差的环境调到 30配合重试才有意义--retry整数3失败重试次数。超过 3 次基本说明是逻辑问题加次数只是拖时间--dry-run是最值得养成习惯的一个。它会告诉你这轮解析出多少条、其中多少是重复、多少字段缺失数字对得上再写库。跳过这一步直接写等发现字段全空的时候表已经脏了只能删库重来。4.3 复现步骤与预期输出完整走一遍的顺序是抓包落盘 → 离线解析 → 落库 → 校验。# 第一步开代理手机端操作几分钟产生一段真实流量 mitmdump -p 8080 -s ./addon_dump.py --set confdir~/.mitmproxy # 第二步干跑只看统计不写库 python -m tools.import_records --input ./dump --dry-run --book-id b_1042 # 预期输出形如 # scanned files : 12 # parsed records: 348 # duplicate : 6 # missing field : 0 (word_id/correct/seq) # 第三步正式写库 python -m tools.import_records --input ./dump --db ./data/cidian.db --book-id b_1042 # 第四步校验看三张表的行数和游标位置是否自洽 sqlite3 ./data/cidian.db SELECT (SELECT COUNT(*) FROM words) AS words, (SELECT COUNT(*) FROM records) AS records, (SELECT last_seq FROM sync_state WHERE book_idb_1042) AS cursor;第二步的统计里「missing field」这一项必须为 0 才能往下走。不为 0 说明字段映射和实际响应不一致多半是客户端版本变了或者你抓的是另一个接口回去重新对一遍字段名。第四步的校验看两个关系records的行数应该大于等于words因为一个词会被答多次cursor应该等于记录里的最大seq对不上说明游标更新逻辑没生效。5. 踩坑与排查五个让链路中途断掉的现场5.1 浏览器能上网客户端一直转圈现象代理配好、证书装完手机浏览器访问网页正常但目标客户端打开就卡在加载mitmdump里只有一条CONNECT没有后续明文。原因客户端只信任系统级证书用户证书不在它的信任链里TLS 握手在证书校验这一步就断了。日志里不会给你明确提示看起来就像网络问题。解决先确认是不是这个原因——换一台系统版本更低的设备试如果那边能看到明文就是证书信任层级的问题。确认之后别硬啃这条路取决于设备和版本超出工具包定位。我一般的处理是换设备或者干脆只用那台能抓到明文的机器不在这上面耗时间。5.2 抓到的响应是乱码JSON 解析直接抛异常现象文件落下来了打开是一堆二进制json.loads报UnicodeDecodeError或者Expecting value。原因响应头里带了Content-Encoding: gzip或brmitmproxy 落盘时存的是压缩后的原始字节。浏览器会自动解压你用文本编辑器打开当然看不懂。解决在插件里读取flow.response.content这个属性已经解压过如果你读的是落盘的原始流文件就得自己按Content-Encoding解压。判断方法很简单文件头两个字节是1f 8b就是 gzip是28 b5 2f fd就是 brotli后者要额外装brotli库。5.3 时间排序全乱最新记录跑到最前面去了现象按时间排序发现同一天的记录一会儿在最前一会儿在最后图形化看就是锯齿状。原因接口里毫秒和秒混用了。老接口返回秒级新接口返回毫秒直接当同一个单位排序秒级那批会被当成 1970 年附近的时间。解决入表前统一转毫秒用 10 的 12 次方做分界判断。已经入库的脏数据跑一条UPDATE records SET answered_at answered_at * 1000 WHERE answered_at 100000000000修一遍跑之前先SELECT COUNT(*)看影响面。这类问题属于典型的「不报错但结论全错」比崩溃更难发现。5.4 同一段时间抓了两次记录表直接翻倍现象records行数比预期多出将近一倍错题率统计出来明显偏高。原因表上没有唯一约束或者约束建在了自增主键上而不是业务键上INSERT每次都成功。解决加上UNIQUE(word_id, seq)写入改成INSERT OR IGNORE。已经脏了的库用临时表去重再回灌建一张结构相同的新表INSERT OR IGNORE ... SELECT过去校验行数后改名。别直接DELETE靠GROUP BY挑那个写法在几十万行时会慢到你以为卡死了。5.5 导出的词表里中文全是问号现象单词正常释义字段打开是一串???或者方块。原因写文件时没指定编码走了系统默认。Windows 上默认是 GBK碰上非 GBK 能表示的字符就替换成问号而且这个替换是不可逆的文件已经写坏了。解决所有open()显式写encodingutf-8CSV 再加newline防止空行。已经写坏的文件没法救只能从库里重新导。从那以后我写任何落盘代码都先敲encoding这四个字母算是个便宜的习惯。6. 进阶把抓到的流量做成一张错题热力图和复习排期数据落库之后工具包的任务其实就结束了真正有价值的是拿这些记录做决策。我这里给两个查询都是从实际使用里磨出来的。第一个是按词书统计错题率和平均耗时用来找「反复错但自己没意识到」的词SELECT w.spelling, COUNT(*) AS tries, SUM(1 - r.correct) AS wrong, ROUND(AVG(1.0 - r.correct) * 100, 1) AS wrong_rate, ROUND(AVG(r.cost_ms) / 1000.0, 1) AS avg_sec FROM records r JOIN words w ON w.word_id r.word_id WHERE w.book_id b_1042 GROUP BY r.word_id HAVING tries 3 AND wrong_rate 50 ORDER BY wrong_rate DESC, avg_sec DESC LIMIT 50;HAVING tries 3是必须的只答过一次就错的词没有统计意义样本太薄会把排序彻底带偏。avg_sec放在第二排序位是因为耗时长的词往往不是不会而是「想太久」这类词在复习时的处理方式和硬错题不一样——前者需要提速后者需要重学。第二个查询做复习排期思路是艾宾浩斯间隔的简化版按最后一次作答时间算间隔天数越久没碰、错得越多的词优先级越高。SELECT w.spelling, MAX(r.answered_at) AS last_at, (strftime(%s,now) * 1000 - MAX(r.answered_at)) / 86400000.0 AS days_idle, ROUND(AVG(1.0 - r.correct), 2) AS wrong_rate FROM records r JOIN words w ON w.word_id r.word_id GROUP BY r.word_id HAVING wrong_rate 0.3 OR days_idle 7 ORDER BY wrong_rate * 2 MIN(days_idle, 30) / 10.0 DESC LIMIT 80;排序权重里wrong_rate乘 2、闲置天数除以 10是我自己调了几轮的结果目的是让「错得多」压过「放得久」否则一堆早就掌握的老词会长期霸占列表顶部。MIN(days_idle, 30)是封顶不然一个月没碰的词会把权重拉到无穷大排期表第一页永远是那几十个词。这套东西跑通之后我现在每周固定做一次先--dry-run看统计再增量同步最后跑一遍排期查询导出当天的复习清单。中间任何一步数字对不上就停在原地查绝不带着脏数据往下走。数据链路这东西错一步后面全是错的而且不报错。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑