From 1e9687f9f087ccff0383cd0fd73f94f4decf43f4 Mon Sep 17 00:00:00 2001 From: yibo Date: Tue, 21 Jul 2026 12:26:44 +0800 Subject: [PATCH] feat(bosszhipin): 本地数据读取工具 + 登录修复记录 + 明确边界 --- bosszhipin/.gitignore | 5 +++++ bosszhipin/README.md | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ bosszhipin/parse_resumes.py | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ bosszhipin/pull_dbs.sh | 23 +++++++++++++++++++++++ bosszhipin/scan_geek_list.py | 244 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 446 insertions(+), 0 deletions(-) create mode 100644 bosszhipin/.gitignore create mode 100644 bosszhipin/README.md create mode 100644 bosszhipin/parse_resumes.py create mode 100755 bosszhipin/pull_dbs.sh create mode 100644 bosszhipin/scan_geek_list.py diff --git a/bosszhipin/.gitignore b/bosszhipin/.gitignore new file mode 100644 index 0000000..7481cb6 --- /dev/null +++ b/bosszhipin/.gitignore @@ -0,0 +1,5 @@ +.venv/ +data/ +apk/ +*.json +__pycache__/ diff --git a/bosszhipin/README.md b/bosszhipin/README.md new file mode 100644 index 0000000..949538b --- /dev/null +++ b/bosszhipin/README.md @@ -0,0 +1,88 @@ +# bosszhipin + +BOSS直聘(`com.hpbr.bosszhipin`)相关的所有逆向分析记录、脚本和边界决策。手机是 Pixel 10 Pro Fold(对外真实机型),root + Magisk + LSPosed(zygisk_vector)。 + +## 目录结构 + +``` +bosszhipin/ + README.md 本文件 + pull_dbs.sh 从手机拉本地 SQLite 库 + parse_resumes.py 解析已联系候选人 + 简历(Java 序列化) + scan_geek_list.py 读屏候选人列表(推荐/精选/最新) -> 落地 geek_list.db + apk/ base.apk + jadx 反编译源码(gitignore,本地约 430MB,未提交) + data/ 拉下来的数据库、导出的 json、截图/dump(gitignore,含真实候选人 PII) + .venv/ Python venv(javaobj-py3 等依赖,gitignore) +``` + +`apk/` 和 `data/` 都不进 git——前者太大且是版权方二进制/反编译产物,后者是真实候选人的个人信息。 + +## 一、登录问题:设备型号伪装冲突(已解决) + +**现象**:BOSS直聘登录一直卡在"安全检测"。 + +**根因**:`/data/adb/post-fs-data.d/wechat-model-spoof.sh`(另一个项目 `wechat_plain` 为了防微信多设备登录预警写的开机脚本)用 `resetprop` 把 `ro.product.model/brand/manufacturer` 等一串系统属性**全局**改成了 "samsung SM-X700 (Galaxy Tab S8)",`adb shell getprop` 也读到伪装值——不是只在微信进程里生效。BOSS直聘风控 SDK 拿 `Build.MODEL/BRAND` 和真实硬件特征(Tensor 芯片、GPU 渲染器字符串等)交叉校验,型号与硬件完全对不上,直接被判定高风险设备,再叠加 root/Magisk/LSPosed 存在,触发安全检测拦登录。 + +**处理**:把该脚本从 `/data/adb/post-fs-data.d/` 挪到 `/data/adb/wechat-model-spoof.sh.disabled`(保留文件,只是不在开机时执行),重启后 `ro.product.model` 恢复真实 "Pixel 10 Pro Fold / Google",登录成功。 + +**遗留权衡**:这个伪装脚本和"微信多设备登录不触发设备名预警"是互斥的——以后如果又要用到微信那边的多设备登录场景,需要手动把脚本挪回 `post-fs-data.d/` 再重启;用 BOSS直聘时则相反。两边不能同时生效,除非做成"按前台 App 动态切换属性"(目前没做,成本较高,暂不需要)。 + +## 二、本地数据库读取(已完成,只读,安全) + +### 发现 + +- `/data/data/com.hpbr.bosszhipin/databases/` 下所有 db **完全没加密**(标准 `SQLite format 3` 明文头,不是 SQLCipher)。 +- `hpbr_bosszhipin_client.db` 是我们要的库: + - `Contact` 表:每个**已经跟你产生过聊天事件**的联系人一行,`friendName`/`expectPositionName`/`expectSalary`/`degree`/`workYear`/`jobId` 等字段明文可查。 + - `MessageTable.resume` 字段:简历分享消息里带的完整简历,标准 **Java Object Serialization**(魔数 `AC ED 00 05`),类名 `com.hpbr.bosszhipin.module.contacts.entity.protobuf.ChatResumeBean`,字段自带在流里,`javaobj-py3` 直接反序列化,不需要 APK 里的类定义。 + - `MessageTable.text` 等字段:聊天文字内容,跟简历同一张表。 +- **持久化触发时机**(读 jadx 反编译源码确认,见 `apk/decompiled/sources/com/hpbr/bosszhipin/data/manager/ContactManager.java:338` `insertOrUpdateAllWithMsg`):只要该候选人身上产生了一条 `ChatBean`(聊天消息——不管是你手动打招呼、系统自动发的开场白,还是对方回复),就会把 `ContactBean` 写进 `Contact` 表、消息(含简历)写进 `MessageTable`。**只是划过、没有聊天事件的候选人不会进库**——这是为什么"精选/最新/推荐"里看到的候选人数量远多于库里实际存的数量。 + +### 双角色:招聘模式 ↔ 牛人模式,同一套表复用,没有新增表 + +BOSS直聘账号可以在"招聘模式"(你是老板)和"牛人模式"(你是求职者)之间切换,切换后**不会新建数据库或新增表**,还是 `Contact` + `MessageTable` 这两张,靠 `Contact.myRole` 区分方向: + +- `myRole=1`:你是老板,联系人是候选人,`bossCompanyName`/`bossJobPosition`/`positionName`/`salaryDesc` 描述"你发的职位",`MessageTable.resume`(`ChatResumeBean`)是候选人简历。 +- `myRole=0`:你是牛人,联系人是招聘方,同一批列描述"对方公司推给你的职位",`MessageTable.job` 字段(类名 `ChatJobBean`,同样是明文 Java 序列化)是职位卡片——公司名、职位、薪资范围(如"50-80K·15薪")、城市、经验要求、职位描述。 +- 持久化时机对称:牛人模式下,只有**实际跟招聘方产生过沟通**的职位才会进 `MessageTable.job`;单纯在牛人 tab 的推荐/最新职位流里划过没聊的,跟候选人那边"划过没聊天不进库"是一模一样的逻辑,不会落库。 + +### 用法 + +```bash +cd bosszhipin +python3 -m venv .venv && source .venv/bin/activate && pip install javaobj-py3 # 首次 + +./pull_dbs.sh # adb 从手机拉库到 ./data/ +python3 parse_resumes.py data/hpbr_bosszhipin_client.db > data/export.json +``` + +`export.json`:`{"contacts": [...], "resumes": [...], "jobs": [...]}`。`contacts[].myRole` 区分方向;`resumes[].resume` 是候选人简历详情(含嵌套 `experiences` 工作/教育经历列表、`userInfo`);`jobs[].job` 是牛人模式收到的职位卡片详情。 + +## 三、候选人列表读取(已完成,读屏方案,非接口) + +**目标**:产品助理/Prompt Engineer 两个岗位下"精选/最新/推荐"里划过但没聊天、因此不在本地库里的候选人,也想留档筛选。 + +**没有做**:调用候选人列表的私有网络接口批量拉取。这条路虽然技术上可行,但结论是不做——理由记在下面「四、明确的边界」。 + +**做的方案**:`scan_geek_list.py`——纯 UI 自动化:`adb shell input swipe` 模拟手动划屏 + `adb shell uiautomator dump` 读当前屏幕上**已经渲染出来**的控件文本(候选人卡片是原生 TextView,无障碍树里能拿到完整文字,比截图 OCR 准且无噪声)。不调用任何 App 私有接口,行为等价于"人自己划一遍列表、程序在旁边记笔记"。 + +用法: +```bash +python3 scan_geek_list.py # 自动探测顶部两个职位 tab,扫 推荐/精选/最新 +python3 scan_geek_list.py --job 产品助理 --tab 精选 --max-swipes 25 +``` +落地到 `data/geek_list.db`(表 `candidates`:job_tab/sub_tab/name/badge/meta/focus_line/education/tags/description,`dedup_key` 去重)。 + +**已知局限**: +- 卡片文本解析是按已观察到的固定格式写的启发式规则(姓名 → 在线状态 → 学历薪资行 → 求职期望/最近关注 → 教育经历 → 标签 chip → 自我介绍),不是所有候选人卡片字段都规整(比如某些"教育经历"没有用" · "分隔符会被误分类进 tags),姓名重复的候选人可能被去重逻辑漏判。个人整理用够用,不追求 100% 准确。 +- **"推荐"tab 是算法持续推荐的人才池,不是"投递到你职位/给你打招呼"的候选人**,理论上可以无限下滑——跑的时候用 `--max-swipes` 限制,不要当成有限列表扫到底。 +- 同一屏内容前后两次 swipe 有重叠是正常的(`dedup_key` = 岗位+子tab+姓名+学历薪资行,重叠部分会被去重或用更完整的一份覆盖,见脚本里 `elif len(description) > ...` 那段)。 +- 目前只针对招聘模式的候选人列表写了解析规则。牛人模式下同理会有一个职位推送列表(推荐/最新职位流,划过没聊天不落库),如果要照这套读屏方案覆盖,需要针对职位卡片的文本结构(公司名/薪资/职位/标签的排布跟候选人卡片不同)另写一套 `parse_cards`,脚本框架(swipe + dump + dedup)可以直接复用,还没做。 + +## 四、明确的边界:这三类不做,以后也不会因为"读起来更方便"而改主意 + +1. **自动"立即沟通" / 自动生成回复发给候选人**:不管是通过 hook 私有 API,还是模拟点击 UI,只要是让候选人以为在跟人聊天、实际是程序在处理,就不做。这条不因为"对象是已经打招呼/投递过的候选人"而放宽,也不因为运行环境从 Mac 搬到手机上的 Hermes 而改变——运行的位置不影响这个判断。 +2. **调用候选人列表的私有网络接口批量拉取**:即使数据范围是你有权限查看的(投递你职位/给你打招呼的候选人),用逆向出来的私有接口去自动化批量拉取,本身违反平台"禁止非官方客户端/爬虫访问"的条款——这一条约束的是"访问方式",跟"你对这份数据有没有查看权限"是两回事。批量抓取来的第三方个人信息存进平台管辖之外的数据库,也超出候选人当初同意信息被使用的范围。 +3. 以上两条被拒绝后如果又提出"把这个搬到 Hermes 上自动做"——结论不变,只是换了个运行的地方,性质没变。 + +**可以做、也已经在做的**:只读本机已有数据(联系人/聊天记录/简历)、读屏记录界面已显示内容、基于本地数据做筛选/整理/草稿建议。这些之后要接入 Pixel 上的 Hermes(替代依赖 Mac 版 Claude Code)都没问题,因为都是只读或"生成内容交给人审核发送",没有自动化对外触达这一步。 diff --git a/bosszhipin/parse_resumes.py b/bosszhipin/parse_resumes.py new file mode 100644 index 0000000..f7c1631 --- /dev/null +++ b/bosszhipin/parse_resumes.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""读取 hpbr_bosszhipin_client.db,把 Contact 表(联系人摘要) + MessageTable 里的 +resume(招聘模式收到的候选人简历,ChatResumeBean) / job(牛人模式收到的职位卡片, +ChatJobBean) 都反序列化导出成 JSON。两者都是明文 Java Object Serialization, +不需要解密。myRole=1 表示这条联系人是"你当老板、对方是候选人",myRole=0 表示 +"你当牛人、对方是招聘方"——同一套 Contact 表字段两种角色复用,见 README 第五节。 + +用法: python3 parse_resumes.py [data/hpbr_bosszhipin_client.db] > resumes.json +""" +import json +import sqlite3 +import sys +from pathlib import Path + +import javaobj + +DB_PATH = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).parent / "data/hpbr_bosszhipin_client.db" + + +def to_plain(value): + """把 javaobj 解出来的对象树递归拍平成 JSON 能序列化的 dict/list/基础类型。""" + if isinstance(value, (str, int, float, bool)) or value is None: + return value + if isinstance(value, (list, tuple)): + return [to_plain(v) for v in value] + if isinstance(value, dict): + return {k: to_plain(v) for k, v in value.items()} + if hasattr(value, "__dict__"): + return { + k: to_plain(v) + for k, v in vars(value).items() + if k not in ("classdesc", "annotations") + } + return str(value) + + +def parse_blob(blob: bytes): + obj = javaobj.loads(blob) + return to_plain(obj) + + +def collect(con, column, out_key): + """从 MessageTable 里挑出某个 BLOB 列(resume 或 job)非空的行,反序列化。""" + rows = [] + for row in con.execute( + f"SELECT mid, fromUid, toUid, time, {column} FROM MessageTable WHERE {column} IS NOT NULL" + ): + try: + parsed = parse_blob(row[column]) + except Exception as e: + parsed = {"_parse_error": str(e)} + rows.append( + { + "mid": row["mid"], + "fromUid": row["fromUid"], + "toUid": row["toUid"], + "time": row["time"], + out_key: parsed, + } + ) + return rows + + +def main(): + con = sqlite3.connect(str(DB_PATH)) + con.row_factory = sqlite3.Row + + contacts = { + row["friendId"]: dict(row) + for row in con.execute( + "SELECT friendId, friendName, myRole, expectPositionName, expectSalary, degree, " + "workYear, workplace, gender, age, currentInterviewDesc, exchangeResumeUrl, " + "videoResumeUrl, bossCompanyName, bossJobPosition, positionName, salaryDesc, jobId, " + "note, lastChatText, lastChatTime FROM Contact" + ) + } + + resumes = collect(con, "resume", "resume") # myRole=1: 你当老板收到的候选人简历 + jobs = collect(con, "job", "job") # myRole=0: 你当牛人收到的职位卡片 + + result = {"contacts": list(contacts.values()), "resumes": resumes, "jobs": jobs} + json.dump(result, sys.stdout, ensure_ascii=False, indent=2) + + +if __name__ == "__main__": + main() diff --git a/bosszhipin/pull_dbs.sh b/bosszhipin/pull_dbs.sh new file mode 100755 index 0000000..c3b7984 --- /dev/null +++ b/bosszhipin/pull_dbs.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# 从手机拉取 BOSS直聘本地数据库到 ./data/ 供离线解析。 +# 数据库明文 SQLite,无需 root 破解密钥——只是普通 App 私有目录需要 su 才能 cp 出来。 +set -euo pipefail +cd "$(dirname "$0")" + +PLATFORM_TOOLS="/opt/homebrew/share/android-commandlinetools/platform-tools" +export PATH="$PLATFORM_TOOLS:$PATH" + +PKG=com.hpbr.bosszhipin +DBDIR="/data/data/$PKG/databases" +OUT="./data" +mkdir -p "$OUT" + +FILES="boss_app_client.db boss_app_client.db-shm boss_app_client.db-wal hpbr_bosszhipin_client.db hpbr_bosszhipin_client.db-shm hpbr_bosszhipin_client.db-wal" + +adb shell "su -c 'for f in $FILES; do [ -f $DBDIR/\$f ] && cp $DBDIR/\$f /sdcard/\$f && chmod 666 /sdcard/\$f; done; true'" +for f in $FILES; do + adb pull "/sdcard/$f" "$OUT/$f" 2>/dev/null || true +done +adb shell "su -c 'rm -f $(for f in $FILES; do echo -n /sdcard/$f\ ; done)'" + +echo "拉取完成 -> $OUT/" diff --git a/bosszhipin/scan_geek_list.py b/bosszhipin/scan_geek_list.py new file mode 100644 index 0000000..be2876c --- /dev/null +++ b/bosszhipin/scan_geek_list.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""扫描 BOSS直聘"牛人"候选人列表(推荐/精选/最新),用 uiautomator dump 读屏幕上已经 +渲染出来的候选人卡片文字,落地到本地 SQLite。不调用任何 App 私有网络接口——只是 +模拟人手动划列表的操作(adb input swipe) + 读取当前屏幕控件文本(adb uiautomator dump)。 + +用法: + python3 scan_geek_list.py # 扫描所有已发现的岗位 tab x 推荐/精选/最新 + python3 scan_geek_list.py --job 产品助理 --tab 精选 + python3 scan_geek_list.py --max-swipes 30 # 每个 tab 最多滑动多少次(防止卡死) +""" +import argparse +import hashlib +import re +import sqlite3 +import subprocess +import time +import xml.etree.ElementTree as ET +from pathlib import Path + +ADB_BIN = "adb" # 假设已在 PATH 里(见 pull_dbs.sh 里加过 platform-tools) +DUMP_REMOTE = "/sdcard/window_dump.xml" +DUMP_LOCAL = Path(__file__).parent / "data" / "_window_dump.xml" +DB_PATH = Path(__file__).parent / "data" / "geek_list.db" + +BOTTOM_NAV_TEXTS = {"牛人", "搜索", "消息", "我的"} +TOPBAR_TEXTS = {"推荐", "精选", "最新", "筛选"} +META_RE = re.compile(r"(应届生|经验|在校|\d+年).*[||].*(K|面议|元)") +EDU_RE = re.compile(r".+ · .+") + + +def adb(*args, timeout=15): + return subprocess.run([ADB_BIN] + list(args), capture_output=True, text=True, timeout=timeout) + + +def dump_screen_texts(): + adb("shell", "uiautomator", "dump", DUMP_REMOTE) + DUMP_LOCAL.parent.mkdir(parents=True, exist_ok=True) + adb("pull", DUMP_REMOTE, str(DUMP_LOCAL)) + root = ET.parse(str(DUMP_LOCAL)).getroot() + out = [] + for node in root.iter("node"): + t = node.attrib.get("text", "").strip() + if t: + out.append((t, node.attrib.get("bounds", ""))) + return out + + +def find_bounds(texts_with_bounds, label): + for t, b in texts_with_bounds: + if t == label: + m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", b) + if m: + x1, y1, x2, y2 = map(int, m.groups()) + return (x1 + x2) // 2, (y1 + y2) // 2 + return None + + +def tap(x, y): + adb("shell", "input", "tap", str(x), str(y)) + time.sleep(1) + + +def swipe_up(): + # 从屏幕中下部往上划一屏,留出顶部 tab 栏和底部导航栏不划到 + adb("shell", "input", "swipe", "1000", "1700", "1000", "700", "400") + time.sleep(1.3) + + +def parse_cards(texts): + """把一屏候选人卡片的文本节点,按已知卡片结构切成一张张候选人记录。""" + texts = [t for t in texts if t not in BOTTOM_NAV_TEXTS and t not in TOPBAR_TEXTS and t not in ("北京",)] + cards = [] + i = 0 + n = len(texts) + while i < n: + # 找 meta 行(如 "26年应届生 | 本科 | 6-9K")作为卡片锚点 + if META_RE.search(texts[i]): + name = texts[i - 1] if i - 1 >= 0 else "" + badge = "" + # name 前一格可能是徽章("在线"/"刚刚活跃")而不是姓名,姓名再往前一格 + if name in ("在线", "刚刚活跃", "今日活跃") and i - 2 >= 0: + badge = name + name = texts[i - 2] + meta = texts[i] + j = i + 1 + focus_line = "" + education = "" + tags = [] + description = "" + if j < n and (texts[j].startswith("求职期望") or texts[j].startswith("最近关注")): + focus_line = texts[j] + j += 1 + if j < n and EDU_RE.match(texts[j]) and len(texts[j]) < 40: + education = texts[j] + j += 1 + # 标签: 连续的短文本(<12 字符),直到遇到长段描述或下一张卡片的 meta 行 + while j < n and len(texts[j]) < 12 and not META_RE.search(texts[j]): + tags.append(texts[j]) + j += 1 + if j < n and not META_RE.search(texts[j]): + description = texts[j] + j += 1 + cards.append( + { + "name": name, + "badge": badge, + "meta": meta, + "focus_line": focus_line, + "education": education, + "tags": "、".join(tags), + "description": description, + } + ) + i = j + else: + i += 1 + return cards + + +def ensure_db(): + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + con = sqlite3.connect(str(DB_PATH)) + con.execute( + """CREATE TABLE IF NOT EXISTS candidates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_tab TEXT, + sub_tab TEXT, + name TEXT, + badge TEXT, + meta TEXT, + focus_line TEXT, + education TEXT, + tags TEXT, + description TEXT, + dedup_key TEXT UNIQUE, + captured_at INTEGER + )""" + ) + con.commit() + return con + + +def dedup_key(job_tab, sub_tab, card): + # 只用姓名+meta做键:同一张卡片在相邻两屏重叠截取时,education/tags 是否被 + # 完整读到可能不一致,用它们做键会把同一个人拆成两条。 + raw = f"{job_tab}|{sub_tab}|{card['name']}|{card['meta']}" + return hashlib.sha1(raw.encode("utf-8")).hexdigest() + + +def scan_current_tab(con, job_tab, sub_tab, max_swipes=25): + seen_keys = set() + new_total = 0 + stale_rounds = 0 + for _ in range(max_swipes): + texts = [t for t, _ in dump_screen_texts()] + cards = parse_cards(texts) + added_this_round = 0 + for c in cards: + key = dedup_key(job_tab, sub_tab, c) + if key in seen_keys: + continue + seen_keys.add(key) + row = con.execute( + "SELECT id, length(description) FROM candidates WHERE dedup_key = ?", (key,) + ).fetchone() + if row is None: + con.execute( + "INSERT INTO candidates (job_tab, sub_tab, name, badge, meta, focus_line, education, tags, description, dedup_key, captured_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?,strftime('%s','now'))", + ( + job_tab, + sub_tab, + c["name"], + c["badge"], + c["meta"], + c["focus_line"], + c["education"], + c["tags"], + c["description"], + key, + ), + ) + added_this_round += 1 + new_total += 1 + elif len(c["description"]) > (row[1] or 0): + # 同一个人前后两屏都拍到了,取信息更完整(描述更长)的那次覆盖 + con.execute( + "UPDATE candidates SET badge=?, focus_line=?, education=?, tags=?, description=? WHERE id=?", + (c["badge"], c["focus_line"], c["education"], c["tags"], c["description"], row[0]), + ) + con.commit() + if added_this_round == 0: + stale_rounds += 1 + if stale_rounds >= 2: + break + else: + stale_rounds = 0 + swipe_up() + print(f" [{job_tab} / {sub_tab}] 新增 {new_total} 条") + return new_total + + +def switch_job_and_tab(job_label, sub_tab_label): + texts = dump_screen_texts() + pos = find_bounds(texts, job_label) + if pos: + tap(*pos) + texts = dump_screen_texts() + pos = find_bounds(texts, sub_tab_label) + if pos: + tap(*pos) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--job", action="append", help="职位 tab 名(可重复),不填则自动探测顶部两个职位 tab") + ap.add_argument("--tab", action="append", default=None, help="子 tab: 推荐/精选/最新,不填则三个都扫") + ap.add_argument("--max-swipes", type=int, default=25) + args = ap.parse_args() + + sub_tabs = args.tab or ["推荐", "精选", "最新"] + + con = ensure_db() + + jobs = args.job + if not jobs: + # 从当前屏幕顶部读职位 tab 名(比如 "产品助理" / "Prompt Engin...") + texts = [t for t, _ in dump_screen_texts()] + jobs = [t for t in texts[:2] if t not in BOTTOM_NAV_TEXTS and t not in TOPBAR_TEXTS] + print("自动探测到职位 tab:", jobs) + + for job in jobs: + for sub_tab in sub_tabs: + print(f"=== 切换到 {job} / {sub_tab} ===") + switch_job_and_tab(job, sub_tab) + scan_current_tab(con, job, sub_tab, max_swipes=args.max_swipes) + + total = con.execute("SELECT count(*) FROM candidates").fetchone()[0] + print(f"完成,库里累计 {total} 条候选人记录 -> {DB_PATH}") + con.close() + + +if __name__ == "__main__": + main() -- libgit2 0.21.4