Commit 1e9687f9f087ccff0383cd0fd73f94f4decf43f4
1 parent
1c2bff36
feat(bosszhipin): 本地数据读取工具 + 登录修复记录 + 明确边界
- 修复 BOSS直聘登录被安全检测拦:根因是 wechat_plain 的全局设备型号伪装 脚本连累风控,禁用脚本恢复真实机型后登录正常 - 本地库明文无加密,Contact/MessageTable 结构与持久化触发时机(反编译确认) - parse_resumes.py: 简历(ChatResumeBean)+职位卡片(ChatJobBean)反序列化导出, 支持招聘模式(myRole=1)/牛人模式(myRole=0)双角色 - scan_geek_list.py: 纯读屏方案(uiautomator dump + swipe)记录候选人列表, 不调用私有接口 - README 第四节写死三条边界:不做自动沟通/自动回复、不做私有接口批量抓取, 搬到 Hermes 上跑也不改变判断 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
5 changed files
with
446 additions
and
0 deletions
bosszhipin/.gitignore
0 → 100644
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() | ... | ... |