Commit 56ee9a7a8aba96d560505c5f68c9db40ddd7d567

Authored by yibo
2 parents c8430155 1e9687f9

merge: 合并远端 bosszhipin 功能分支

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
bosszhipin/.gitignore 0 → 100644
  1 +.venv/
  2 +data/
  3 +apk/
  4 +*.json
  5 +__pycache__/
... ...
bosszhipin/README.md 0 → 100644
  1 +# bosszhipin
  2 +
  3 +BOSS直聘(`com.hpbr.bosszhipin`)相关的所有逆向分析记录、脚本和边界决策。手机是 Pixel 10 Pro Fold(对外真实机型),root + Magisk + LSPosed(zygisk_vector)。
  4 +
  5 +## 目录结构
  6 +
  7 +```
  8 +bosszhipin/
  9 + README.md 本文件
  10 + pull_dbs.sh 从手机拉本地 SQLite 库
  11 + parse_resumes.py 解析已联系候选人 + 简历(Java 序列化)
  12 + scan_geek_list.py 读屏候选人列表(推荐/精选/最新) -> 落地 geek_list.db
  13 + apk/ base.apk + jadx 反编译源码(gitignore,本地约 430MB,未提交)
  14 + data/ 拉下来的数据库、导出的 json、截图/dump(gitignore,含真实候选人 PII)
  15 + .venv/ Python venv(javaobj-py3 等依赖,gitignore)
  16 +```
  17 +
  18 +`apk/` 和 `data/` 都不进 git——前者太大且是版权方二进制/反编译产物,后者是真实候选人的个人信息。
  19 +
  20 +## 一、登录问题:设备型号伪装冲突(已解决)
  21 +
  22 +**现象**:BOSS直聘登录一直卡在"安全检测"。
  23 +
  24 +**根因**:`/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 存在,触发安全检测拦登录。
  25 +
  26 +**处理**:把该脚本从 `/data/adb/post-fs-data.d/` 挪到 `/data/adb/wechat-model-spoof.sh.disabled`(保留文件,只是不在开机时执行),重启后 `ro.product.model` 恢复真实 "Pixel 10 Pro Fold / Google",登录成功。
  27 +
  28 +**遗留权衡**:这个伪装脚本和"微信多设备登录不触发设备名预警"是互斥的——以后如果又要用到微信那边的多设备登录场景,需要手动把脚本挪回 `post-fs-data.d/` 再重启;用 BOSS直聘时则相反。两边不能同时生效,除非做成"按前台 App 动态切换属性"(目前没做,成本较高,暂不需要)。
  29 +
  30 +## 二、本地数据库读取(已完成,只读,安全)
  31 +
  32 +### 发现
  33 +
  34 +- `/data/data/com.hpbr.bosszhipin/databases/` 下所有 db **完全没加密**(标准 `SQLite format 3` 明文头,不是 SQLCipher)。
  35 +- `hpbr_bosszhipin_client.db` 是我们要的库:
  36 + - `Contact` 表:每个**已经跟你产生过聊天事件**的联系人一行,`friendName`/`expectPositionName`/`expectSalary`/`degree`/`workYear`/`jobId` 等字段明文可查。
  37 + - `MessageTable.resume` 字段:简历分享消息里带的完整简历,标准 **Java Object Serialization**(魔数 `AC ED 00 05`),类名 `com.hpbr.bosszhipin.module.contacts.entity.protobuf.ChatResumeBean`,字段自带在流里,`javaobj-py3` 直接反序列化,不需要 APK 里的类定义。
  38 + - `MessageTable.text` 等字段:聊天文字内容,跟简历同一张表。
  39 +- **持久化触发时机**(读 jadx 反编译源码确认,见 `apk/decompiled/sources/com/hpbr/bosszhipin/data/manager/ContactManager.java:338` `insertOrUpdateAllWithMsg`):只要该候选人身上产生了一条 `ChatBean`(聊天消息——不管是你手动打招呼、系统自动发的开场白,还是对方回复),就会把 `ContactBean` 写进 `Contact` 表、消息(含简历)写进 `MessageTable`。**只是划过、没有聊天事件的候选人不会进库**——这是为什么"精选/最新/推荐"里看到的候选人数量远多于库里实际存的数量。
  40 +
  41 +### 双角色:招聘模式 ↔ 牛人模式,同一套表复用,没有新增表
  42 +
  43 +BOSS直聘账号可以在"招聘模式"(你是老板)和"牛人模式"(你是求职者)之间切换,切换后**不会新建数据库或新增表**,还是 `Contact` + `MessageTable` 这两张,靠 `Contact.myRole` 区分方向:
  44 +
  45 +- `myRole=1`:你是老板,联系人是候选人,`bossCompanyName`/`bossJobPosition`/`positionName`/`salaryDesc` 描述"你发的职位",`MessageTable.resume`(`ChatResumeBean`)是候选人简历。
  46 +- `myRole=0`:你是牛人,联系人是招聘方,同一批列描述"对方公司推给你的职位",`MessageTable.job` 字段(类名 `ChatJobBean`,同样是明文 Java 序列化)是职位卡片——公司名、职位、薪资范围(如"50-80K·15薪")、城市、经验要求、职位描述。
  47 +- 持久化时机对称:牛人模式下,只有**实际跟招聘方产生过沟通**的职位才会进 `MessageTable.job`;单纯在牛人 tab 的推荐/最新职位流里划过没聊的,跟候选人那边"划过没聊天不进库"是一模一样的逻辑,不会落库。
  48 +
  49 +### 用法
  50 +
  51 +```bash
  52 +cd bosszhipin
  53 +python3 -m venv .venv && source .venv/bin/activate && pip install javaobj-py3 # 首次
  54 +
  55 +./pull_dbs.sh # adb 从手机拉库到 ./data/
  56 +python3 parse_resumes.py data/hpbr_bosszhipin_client.db > data/export.json
  57 +```
  58 +
  59 +`export.json`:`{"contacts": [...], "resumes": [...], "jobs": [...]}`。`contacts[].myRole` 区分方向;`resumes[].resume` 是候选人简历详情(含嵌套 `experiences` 工作/教育经历列表、`userInfo`);`jobs[].job` 是牛人模式收到的职位卡片详情。
  60 +
  61 +## 三、候选人列表读取(已完成,读屏方案,非接口)
  62 +
  63 +**目标**:产品助理/Prompt Engineer 两个岗位下"精选/最新/推荐"里划过但没聊天、因此不在本地库里的候选人,也想留档筛选。
  64 +
  65 +**没有做**:调用候选人列表的私有网络接口批量拉取。这条路虽然技术上可行,但结论是不做——理由记在下面「四、明确的边界」。
  66 +
  67 +**做的方案**:`scan_geek_list.py`——纯 UI 自动化:`adb shell input swipe` 模拟手动划屏 + `adb shell uiautomator dump` 读当前屏幕上**已经渲染出来**的控件文本(候选人卡片是原生 TextView,无障碍树里能拿到完整文字,比截图 OCR 准且无噪声)。不调用任何 App 私有接口,行为等价于"人自己划一遍列表、程序在旁边记笔记"。
  68 +
  69 +用法:
  70 +```bash
  71 +python3 scan_geek_list.py # 自动探测顶部两个职位 tab,扫 推荐/精选/最新
  72 +python3 scan_geek_list.py --job 产品助理 --tab 精选 --max-swipes 25
  73 +```
  74 +落地到 `data/geek_list.db`(表 `candidates`:job_tab/sub_tab/name/badge/meta/focus_line/education/tags/description,`dedup_key` 去重)。
  75 +
  76 +**已知局限**:
  77 +- 卡片文本解析是按已观察到的固定格式写的启发式规则(姓名 → 在线状态 → 学历薪资行 → 求职期望/最近关注 → 教育经历 → 标签 chip → 自我介绍),不是所有候选人卡片字段都规整(比如某些"教育经历"没有用" · "分隔符会被误分类进 tags),姓名重复的候选人可能被去重逻辑漏判。个人整理用够用,不追求 100% 准确。
  78 +- **"推荐"tab 是算法持续推荐的人才池,不是"投递到你职位/给你打招呼"的候选人**,理论上可以无限下滑——跑的时候用 `--max-swipes` 限制,不要当成有限列表扫到底。
  79 +- 同一屏内容前后两次 swipe 有重叠是正常的(`dedup_key` = 岗位+子tab+姓名+学历薪资行,重叠部分会被去重或用更完整的一份覆盖,见脚本里 `elif len(description) > ...` 那段)。
  80 +- 目前只针对招聘模式的候选人列表写了解析规则。牛人模式下同理会有一个职位推送列表(推荐/最新职位流,划过没聊天不落库),如果要照这套读屏方案覆盖,需要针对职位卡片的文本结构(公司名/薪资/职位/标签的排布跟候选人卡片不同)另写一套 `parse_cards`,脚本框架(swipe + dump + dedup)可以直接复用,还没做。
  81 +
  82 +## 四、明确的边界:这三类不做,以后也不会因为"读起来更方便"而改主意
  83 +
  84 +1. **自动"立即沟通" / 自动生成回复发给候选人**:不管是通过 hook 私有 API,还是模拟点击 UI,只要是让候选人以为在跟人聊天、实际是程序在处理,就不做。这条不因为"对象是已经打招呼/投递过的候选人"而放宽,也不因为运行环境从 Mac 搬到手机上的 Hermes 而改变——运行的位置不影响这个判断。
  85 +2. **调用候选人列表的私有网络接口批量拉取**:即使数据范围是你有权限查看的(投递你职位/给你打招呼的候选人),用逆向出来的私有接口去自动化批量拉取,本身违反平台"禁止非官方客户端/爬虫访问"的条款——这一条约束的是"访问方式",跟"你对这份数据有没有查看权限"是两回事。批量抓取来的第三方个人信息存进平台管辖之外的数据库,也超出候选人当初同意信息被使用的范围。
  86 +3. 以上两条被拒绝后如果又提出"把这个搬到 Hermes 上自动做"——结论不变,只是换了个运行的地方,性质没变。
  87 +
  88 +**可以做、也已经在做的**:只读本机已有数据(联系人/聊天记录/简历)、读屏记录界面已显示内容、基于本地数据做筛选/整理/草稿建议。这些之后要接入 Pixel 上的 Hermes(替代依赖 Mac 版 Claude Code)都没问题,因为都是只读或"生成内容交给人审核发送",没有自动化对外触达这一步。
... ...
bosszhipin/parse_resumes.py 0 → 100644
  1 +#!/usr/bin/env python3
  2 +"""读取 hpbr_bosszhipin_client.db,把 Contact 表(联系人摘要) + MessageTable 里的
  3 +resume(招聘模式收到的候选人简历,ChatResumeBean) / job(牛人模式收到的职位卡片,
  4 +ChatJobBean) 都反序列化导出成 JSON。两者都是明文 Java Object Serialization,
  5 +不需要解密。myRole=1 表示这条联系人是"你当老板、对方是候选人",myRole=0 表示
  6 +"你当牛人、对方是招聘方"——同一套 Contact 表字段两种角色复用,见 README 第五节。
  7 +
  8 +用法: python3 parse_resumes.py [data/hpbr_bosszhipin_client.db] > resumes.json
  9 +"""
  10 +import json
  11 +import sqlite3
  12 +import sys
  13 +from pathlib import Path
  14 +
  15 +import javaobj
  16 +
  17 +DB_PATH = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).parent / "data/hpbr_bosszhipin_client.db"
  18 +
  19 +
  20 +def to_plain(value):
  21 + """把 javaobj 解出来的对象树递归拍平成 JSON 能序列化的 dict/list/基础类型。"""
  22 + if isinstance(value, (str, int, float, bool)) or value is None:
  23 + return value
  24 + if isinstance(value, (list, tuple)):
  25 + return [to_plain(v) for v in value]
  26 + if isinstance(value, dict):
  27 + return {k: to_plain(v) for k, v in value.items()}
  28 + if hasattr(value, "__dict__"):
  29 + return {
  30 + k: to_plain(v)
  31 + for k, v in vars(value).items()
  32 + if k not in ("classdesc", "annotations")
  33 + }
  34 + return str(value)
  35 +
  36 +
  37 +def parse_blob(blob: bytes):
  38 + obj = javaobj.loads(blob)
  39 + return to_plain(obj)
  40 +
  41 +
  42 +def collect(con, column, out_key):
  43 + """从 MessageTable 里挑出某个 BLOB 列(resume 或 job)非空的行,反序列化。"""
  44 + rows = []
  45 + for row in con.execute(
  46 + f"SELECT mid, fromUid, toUid, time, {column} FROM MessageTable WHERE {column} IS NOT NULL"
  47 + ):
  48 + try:
  49 + parsed = parse_blob(row[column])
  50 + except Exception as e:
  51 + parsed = {"_parse_error": str(e)}
  52 + rows.append(
  53 + {
  54 + "mid": row["mid"],
  55 + "fromUid": row["fromUid"],
  56 + "toUid": row["toUid"],
  57 + "time": row["time"],
  58 + out_key: parsed,
  59 + }
  60 + )
  61 + return rows
  62 +
  63 +
  64 +def main():
  65 + con = sqlite3.connect(str(DB_PATH))
  66 + con.row_factory = sqlite3.Row
  67 +
  68 + contacts = {
  69 + row["friendId"]: dict(row)
  70 + for row in con.execute(
  71 + "SELECT friendId, friendName, myRole, expectPositionName, expectSalary, degree, "
  72 + "workYear, workplace, gender, age, currentInterviewDesc, exchangeResumeUrl, "
  73 + "videoResumeUrl, bossCompanyName, bossJobPosition, positionName, salaryDesc, jobId, "
  74 + "note, lastChatText, lastChatTime FROM Contact"
  75 + )
  76 + }
  77 +
  78 + resumes = collect(con, "resume", "resume") # myRole=1: 你当老板收到的候选人简历
  79 + jobs = collect(con, "job", "job") # myRole=0: 你当牛人收到的职位卡片
  80 +
  81 + result = {"contacts": list(contacts.values()), "resumes": resumes, "jobs": jobs}
  82 + json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
  83 +
  84 +
  85 +if __name__ == "__main__":
  86 + main()
... ...
bosszhipin/pull_dbs.sh 0 → 100755
  1 +#!/usr/bin/env bash
  2 +# 从手机拉取 BOSS直聘本地数据库到 ./data/ 供离线解析。
  3 +# 数据库明文 SQLite,无需 root 破解密钥——只是普通 App 私有目录需要 su 才能 cp 出来。
  4 +set -euo pipefail
  5 +cd "$(dirname "$0")"
  6 +
  7 +PLATFORM_TOOLS="/opt/homebrew/share/android-commandlinetools/platform-tools"
  8 +export PATH="$PLATFORM_TOOLS:$PATH"
  9 +
  10 +PKG=com.hpbr.bosszhipin
  11 +DBDIR="/data/data/$PKG/databases"
  12 +OUT="./data"
  13 +mkdir -p "$OUT"
  14 +
  15 +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"
  16 +
  17 +adb shell "su -c 'for f in $FILES; do [ -f $DBDIR/\$f ] && cp $DBDIR/\$f /sdcard/\$f && chmod 666 /sdcard/\$f; done; true'"
  18 +for f in $FILES; do
  19 + adb pull "/sdcard/$f" "$OUT/$f" 2>/dev/null || true
  20 +done
  21 +adb shell "su -c 'rm -f $(for f in $FILES; do echo -n /sdcard/$f\ ; done)'"
  22 +
  23 +echo "拉取完成 -> $OUT/"
... ...
bosszhipin/scan_geek_list.py 0 → 100644
  1 +#!/usr/bin/env python3
  2 +"""扫描 BOSS直聘"牛人"候选人列表(推荐/精选/最新),用 uiautomator dump 读屏幕上已经
  3 +渲染出来的候选人卡片文字,落地到本地 SQLite。不调用任何 App 私有网络接口——只是
  4 +模拟人手动划列表的操作(adb input swipe) + 读取当前屏幕控件文本(adb uiautomator dump)。
  5 +
  6 +用法:
  7 + python3 scan_geek_list.py # 扫描所有已发现的岗位 tab x 推荐/精选/最新
  8 + python3 scan_geek_list.py --job 产品助理 --tab 精选
  9 + python3 scan_geek_list.py --max-swipes 30 # 每个 tab 最多滑动多少次(防止卡死)
  10 +"""
  11 +import argparse
  12 +import hashlib
  13 +import re
  14 +import sqlite3
  15 +import subprocess
  16 +import time
  17 +import xml.etree.ElementTree as ET
  18 +from pathlib import Path
  19 +
  20 +ADB_BIN = "adb" # 假设已在 PATH 里(见 pull_dbs.sh 里加过 platform-tools)
  21 +DUMP_REMOTE = "/sdcard/window_dump.xml"
  22 +DUMP_LOCAL = Path(__file__).parent / "data" / "_window_dump.xml"
  23 +DB_PATH = Path(__file__).parent / "data" / "geek_list.db"
  24 +
  25 +BOTTOM_NAV_TEXTS = {"牛人", "搜索", "消息", "我的"}
  26 +TOPBAR_TEXTS = {"推荐", "精选", "最新", "筛选"}
  27 +META_RE = re.compile(r"(应届生|经验|在校|\d+年).*[||].*(K|面议|元)")
  28 +EDU_RE = re.compile(r".+ · .+")
  29 +
  30 +
  31 +def adb(*args, timeout=15):
  32 + return subprocess.run([ADB_BIN] + list(args), capture_output=True, text=True, timeout=timeout)
  33 +
  34 +
  35 +def dump_screen_texts():
  36 + adb("shell", "uiautomator", "dump", DUMP_REMOTE)
  37 + DUMP_LOCAL.parent.mkdir(parents=True, exist_ok=True)
  38 + adb("pull", DUMP_REMOTE, str(DUMP_LOCAL))
  39 + root = ET.parse(str(DUMP_LOCAL)).getroot()
  40 + out = []
  41 + for node in root.iter("node"):
  42 + t = node.attrib.get("text", "").strip()
  43 + if t:
  44 + out.append((t, node.attrib.get("bounds", "")))
  45 + return out
  46 +
  47 +
  48 +def find_bounds(texts_with_bounds, label):
  49 + for t, b in texts_with_bounds:
  50 + if t == label:
  51 + m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", b)
  52 + if m:
  53 + x1, y1, x2, y2 = map(int, m.groups())
  54 + return (x1 + x2) // 2, (y1 + y2) // 2
  55 + return None
  56 +
  57 +
  58 +def tap(x, y):
  59 + adb("shell", "input", "tap", str(x), str(y))
  60 + time.sleep(1)
  61 +
  62 +
  63 +def swipe_up():
  64 + # 从屏幕中下部往上划一屏,留出顶部 tab 栏和底部导航栏不划到
  65 + adb("shell", "input", "swipe", "1000", "1700", "1000", "700", "400")
  66 + time.sleep(1.3)
  67 +
  68 +
  69 +def parse_cards(texts):
  70 + """把一屏候选人卡片的文本节点,按已知卡片结构切成一张张候选人记录。"""
  71 + texts = [t for t in texts if t not in BOTTOM_NAV_TEXTS and t not in TOPBAR_TEXTS and t not in ("北京",)]
  72 + cards = []
  73 + i = 0
  74 + n = len(texts)
  75 + while i < n:
  76 + # 找 meta 行(如 "26年应届生 | 本科 | 6-9K")作为卡片锚点
  77 + if META_RE.search(texts[i]):
  78 + name = texts[i - 1] if i - 1 >= 0 else ""
  79 + badge = ""
  80 + # name 前一格可能是徽章("在线"/"刚刚活跃")而不是姓名,姓名再往前一格
  81 + if name in ("在线", "刚刚活跃", "今日活跃") and i - 2 >= 0:
  82 + badge = name
  83 + name = texts[i - 2]
  84 + meta = texts[i]
  85 + j = i + 1
  86 + focus_line = ""
  87 + education = ""
  88 + tags = []
  89 + description = ""
  90 + if j < n and (texts[j].startswith("求职期望") or texts[j].startswith("最近关注")):
  91 + focus_line = texts[j]
  92 + j += 1
  93 + if j < n and EDU_RE.match(texts[j]) and len(texts[j]) < 40:
  94 + education = texts[j]
  95 + j += 1
  96 + # 标签: 连续的短文本(<12 字符),直到遇到长段描述或下一张卡片的 meta 行
  97 + while j < n and len(texts[j]) < 12 and not META_RE.search(texts[j]):
  98 + tags.append(texts[j])
  99 + j += 1
  100 + if j < n and not META_RE.search(texts[j]):
  101 + description = texts[j]
  102 + j += 1
  103 + cards.append(
  104 + {
  105 + "name": name,
  106 + "badge": badge,
  107 + "meta": meta,
  108 + "focus_line": focus_line,
  109 + "education": education,
  110 + "tags": "、".join(tags),
  111 + "description": description,
  112 + }
  113 + )
  114 + i = j
  115 + else:
  116 + i += 1
  117 + return cards
  118 +
  119 +
  120 +def ensure_db():
  121 + DB_PATH.parent.mkdir(parents=True, exist_ok=True)
  122 + con = sqlite3.connect(str(DB_PATH))
  123 + con.execute(
  124 + """CREATE TABLE IF NOT EXISTS candidates (
  125 + id INTEGER PRIMARY KEY AUTOINCREMENT,
  126 + job_tab TEXT,
  127 + sub_tab TEXT,
  128 + name TEXT,
  129 + badge TEXT,
  130 + meta TEXT,
  131 + focus_line TEXT,
  132 + education TEXT,
  133 + tags TEXT,
  134 + description TEXT,
  135 + dedup_key TEXT UNIQUE,
  136 + captured_at INTEGER
  137 + )"""
  138 + )
  139 + con.commit()
  140 + return con
  141 +
  142 +
  143 +def dedup_key(job_tab, sub_tab, card):
  144 + # 只用姓名+meta做键:同一张卡片在相邻两屏重叠截取时,education/tags 是否被
  145 + # 完整读到可能不一致,用它们做键会把同一个人拆成两条。
  146 + raw = f"{job_tab}|{sub_tab}|{card['name']}|{card['meta']}"
  147 + return hashlib.sha1(raw.encode("utf-8")).hexdigest()
  148 +
  149 +
  150 +def scan_current_tab(con, job_tab, sub_tab, max_swipes=25):
  151 + seen_keys = set()
  152 + new_total = 0
  153 + stale_rounds = 0
  154 + for _ in range(max_swipes):
  155 + texts = [t for t, _ in dump_screen_texts()]
  156 + cards = parse_cards(texts)
  157 + added_this_round = 0
  158 + for c in cards:
  159 + key = dedup_key(job_tab, sub_tab, c)
  160 + if key in seen_keys:
  161 + continue
  162 + seen_keys.add(key)
  163 + row = con.execute(
  164 + "SELECT id, length(description) FROM candidates WHERE dedup_key = ?", (key,)
  165 + ).fetchone()
  166 + if row is None:
  167 + con.execute(
  168 + "INSERT INTO candidates (job_tab, sub_tab, name, badge, meta, focus_line, education, tags, description, dedup_key, captured_at) "
  169 + "VALUES (?,?,?,?,?,?,?,?,?,?,strftime('%s','now'))",
  170 + (
  171 + job_tab,
  172 + sub_tab,
  173 + c["name"],
  174 + c["badge"],
  175 + c["meta"],
  176 + c["focus_line"],
  177 + c["education"],
  178 + c["tags"],
  179 + c["description"],
  180 + key,
  181 + ),
  182 + )
  183 + added_this_round += 1
  184 + new_total += 1
  185 + elif len(c["description"]) > (row[1] or 0):
  186 + # 同一个人前后两屏都拍到了,取信息更完整(描述更长)的那次覆盖
  187 + con.execute(
  188 + "UPDATE candidates SET badge=?, focus_line=?, education=?, tags=?, description=? WHERE id=?",
  189 + (c["badge"], c["focus_line"], c["education"], c["tags"], c["description"], row[0]),
  190 + )
  191 + con.commit()
  192 + if added_this_round == 0:
  193 + stale_rounds += 1
  194 + if stale_rounds >= 2:
  195 + break
  196 + else:
  197 + stale_rounds = 0
  198 + swipe_up()
  199 + print(f" [{job_tab} / {sub_tab}] 新增 {new_total} 条")
  200 + return new_total
  201 +
  202 +
  203 +def switch_job_and_tab(job_label, sub_tab_label):
  204 + texts = dump_screen_texts()
  205 + pos = find_bounds(texts, job_label)
  206 + if pos:
  207 + tap(*pos)
  208 + texts = dump_screen_texts()
  209 + pos = find_bounds(texts, sub_tab_label)
  210 + if pos:
  211 + tap(*pos)
  212 +
  213 +
  214 +def main():
  215 + ap = argparse.ArgumentParser()
  216 + ap.add_argument("--job", action="append", help="职位 tab 名(可重复),不填则自动探测顶部两个职位 tab")
  217 + ap.add_argument("--tab", action="append", default=None, help="子 tab: 推荐/精选/最新,不填则三个都扫")
  218 + ap.add_argument("--max-swipes", type=int, default=25)
  219 + args = ap.parse_args()
  220 +
  221 + sub_tabs = args.tab or ["推荐", "精选", "最新"]
  222 +
  223 + con = ensure_db()
  224 +
  225 + jobs = args.job
  226 + if not jobs:
  227 + # 从当前屏幕顶部读职位 tab 名(比如 "产品助理" / "Prompt Engin...")
  228 + texts = [t for t, _ in dump_screen_texts()]
  229 + jobs = [t for t in texts[:2] if t not in BOTTOM_NAV_TEXTS and t not in TOPBAR_TEXTS]
  230 + print("自动探测到职位 tab:", jobs)
  231 +
  232 + for job in jobs:
  233 + for sub_tab in sub_tabs:
  234 + print(f"=== 切换到 {job} / {sub_tab} ===")
  235 + switch_job_and_tab(job, sub_tab)
  236 + scan_current_tab(con, job, sub_tab, max_swipes=args.max_swipes)
  237 +
  238 + total = con.execute("SELECT count(*) FROM candidates").fetchone()[0]
  239 + print(f"完成,库里累计 {total} 条候选人记录 -> {DB_PATH}")
  240 + con.close()
  241 +
  242 +
  243 +if __name__ == "__main__":
  244 + main()
... ...