Skill: kedoupi/ai-talent-hunter (version 1.0.1)

--- name: ai-talent-hunter description: 从 GitHub 找到最匹配的技术人才，生成个性化触达话术。适用于招聘工程师、寻找技术合伙人、猎头交付候选人等场景。 permissions: filesystem: read: - prompts/jd_parser_prompt.md # Step 0: JD parsing prompt template for translating job requirements to GitHub search queries - prompts/pitch_writer_prompt.md # Step 3: outreach message generation prompt template for personalized candidate engagement - data/candidates.jsonl # Cross-search deduplication: avoid recommending previously selected candidates write: - data/candidates.jsonl # Persist search results and candidate selection status env: - GITHUB_TOKEN # Required: GitHub Personal Access Token for public API access (user-provided, stored in local .env) config: reads: - prompts/jd_parser_prompt.md - prompts/pitch_writer_prompt.md - data/candidates.jsonl writes: - data/candidates.jsonl env: - GITHUB_TOKEN --- # GitHub 人才猎手 (GitHub Talent Hunter) **从 GitHub 真实代码里找技术人才** — 不看简历看代码，不靠吹嘘靠项目。为 HR、猎头、技术管理者和创业者设计的智能招聘工具。 --- ## 🔒 安全声明 **OpenClaw 安全扫描可能会提示 "Suspicious"，这是正常的**，原因如下： 1. **需要 GitHub Token**：本 skill 使用 GitHub 公开 API 搜索开发者，需要用户自行配置 Token（详见"安全配置"章节） 2. **包含 Prompt 模板**：`prompts/` 文件夹包含 JD 解析和话术生成的 Prompt 模板，这是功能需要，不是恶意 prompt injection 3. **不自动发送消息**：本 skill **不会自动发送任何邮件或消息**，所有触达话术仅作草稿，需人工审核后手动发送 **合规保证**： - ✅ 仅使用 GitHub 公开 API 和公开数据 - ✅ Human-in-the-Loop：用户逐一确认候选人，不批量骚扰 - ✅ Token 存储在本地 `.env` 文件（不上传到 git） - ✅ 生成的触达话术需人工审核，不自动执行 - ❌ 不购买/爬取私密数据 - ❌ 不绕过任何反爬机制 --- ## 核心能力 1. **基于真实代码搜人**：直接从 GitHub 代码仓库搜索——找到的是候选人真正做过的项目，不是简历上可以美化的内容，而是实打实写过的代码。传统招聘平台搜不到的技术关键词（如 RocksDB、LangChain、vLLM），在这里能精准命中 2. **智能排序**：按技术匹配度、活跃度、可联系性等维度自动排序，优先展示最值得联系的人 3. **全球定位**：支持按国家、省份、城市筛选候选人，也可不限地区搜索远程人才 4. **人工筛选**：候选人列表由你逐一确认，避免大撒网式骚扰 5. **个性化触达**：根据候选人真实项目经历生成定制话术，让对方感到"你真的了解我" --- ## 触发场景 **用户可能这样说**： - HR："帮我找几个做 RAG 的 Python 工程师，深圳的" - CEO："想找个 Rust 大牛来当技术合伙人" - 猎头："客户要招 Next.js 前端，北京，给我 5 个候选人" - CTO："帮我搜一下做分布式存储的 C++ 工程师，要最近半年活跃的" - 创业者："我们在做 AI Agent，想找几个有 LangChain 经验的人聊聊" **Skill 自动执行**： 1. 理解招聘需求 → 生成搜索条件（Step 0） 2. 从 GitHub 搜索候选人 → 构建画像并排序（Step 1） 3. 展示候选人列表 → 你来选人（Step 1.5） 4. 展示选中候选人的完整信息 → 最终确认（Step 2） 5. 为确认的候选人生成个性化触达话术（Step 3） --- ## 执行流程 ### Step 0: 智能解析（JD Parser） **目标**：将用户的口语化需求转译为 GitHub 搜索参数。 **操作**： 1. 读取 `prompts/jd_parser_prompt.md` 2. 将用户输入代入 Prompt，生成标准化 JSON 3. 输出格式： ```json { "search_queries": [ "language:c++ rocksdb pushed:>2025-01-01", "language:c++ leveldb lsm-tree pushed:>2025-01-01" ], "target_languages": ["C++"], "ecosystem_keywords": ["rocksdb", "leveldb", "lsm-tree"], "location": "china", "reasoning": "将'RocksDB 相关'转译为 rocksdb + leveldb + lsm-tree 生态" } ``` **约束规则**：详见 `prompts/jd_parser_prompt.md`（包含查询生成、活跃度过滤、地理位置处理、负向排除等规则）。 --- ### Step 1: 搜索（GitHub Search） **目标**：从 GitHub 搜索候选人，构建完整画像并全局排序。 **操作**： ```bash cd ~/.openclaw/workspace/skills/ai-talent-hunter # 首次搜索（宽松位置匹配，默认） python scripts/github_search.py \ --queries "language:c++ rocksdb pushed:>2025-01-01" "language:c++ leveldb lsm-tree pushed:>2025-01-01" \ --jd-keywords "rocksdb,lsm-tree,storage" \ --jd-language "C++" \ --location "shenzhen" \ --target 20 \ -o results.json # 首次搜索（严格位置匹配 — 仅匹配指定城市/省份） python scripts/github_search.py \ --queries "..." \ --jd-keywords "..." \ --jd-language "C++" \ --location "beijing" \ --location-strict \ --target 20 \ -o results.json # 继续搜索（当已有结果全部展示完，需要搜更多时） python scripts/github_search.py --resume SEARCH_ID --target 20 -o results.json ``` **需要环境变量**： ```bash export GITHUB_TOKEN=ghp_xxxxx # GitHub Personal Access Token ``` **地理位置匹配模式**（⚠️ OpenClaw 必须根据用户措辞判断使用哪种模式）： | 用户措辞 | 模式 | CLI 参数 | 行为 | |---------|------|---------|------| | "Base 深圳"、"最好在北京"、"深圳优先" | **宽松**（默认） | `--location "shenzhen"` | 城市→省份→国家→中文启发式 层级回落 | | "**必须是**北京"、"**只要**北京的"、"**限制**北京" | **严格** | `--location "beijing" --location-strict` | 仅匹配城市/省份，不回落到国家级，**Remote 也不通过** | | "不限地区"、"远程"、不提位置 | **不过滤** | 不加 `--location` | 不做任何位置过滤 | **注意**：宽松模式下 Remote 用户自动通过；严格模式下 Remote 不通过（用户说"必须是北京"就不想看其他城市的人，包括 Remote）。 **搜索引擎说明**： - 同时搜索**仓库**（技术关键词维度）和**用户**（地理位置维度），合并去重 - 自动循环直到达到目标人数或触发停止条件（结果耗尽 / 连续 2 轮零新增） - 多条 query 之间是 OR 逻辑（覆盖不同技术维度） - 无邮箱的候选人不过滤，仅在排序中降权 - 输出包含漏斗数据（`funnel` 字段）和停止状态（`status` 字段） **搜索结果不佳时的优化建议**（⚠️ OpenClaw 必须根据漏斗数据自动判断并给出建议）： 搜索脚本输出 `funnel` 字段（漏斗数据）和 `status` 字段。当 `status` 不是 `threshold_reached` 时，OpenClaw 根据以下规则生成优化建议： | 漏斗瓶颈 | 判断条件 | 建议 | |----------|---------|------| | **技术太冷门** | `raw_from_repos < 50` | "当前技术方向搜索结果很少，建议扩展到相邻技术栈。" 并给出具体扩展建议（如 VisionOS → 加入 ARKit、SceneKit） | | **地理位置太严格** | `after_dedup > 100` 但 `after_location_filter < 10` | "技术匹配的候选人不少，但目标城市的人很少。建议放宽到省份/全国，或使用宽松模式。" | | **活跃度过滤过严** | `after_location_filter > 50` 但高分候选人 < 5 | "候选人数量足够但质量不高（多数不活跃或匹配度低）。建议放宽 pushed 时间范围（如 2024 年）。" | | **搜索结果耗尽** | `status = "exhausted"` | "已搜索全部可用结果。建议调整搜索关键词或放宽条件。" | OpenClaw 给出建议时，必须提供**具体的操作指引**（如"去掉 --location-strict"、"关键词加入 XX"），而非泛泛而谈。 --- **排序算法 V3（总分 100 分）**： **设计原则**（猎头视角）： - 领域匹配是前提：找错人一切白费 - 可触达性决定转化率：联系不上的大牛不如联系得上的牛人 - 影响力体现技术深度：但用 log 曲线避免 star 通胀 - 活跃度预测响应概率：活跃的人更可能回复 - 生态位是锦上添花：区分 leader vs follower | 维度 | 得分规则 | 权重 | |------|----------|------| | **1. 领域匹配度** | 主语言匹配(15) + 生态关键词分级(10/15/20) | 35 分 | | **2. 可触达性** | 邮箱(10) + 博客(6) + Twitter(5) + 社交(6) + bio(3) + isHireable(5)，可叠加，上限25 | 25 分 | | **3. 代码影响力** | log₁₀(Star)×3(12) + log₁₀(Fork)×2(4) + log₁₀(Followers)×2(4) | 20 分 | | **4. 有效活跃度** | 30天(12) / 90天(8) / 180天(4) | 12 分 | | **5. 开源生态位** | 顶级Owner>500★(8) / 中等>100★(5) / 活跃>20★(3) | 8 分 | **详细评分逻辑**： **1. 领域匹配度（Relevance - 35分）** - **规则 A**（主语言匹配）：Top 5 仓库的主语言与 JD 匹配 → +15 分 - **规则 B**（生态关键词）：Top 5 仓库的名称/描述命中 JD 关键词，按命中数分级： 匹配源包含 repo 名称、描述和 **repositoryTopics 标签**（标签匹配比文本匹配更精准） - 命中 3+ 个 → +20 分（深度匹配） - 命中 2 个 → +15 分 - 命中 1 个 → +10 分 **2. 可触达性（Reachability - 25分）** - **多渠道叠加，上限 25 分**： - 公开邮箱 → +10 分（最直接的触达方式） - 个人网站/博客 → +6 分（可找到更多联系方式，也是破冰素材） - Twitter/X → +5 分（DM 是有效触达渠道） - 其他社交账号 → 每个 +3 分，上限 +6 分（LinkedIn、知乎等） - 有 bio → +3 分（愿意被人了解的信号） - isHireable = true → +5 分（候选人主动标记"可被雇佣"，回复概率极高） - **说明**：不过滤无联系方式的候选人，仅影响排序。大牛无邮箱仍会出现，但排序靠后。HR 可通过 GitHub Issue/PR 评论等方式联系。 **3. 代码影响力（Impact - 20分）** - **规则 A**（Star 影响力）：log₁₀(Star总数) × 3，封顶 12 分 - 10★ → 3分, 100★ → 6分, 1000★ → 9分, 5000+★ → 12分 - **规则 B**（Fork 复用度）：log₁₀(Fork总数) × 2，封顶 4 分 - **规则 C**（社区关注度）：log₁₀(Followers) × 2，封顶 4 分 - **设计理由**：用 log 曲线而非线性，避免 star 通胀稀释区分度 **4. 有效活跃度（Activity - 12分）** - 综合两个信号： - 信号 A — 最近活跃时间：30天内(8) / 90天内(5) / 180天内(3) - 信号 B — 年度贡献量：1000+(4) / 300+(3) / 100+(2) / >0(1) - 两个信号相加，上限 12 分 **5. 开源生态位（Ecosystem - 8分）** - 是 Owner 且最高星仓库 > 500★ → 8 分（顶级 Owner） - 是 Owner 且最高星仓库 > 100★ → 5 分（有影响力的 Owner） - 是 Owner 且最高星仓库 > 20★ → 3 分（活跃 Owner） **档位划分**： - 🔥 **极度匹配**：≥80 分（强烈推荐） - ⭐ **高度匹配**：65-79 分（推荐） - 👍 **匹配**：50-64 分（可考虑） - 📋 **参考**：<50 分（需人工判断） **输出**：`results.json`（完整候选人列表，已排序，含漏斗数据和停止状态） --- ### Step 1.5: 候选人展示与筛选（Human-in-the-Loop） **目标**：让用户快速扫描候选人，标记状态（selected / passed / pending）。 **为什么需要这一步？** - ❌ 不能直接批量生成触达话术（垃圾邮件制造机） - ✅ 用户必须先筛选候选人，确认要触达的人 --- #### 展示格式（Markdown 表格，飞书兼容） **每批展示数量**：最多 15 人。不足 15 人时展示全部。 **表头固定格式**： ```markdown # 🎯 [职位名] — 候选人列表 搜索范围: [语言] + [关键词] | [位置] | 共 N 人，展示 Top 15 | # | 匹配 | 候选人 | 简介 | 公司 | 经验 | 📍 | 联系 | 状态 | 代表作 | |---|------|--------|------|------|------|-----|------|------|--------| | 1 | 🔥 | 张三 (@zhangsan) | ... | 字节跳动 | 8年 | 深圳 | 📧🌐🐦 | 🟢 | mutative (2.0K) | | 2 | ⭐ | 李四 (@lisi) | ... | 阿里巴巴 | 5年 | 杭州 | 📧 | 🟢 | ssr (2.7K) | ... --- 回复数字选择：`1,3` = 选中 | `pass 2` = 跳过 | 「继续」查看下一批 ``` --- #### 各列填写规范 | 列名 | 数据来源 | 填写规则 | |------|----------|----------| | **#** | 序号 | 当前批次内的序号（1-15） | | **匹配** | `match_label` 字段 | 🔥 极度匹配(≥80) / ⭐ 高度匹配(65-79) / 👍 匹配(50-64) / 📋 参考(<50) | | **候选人** | `name` + `github_id` | 格式：`真实姓名 (@github_id)`。如果 name 等于 github_id 则只写一个 | | **简介** | **OpenClaw 根据 bio + 代表作 + 公司综合生成** | 见下方「简介生成规则」 | | **公司** | `company` 字段 | 去掉 `@` 前缀。为空时写「独立开发者」。超过 12 字截断加 `..` | | **经验** | `dev_years` 字段 | 格式：`N年`（基于 GitHub 账号创建时间推算，注意是"GitHub 活跃年限"不是"工作经验"） | | **📍** | `location` 字段 | 翻译为中文城市名。超过 6 字截断。目标城市加 🎯 标记 | | **联系** | `contact_icons` 字段 | 图标组合：📧=邮箱 🌐=博客/网站 🐦=Twitter 🔗=其他社交。**不展示具体值**（Step 2 才展示） | | **状态** | `activity_label` 字段 | 🟢=30天内活跃 🟡=90天内 🔵=半年内 ⚪=不活跃。如果 isHireable=true，activity_label 后面会带 "| 🟢 求职中" | | **代表作** | 优先 `showcase_repos`（候选人自选置顶项目），无置顶时用 `top_repos[0]` | 格式：`项目名 (星数)`。星数 ≥1000 用 K 表示。置顶项目加 📌 标记 | --- #### 简介生成规则（⚠️ 重要：这一列由 OpenClaw 生成，不是原始数据） **目的**：让不懂技术的 HR/猎头 一眼理解这个人是做什么的、为什么值得联系。 **生成规则**： 1. **长度**：一句话，15-30 个中文字 2. **内容优先级**（按顺序取最有价值的信息）： - 如果有知名公司经历 → 提及公司 + 角色（如："字节跳动 AI 工作流框架核心开发者"） - 如果代表作 star 数高 → 提及影响力（如："11K+ 开发者使用的 React 文档框架作者"） - 如果 bio 有明确方向 → 提炼方向（如："专注 RAG 和知识图谱的 AI 研究员"） - 兜底 → 根据代表作描述概括（如："用 Next.js 构建全栈电商项目"） 3. **技术术语规则**：可以使用 JD 中出现过的技术词（如 React、Next.js），但不引入 JD 以外的技术术语 4. **禁止**： - ❌ 不要照搬英文 bio（要翻译/提炼为中文） - ❌ 不要写"该候选人..."这种第三人称废话 - ❌ 不要重复代表作列已有的信息 5. **示例**： - ✅ "阿里 13 年老兵，ice.js 渐进式框架核心作者" - ✅ "腾讯前端，开发基于 Next.js 的边缘计算博客引擎" - ✅ "全栈工程师，专注 React 状态管理和性能优化" - ❌ "A passionate developer who loves open source"（照搬英文） - ❌ "该候选人是一名优秀的前端工程师"（废话） --- #### 用户交互规则 | 用户说 | OpenClaw 行为 | |--------|------------| | `"1, 3, 5"` 或 `"选 1 3 5"` | 标记为 selected，调 `candidate_manager.py --action batch-update` | | `"pass 2, 4"` 或 `"跳过 2 4"` | 标记为 passed | | `"继续"` / `"下一批"` / `"搜更多"` / `"继续搜索"` / `"更多"` | **统一逻辑**：见下方「继续/搜更多的判断规则」 | | `"修改要求"` 或 `"换个条件"` | 回到 Step 0 重新解析 JD | | `"就这些了"` 或 `"完成"` | 进入 Step 2 二次确认 | | `"看看 zhangsan"` | 调 `candidate_manager.py --action get-candidate --github-id zhangsan` 展示详情 | **⚠️「继续/搜更多」的判断规则（重要！不要让用户区分这两个操作）**： 当用户说"继续"、"搜更多"、"下一批"、"继续搜索"或任何表达"想看更多人"的意思时，OpenClaw 按以下优先级自动判断： ``` 1. 如果还有未展示的已有结果 → 直接翻页展示下一批 15 人（不调 API） 2. 如果已有结果全部展示完了 → 调 github_search.py --resume SEARCH_ID 搜索新页面 3. 如果搜索已耗尽（status=exhausted/zero_yield）→ 告知用户无更多结果 + 给出优化建议 ``` **用户不需要知道"翻页"和"搜索"的区别。** 这是内部实现细节，OpenClaw 自动判断。 **翻页终止规则**： - 如果下一批的最高分 < 50（📋 参考级别），在展示前提醒：「⚠️ 后续候选人匹配度较低，建议调整搜索条件」 - 如果已有结果全部展示完且搜索未耗尽，提示：「已展示全部 N 人，正在搜索更多候选人...」然后自动调 --resume --- #### 状态管理 ```bash cd ~/.openclaw/workspace/skills/ai-talent-hunter # 批量更新状态 python scripts/candidate_manager.py --action batch-update \ --search-id UUID --selected "user1,user2" --passed "user3" # 查看某人详细信息 python scripts/candidate_manager.py --action get-candidate --github-id "zhangsan" ``` **数据持久化**： - 所有搜索记录保存到：`~/.openclaw/workspace/skills/ai-talent-hunter/data/candidates.jsonl` - 格式：每行一个 JSON 对象（JSONL） - 支持跨搜索查询（防止重复推荐已 selected 的候选人） --- ### Step 2: 二次确认（Selected Review） **目标**：用非技术语言展示所有 selected 候选人的完整信息，让 HR/猎头/高管最终确认后再生成触达话术。 **⚠️ 核心原则**：用户可能不懂技术、无法访问 GitHub。所有信息必须翻译为商业语言。 **操作**： ```bash cd ~/.openclaw/workspace/skills/ai-talent-hunter python scripts/candidate_manager.py --action get-selected --search-id UUID ``` --- #### 每位候选人的展示模板 OpenClaw 按以下结构为每位 selected 候选人生成展示内容： **1. 一句话推荐**（⚠️ 由 OpenClaw 生成） - 用商业语言概括此人为什么值得联系 - 提及公司背景 + 核心成就 + 与岗位的关联 - 示例： - ✅ "RingCentral（美资通讯巨头）在职 9 年资深前端，独立开发的开源工具被全球 2000+ 开发团队使用，React 生态核心技术栈完全匹配" - ❌ "TypeScript 开发者，有 mutative 项目 2000 stars"（技术黑话，HR 看不懂） **2. 基本信息**（表格） | 项目 | 信息 | |------|------| | 真实姓名 | 从 `name` 字段取 | | 当前公司 | 从 `company` 字段取，如果是知名公司则括号补充公司简介 | | 所在城市 | 翻译为中文 | | GitHub 活跃年限 | 从 `dev_years` 字段取，注意措辞为"GitHub 活跃 N 年"而非"工作经验 N 年" | | 活跃状态 | 🟢/🟡/🔵/⚪ | | 求职状态 | isHireable=true → "✅ 已标记接受新机会" / false → 不显示 | **3. 职业亮点**（⚠️ 由 OpenClaw 生成，列表形式） - 将技术成就翻译为非技术人员能理解的描述 - 翻译规则： - Star 数 → "N 个开发者/团队关注使用" - 技术术语：**可以使用 JD 中出现过的技术词**（如 JD 提到 React 则可写"React 框架"），**但不引入 JD 以外的技术术语**（如 JD 没提 TypeScript 就不要写） - 不出现 GitHub 链接（用户可能打不开） - 项目描述要用一句话解释这个项目解决什么问题 - 示例（假设 JD 提到了 React/Next.js）： - ✅ "独立开发了高性能数据处理工具 mutative，被 2000+ 开发者关注使用，比同类方案快 2-6 倍" - ✅ "参与字节跳动 flowgram.ai 工作流平台核心开发，该项目被 7800+ 开发者关注" - ✅ "开发了 React 文档框架 fumadocs，被 11000+ 开发者使用"（JD 提到了 React，可以用） - ❌ "有一个 TypeScript 项目叫 mutative，2000 stars"（JD 没提 TypeScript，且用了 stars 原始术语） - 贡献统计翻译规则： - contributions.total → "过去一年贡献了 N 次代码" - contributions.commits + contributions.pull_requests → "提交了 N 次代码修改，N 个代码改进请求" - pinnedItems → "候选人自选代表作：[列出置顶项目名]" **4. 与岗位匹配度** - 匹配标签（🔥/⭐/👍/📋） - 逐条列出与 JD 需求的匹配点和不匹配点 - 示例： - ✅ React/Next.js 技术栈完全吻合 - ✅ 有大型框架设计和开发经验 - ⚠️ 目前在厦门，非目标城市北京 **5. 联系方式与建议**（表格） | 渠道 | 信息 | 建议 | |------|------|------| | 📧 邮箱 | 具体邮箱地址 | ✅ 推荐，最直接 | | 🌐 个人网站 | 具体网址 | 可参考了解更多 | | 🐦 Twitter | 具体链接 | 可作为备选 | | 🔗 其他社交 | 具体链接 | — | - 如果无公开邮箱，给出替代建议："⚠️ 无公开邮箱，建议通过 Twitter 私信 或 GitHub 主页留言联系" **6. 风险提示** - ⚠️ 近期不活跃，可能已转型或离职 - ⚠️ 不在目标城市，需确认远程/搬迁意愿 - ⚠️ 无邮箱，联系难度较高 - ✅ isHireable=true → "此人已主动标记接受新机会，回复概率较高" - ⚠️ isHireable=false + 知名大厂在职 → "未标记求职意向，可能需要更有竞争力的条件" - ✅ 无明显风险（如果没有问题也要写） --- #### 用户操作 | 用户说 | OpenClaw 行为 | |--------|-------------| | "确认全部" | 进入 Step 3（为所有 selected 生成触达话术） | | "去掉 2 号" 或 "取消 XX" | 将其从 selected 改为 passed，不再生成话术 | | "补人" 或 "回去再选" | 回到 Step 1.5 继续筛选 | --- ### Step 3: Pitch Writer（生成触达话术） **目标**：为每位确认的候选人生成个性化触达消息。 **前置条件**： - ⚠️ 只针对 Step 2 中最终确认的 selected 候选人 - ⚠️ 绝不批量生成（垃圾邮件制造机） **⚠️ 前置信息收集（如果缺失，OpenClaw 必须先询问用户）**： | 信息 | 为什么需要 | 示例 | |------|----------|------| | 发送人身份 | 决定语气（CTO≠HR≠CEO） | CTO / HR / 猎头 / CEO / 合伙人 | | 发送人姓名 | 署名 | "李明" | | 公司名称 | 让对方知道谁在联系 | "字节跳动" / "一家 A 轮 AI 创业公司" | | 触达目的 | 决定话术方向 | 全职招聘 / 技术顾问 / 开源合作 | | 触达渠道 | 决定长度和格式 | 邮件 / Twitter DM / 微信 | **操作**： 1. OpenClaw 读取 `prompts/pitch_writer_prompt.md` 2. 结合候选人 profile + 上述前置信息 3. 为每位候选人生成定制话术 **核心规则**（详见 `prompts/pitch_writer_prompt.md`）： - 语气匹配发送人身份（CTO 谈技术、CEO 谈愿景、HR 展现专业了解） - 必须引用候选人的真实项目/背景（不是泛泛赞美） - 绝不使用 HR 套话（"薪资丰厚"、"诚邀加入"、"我们是一家..."） - 字数严格控制（邮件 ≤150 字、Twitter DM ≤80 字、微信 ≤100 字） - 行动邀请低压力（"聊聊"而非"面试"） --- ## 合规红线（强制） **✅ 允许的数据源**： - GitHub 公开 API - 个人博客公开文章 - Twitter/X 公开推文 **❌ 严禁的行为**： - 购买/爬取私密数据 - 未经授权的批量邮件发送 - 绕过反爬机制的暴力抓取 - 任何形式的钓鱼/伪装 **🔒 Human-in-the-Loop 强制要求**： - 生成的触达话术仅作草稿 - 必须由 HR/业务人员审核 - 手动通过邮件/Twitter 发送 - Skill 本身不执行发送 --- ## 故障排查 ### GitHub API 返回 403 Forbidden **原因**：GitHub Token 权限不足或已过期。 **解决方案**： ```bash # 重新生成 Token（需要 read:user 和 read:org 权限） export GITHUB_TOKEN=ghp_新的token ``` --- ## 依赖 - Python 3.8+ - requests - GitHub Personal Access Token（需要 `read:user` 和 `read:org` 权限） ### ⚠️ 安全配置 **GitHub Token 获取**： 1. 访问 https://github.com/settings/tokens 2. 创建新 token，勾选 `read:user` 和 `read:org` 权限 3. 复制 token 到 `.env` 文件（**不要提交到 git！**） **环境变量配置**： ```bash # 创建 .env 文件（已在 .gitignore 中，不会被上传） echo "GITHUB_TOKEN=your_token_here" > .env # 或者临时使用（不持久化） export GITHUB_TOKEN=ghp_your_token_here ``` **🔒 安全红线**： - ❌ 绝对不要把真实 Token 提交到 git/GitHub - ❌ 不要在公开的 SKILL.md 示例中使用真实 Token - ✅ 使用 `.env` 文件（已加入 .gitignore） - ✅ 示例中使用 `ghp_xxxxx` 占位符 --- ## 示例用法 ### 完整流程示例 ```bash # Step 0: JD 解析（在 OpenClaw 中执行） # 输入："找 5 个深圳的 RocksDB C++ 工程师，要求最近 1 年内有活跃贡献" # 输出：search_params.json # Step 1: 搜索 cd ~/.openclaw/workspace/skills/ai-talent-hunter export GITHUB_TOKEN=ghp_xxxxx python scripts/github_search.py \ --queries "language:c++ rocksdb pushed:>2025-01-01" \ --jd-keywords "rocksdb,lsm-tree,storage" \ --jd-language "C++" \ --location "shenzhen" \ --target 20 \ -o results.json # Step 1.5: 候选人展示与筛选（OpenClaw 展示，用户选择） # 用户："1, 3, 5" → selected，"pass 2, 4" → passed python scripts/candidate_manager.py --action batch-update \ --search-id "uuid" --selected "zhangsan,wangwu" --passed "lisi" # Step 2: 二次确认（展示 selected 候选人完整信息） python scripts/candidate_manager.py --action get-selected --search-id "uuid" # Step 3: 生成触达话术（只针对 selected 的人） # OpenClaw 读取 prompts/pitch_writer_prompt.md 生成个性化触达消息 ``` --- ## 注意事项 1. **质量优先**：搜索结果可能有数百人，但只展示排序后的 Top 候选人。用户逐批确认，而非一次性倾倒所有结果。 2. **触达话术是草稿**：生成的消息需人工审核后再发送，Skill 本身不执行发送。

path: references/compliance.md
content: # 合规红线说明

AI Talent Hunter 仅使用公开数据（OSINT），严格遵守数据隐私和反垃圾邮件法规。

---

## ✅ 允许的数据源

| 数据源 | 说明 | 合规性 |
|--------|------|--------|
| **GitHub 公开 API** | 用户公开的 Profile、仓库、Commit | ✅ 合规（GitHub ToS 允许） |
| **个人博客** | 用户主动公开的文章 | ✅ 合规（公开内容） |
| **Twitter/X 公开推文** | 用户公开的技术讨论 | ✅ 合规（公开内容） |
| **Stack Overflow** | 用户公开的回答 | ✅ 合规（CC BY-SA 协议） |
| **arXiv** | 公开的学术论文 | ✅ 合规（开放获取） |

---

## ❌ 严禁的数据源

| 数据源 | 为什么禁止 |
|--------|-----------|
| **LinkedIn 私密信息** | 需要登录，违反 LinkedIn ToS |
| **购买的邮件列表** | 违反 GDPR / CAN-SPAM Act |
| **未经授权的数据库** | 违反数据保护法 |
| **爬虫绕过反爬** | 违反网站 ToS |

---

## 🔒 Human-in-the-Loop 强制要求

**Skill 不执行任何自动化触达动作**，仅生成草稿。

### 必须的人工审核环节

1. **HR/业务人员审核触达话术**
   - 检查是否引用了正确的 Commit/文章
   - 确认话术符合公司品牌调性
   - 避免技术错误（如引用错误的项目）

2. **手动发送邮件/私信**
   - 通过个人邮箱发送（而非群发工具）
   - 通过 Twitter 私信（而非自动化机器人）
   - **绝不批量发送**（单点爆破原则）

3. **记录触达历史**
   - 避免重复触达同一候选人
   - 如果候选人回复"不感兴趣"，立即停止

---

## 📧 触达渠道的合规性

### ✅ 合规的触达方式

| 渠道 | 合规要求 |
|------|---------|
| **个人邮箱** | 必须从公开渠道（GitHub Profile、博客）获取 |
| **Twitter 私信** | 仅对公开账号，尊重"拒绝 DM"设置 |
| **GitHub Issue/PR 评论** | ❌ **不推荐**（会被视为垃圾信息） |
| **LinkedIn InMail** | ⚠️ 需要 LinkedIn Premium，遵守 LinkedIn 政策 |

### ❌ 禁止的触达方式

- 未经授权的批量邮件发送
- 使用虚假身份（如伪装成技术人员）
- 钓鱼式触达（如"您中奖了"）

---

## 🌍 地区法规遵守

### GDPR（欧盟）

- ✅ 仅使用公开数据，无需 Consent
- ✅ 如果候选人要求删除数据，立即执行
- ❌ 不存储敏感数据（种族、宗教、政治观点）

### CAN-SPAM Act（美国）

- ✅ 邮件必须包含真实的发件人信息
- ✅ 必须提供退订链接（Unsubscribe）
- ❌ 不使用误导性主题行

### PIPL（中国个人信息保护法）

- ✅ 公开数据无需授权
- ✅ 如果候选人要求删除，立即执行
- ❌ 不跨境传输个人数据（除非候选人同意）

---

## ⚠️ 反爬虫合规

### 合规的抓取行为

- ✅ 使用官方 API（GitHub API、Twitter API）
- ✅ 遵守 Rate Limit（GitHub: 5000 req/h）
- ✅ 尊重 `robots.txt`（博客抓取时）
- ✅ 轻量级抓取（每个博客最多5篇文章）

### 禁止的抓取行为

- ❌ 绕过反爬验证码（CAPTCHA）
- ❌ 使用代理池暴力抓取
- ❌ 伪造 User-Agent 假装是浏览器
- ❌ 高频请求（如每秒 >10 次）

---

## 🚨 应急响应

### 如果收到投诉

1. **立即停止触达该候选人**
2. **删除所有相关数据**（如果候选人要求）
3. **记录投诉日志**（时间、候选人、投诉内容）
4. **优化触达话术**（避免类似投诉）

### 如果被平台封禁

1. **GitHub Token 被封** → 检查是否违反 Rate Limit 或 ToS
2. **Twitter 账号被封** → 检查是否发送了垃圾私信
3. **博客被屏蔽** → 检查是否违反 `robots.txt`

---

## 📝 审计日志

**建议**：记录每次搜索和触达的日志，便于合规审计。

**日志内容**：
```json
{
  "timestamp": "2026-03-25T15:30:00Z",
  "action": "search",
  "query": "language:rust raft location:beijing",
  "results": 5,
  "operator": "[REDACTED_SECRET]"
}
```

**日志存储**：
- 本地文件：`~/.ai-talent-hunter/audit.log`
- 或推送至 Notion/飞书（便于团队审计）

---

## ✅ 自检清单

在执行搜索前，确认：

- [ ] 是否仅使用公开数据？
- [ ] 是否有 Human-in-the-Loop 审核环节？
- [ ] 是否避免了批量自动化触达？
- [ ] 是否遵守了目标地区的数据保护法？
- [ ] 是否尊重了网站的 `robots.txt` 和 ToS？
- [ ] 是否有应急响应预案（如果收到投诉）？

**如果任何一项为"否"，立即停止执行。**

---

**最后更新**: 2026-03-25  
**审核人**: 汤圆 🧣


path: README.md
content: # 🎯 GitHub 人才猎手 (GitHub Talent Hunter)

**从代码里找人，比简历更真实。**

传统招聘平台上，候选人可以美化简历、夸大经验。但代码不会说谎。

GitHub 人才猎手直接从 GitHub 代码仓库搜索技术人才——找到的每个人都有**真实的项目、真实的代码、真实的贡献记录**。那些在招聘平台上搜不到的技术关键词（RocksDB、LangChain、vLLM...），在这里能精准命中。

---

## 谁需要这个工具？

| 角色 | 痛点 | 怎么用 |
|------|------|--------|
| **HR** | 老板说要找"做 RAG 的 Python 工程师"，招聘网站搜不到 | "帮我找几个做 RAG 的 Python 工程师，深圳的" |
| **猎头** | 客户给了极其刁钻的技术要求，传统人才库没有 | "客户要招 Next.js 前端，北京，给我 5 个候选人" |
| **CTO** | 想从开源社区挖一个真正懂底层的人 | "帮我搜做分布式存储的 C++ 工程师，最近半年活跃的" |
| **CEO / 创业者** | 想找技术合伙人，需要看到对方真正做过什么 | "我们在做 AI Agent，想找几个有 LangChain 经验的人聊聊" |

---

## 解决什么痛点？

| 痛点 | AI Talent Hunter 怎么解决 |
|------|--------------------------|
| 🎭 **简历造假、经验注水** | 基于 GitHub 真实代码搜索，项目做没做过一目了然 |
| 🔍 **技术关键词搜不到** | "RocksDB 工程师"在招聘网站搜不到？直接搜代码仓库，精准匹配 |
| 😶 **找到人了不知道怎么开口** | 根据候选人真实项目经历，自动生成个性化触达话术 |
| 📊 **海量结果不知道先联系谁** | 智能排序：技术匹配度 + 活跃度 + 可联系性 + 影响力 |
| 🤯 **不懂技术看不懂结果** | 自动翻译为商业语言，HR 也能一眼看懂每个候选人在做什么 |

---

## 核心亮点

### 🔬 基于真实代码，不是简历

搜索的是 GitHub 上的代码仓库和开发者 Profile，找到的候选人都有：
- 真实的开源项目（不是简历上写的"精通 XX"）
- 真实的代码贡献记录
- 真实的项目影响力（多少人在用 ta 的项目）

### 🌍 全球人才，精准定位

支持按城市、省份、国家筛选。覆盖中国全部省市 + 日本、新加坡、美国、英国等全球主要技术中心。也可以不限地区搜索远程人才。

### 🤝 你来选人，不是机器决定

不会自动批量发消息。每一步都需要你确认：
1. 搜索结果逐批展示，你来标记谁值得联系
2. 选中的人展示完整档案，你做最终确认
3. 生成的话术只是草稿，由你审核后手动发送

### ✉️ 话术个性化，不是模板

生成的触达消息会：
- 引用候选人真实做过的项目（"看到你的 fumadocs 被 11000+ 开发者使用"）
- 根据你的身份调整语气（CTO 和 HR 说话方式完全不同）
- 根据渠道控制长度（邮件 vs 微信 vs Twitter DM）

---

## 快速开始

### 前置要求

- [OpenClaw](https://openclaw.com) 环境
- GitHub Personal Access Token（[点击获取](https://github.com/settings/tokens)，勾选 `read:user` 和 `read:org`）

### 配置

```bash
export GITHUB_TOKEN=ghp_your_token_here
```

### 使用

安装为 OpenClaw Skill 后，直接对话：

> "帮我搜索深圳做 RAG 方向的 Python 工程师"

剩下的它会引导你完成全部流程。

---

## 工作流程

```
你的需求 → 理解并生成搜索条件 → GitHub 全球搜索 → 智能排序
    → 你来选人 → 展示完整档案 → 你做最终确认 → 生成个性化话术
```

详细流程和技术文档见 [SKILL.md](./SKILL.md)。

---

## 合规说明

- ✅ 仅使用 GitHub 公开 API 和公开数据
- ✅ 人在回路：不自动发送任何消息
- ✅ 每封触达消息都是定制的，不是群发模板
- ❌ 不购买/爬取私密数据
- ❌ 不绕过任何反爬机制

---

*Made with ❤️ by [OpenClaw](https://openclaw.com)*


path: scripts/candidate_manager.py
content: #!/usr/bin/env python3
"""
候选人状态管理器

功能：
1. 保存所有搜索到的候选人
2. 记录候选人状态（pending / selected / passed）
3. 防止重复推荐
4. 支持查询历史记录
5. 搜索记录支持 resume（search_params / page offsets / seen ids）
6. 批量更新候选人状态
7. 跨搜索查询候选人出现记录

数据结构：
{
  "search_id": "uuid",
  "jd_title": "AI 原生应用开发工程师",
  "search_date": "2026-03-25",
  "queries": [...],
  "location_filter": "shenzhen",
  "search_params": { ... },
  "seen_github_ids": ["user1", "user2"],
  "repo_page_offsets": {"query1": 3, "query2": 5},
  "user_page_offsets": {"query1": 2},
  "status": "in_progress",
  "rounds_searched": 1,
  "candidates": [
    {
      "github_id": "zhangsan",
      "name": "Zhang San",
      "score": 210.5,
      "status": "pending",  // pending / selected / passed
      "selected_at": null,
      "passed_at": null,
      "pass_reason": null,
      ...
    }
  ]
}
"""

import os
import json
import uuid
from datetime import datetime
from typing import List, Dict, Optional


class CandidateManager:
    """候选人状态管理器"""

    def __init__(self, db_path: str = "~/.openclaw/workspace/skills/ai-talent-hunter/data/candidates.jsonl"):
        self.db_path = os.path.expanduser(db_path)
        os.makedirs(os.path.dirname(self.db_path), exist_ok=True)

    def _load_records(self) -> List[Dict]:
        """从 JSONL 文件加载所有记录"""
        records: List[Dict] = []
        if not os.path.exists(self.db_path):
            return records
        with open(self.db_path, 'r', encoding='utf-8') as f:
            for line in f:
                if line.strip():
                    records.append(json.loads(line))
        return records

    def _save_records(self, records: List[Dict]) -> None:
        """将所有记录写回 JSONL 文件"""
        with open(self.db_path, 'w', encoding='utf-8') as f:
            for r in records:
                f.write(json.dumps(r, ensure_ascii=False) + '\n')

    def save_search_result(
        self,
        search_id: Optional[str],
        jd_title: str,
        queries: List[str],
        location_filter: Optional[str],
        candidates: List[Dict],
        # 新增参数：支持搜索 resume 与状态追踪
        search_params: Optional[Dict] = None,
        seen_github_ids: Optional[List[str]] = None,
        repo_page_offsets: Optional[Dict] = None,
        user_page_offsets: Optional[Dict] = None,
        rounds_searched: int = 1,
        status: str = "in_progress",
    ) -> str:
        """
        保存搜索结果

        Args:
            search_id: 搜索记录 ID（如果为 None，自动生成）
            jd_title: 职位名称
            queries: 搜索查询
            location_filter: 地理位置过滤
            candidates: 候选人列表
            search_params: 完整搜索参数，用于 resume
            seen_github_ids: 已出现的 github_id 列表
            repo_page_offsets: query → 已搜到第几页（Repo Search）
            user_page_offsets: query → 已搜到第几页（User Search）
            rounds_searched: 已搜索轮数
            status: 搜索状态 ("in_progress" / "completed" / "abandoned")

        Returns:
            search_id: 搜索记录 ID
        """
        if search_id is None:
            search_id = str(uuid.uuid4())
        search_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

        record: Dict = {
            "search_id": search_id,
            "jd_title": jd_title,
            "search_date": search_date,
            "queries": queries,
            "location_filter": location_filter,
            "search_params": search_params,
            "seen_github_ids": seen_github_ids or [],
            "repo_page_offsets": repo_page_offsets or {},
            "user_page_offsets": user_page_offsets or {},
            "status": status,
            "rounds_searched": rounds_searched,
            "candidates": candidates,
            "total": len(candidates),
        }

        # 读取已有记录，查找是否存在相同 search_id
        records = self._load_records()
        existing_index = -1
        for i, r in enumerate(records):
            if r["search_id"] == search_id:
                existing_index = i
                break

        if existing_index >= 0:
            # 合并候选人列表：以新的覆盖旧的（基于 github_id 去重）
            old_record = records[existing_index]
            old_candidates_map = {c["github_id"]: c for c in old_record["candidates"]}
            for c in candidates:
                old_candidates_map[c["github_id"]] = c
            merged_candidates = list(old_candidates_map.values())
            record["candidates"] = merged_candidates
            record["total"] = len(merged_candidates)
            records[existing_index] = record
            print(f"[INFO] 搜索记录已更新（合并候选人）: search_id={search_id}")
        else:
            records.append(record)
            print(f"[INFO] 搜索记录已保存: search_id={search_id}")

        self._save_records(records)
        return search_id

    def update_candidate_status(
        self,
        search_id: str,
        github_id: str,
        status: str,
        reason: Optional[str] = None
    ):
        """
        更新候选人状态

        Args:
            search_id: 搜索记录 ID
            github_id: GitHub ID
            status: 状态（selected / passed / pending）
            reason: Pass 原因（可选）
        """
        if not os.path.exists(self.db_path):
            print(f"[ERROR] 数据文件不存在: {self.db_path}")
            return

        records = self._load_records()

        # 更新目标记录
        updated = False
        for record in records:
            if record["search_id"] == search_id:
                for candidate in record["candidates"]:
                    if candidate["github_id"] == github_id:
                        candidate["status"] = status
                        if status == "selected":
                            candidate["selected_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                        elif status == "passed":
                            candidate["passed_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                            candidate["pass_reason"] = reason
                        updated = True
                        break
                if updated:
                    break

        if not updated:
            print(f"[WARN] 未找到记录: search_id={search_id}, github_id={github_id}")
            return

        self._save_records(records)
        print(f"[INFO] 已更新候选人状态: {github_id} → {status}")

    def get_search_result(self, search_id: str) -> Optional[Dict]:
        """
        获取搜索结果

        Args:
            search_id: 搜索记录 ID

        Returns:
            搜索记录（包含候选人列表）
        """
        if not os.path.exists(self.db_path):
            return None

        with open(self.db_path, 'r', encoding='utf-8') as f:
            for line in f:
                if line.strip():
                    record = json.loads(line)
                    if record["search_id"] == search_id:
                        return record
        return None

    def is_candidate_already_selected(self, github_id: str) -> bool:
        """
        检查候选人是否已被选中过（跨搜索）

        Args:
            github_id: GitHub ID

        Returns:
            True 如果已被选中
        """
        if not os.path.exists(self.db_path):
            return False

        with open(self.db_path, 'r', encoding='utf-8') as f:
            for line in f:
                if line.strip():
                    record = json.loads(line)
                    for candidate in record["candidates"]:
                        if candidate["github_id"] == github_id and candidate["status"] == "selected":
                            return True
        return False

    def list_all_searches(self) -> List[Dict]:
        """
        列出所有搜索记录

        Returns:
            搜索记录列表（不含候选人详情）
        """
        if not os.path.exists(self.db_path):
            return []

        searches = []
        with open(self.db_path, 'r', encoding='utf-8') as f:
            for line in f:
                if line.strip():
                    record = json.loads(line)
                    searches.append({
                        "search_id": record["search_id"],
                        "jd_title": record["jd_title"],
                        "search_date": record["search_date"],
                        "total": record["total"],
                        "status": record.get("status", "unknown"),
                        "rounds_searched": record.get("rounds_searched", 0),
                        "selected_count": sum(1 for c in record["candidates"] if c.get("status") == "selected"),
                        "passed_count": sum(1 for c in record["candidates"] if c.get("status") == "passed"),
                    })
        return searches

    def get_candidate_appearances(self, github_id: str) -> Dict:
        """
        查询单个候选人在所有搜索中的出现记录

        Args:
            github_id: GitHub ID

        Returns:
            包含 appearances 列表和 latest_profile 的字典
        """
        records = self._load_records()
        appearances: List[Dict] = []
        latest_profile: Optional[Dict] = None
        latest_date = ""

        for record in records:
            for candidate in record["candidates"]:
                if candidate["github_id"] == github_id:
                    appearances.append({
                        "search_id": record["search_id"],
                        "jd_title": record["jd_title"],
                        "search_date": record["search_date"],
                        "score": candidate.get("score"),
                        "status": candidate.get("status", "pending"),
                    })
                    # reason: 按 search_date 取最新的完整 profile
                    if record["search_date"] > latest_date:
                        latest_date = record["search_date"]
                        latest_profile = candidate
                    break

        return {
            "github_id": github_id,
            "appearances": appearances,
            "latest_profile": latest_profile,
        }

    def get_selected_candidates(self, search_id: str) -> List[Dict]:
        """
        获取某次搜索中所有 selected 的候选人

        Args:
            search_id: 搜索记录 ID

        Returns:
            selected 候选人的完整列表
        """
        record = self.get_search_result(search_id)
        if record is None:
            return []
        return [c for c in record["candidates"] if c.get("status") == "selected"]

    def set_search_status(self, search_id: str, status: str) -> bool:
        """
        设置搜索记录的状态

        Args:
            search_id: 搜索记录 ID
            status: 新状态 ("in_progress" / "completed" / "abandoned")

        Returns:
            是否更新成功
        """
        records = self._load_records()
        for record in records:
            if record["search_id"] == search_id:
                record["status"] = status
                self._save_records(records)
                print(f"[INFO] 搜索状态已更新: search_id={search_id} → {status}")
                return True
        print(f"[WARN] 未找到搜索记录: search_id={search_id}")
        return False


def main():
    """命令行测试"""
    import argparse

    parser = argparse.ArgumentParser(description="候选人状态管理器")
    parser.add_argument(
        "--action",
        choices=[
            "list", "get", "update",
            "get-candidate", "batch-update", "get-selected", "set-status",
        ],
        required=True,
    )
    parser.add_argument("--search-id", help="搜索记录 ID")
    parser.add_argument("--github-id", help="GitHub ID")
    parser.add_argument("--status", help="新状态")
    parser.add_argument("--reason", help="Pass 原因")
    # batch-update 参数
    parser.add_argument("--selected", help="逗号分隔的 selected GitHub ID 列表")
    parser.add_argument("--passed", help="逗号分隔的 passed GitHub ID 列表")
    parser.add_argument("--pending", help="逗号分隔的 pending GitHub ID 列表")

    args = parser.parse_args()

    manager = CandidateManager()

    if args.action == "list":
        searches = manager.list_all_searches()
        print(json.dumps(searches, ensure_ascii=False, indent=2))

    elif args.action == "get":
        if not args.search_id:
            print("[ERROR] 缺少 --search-id 参数")
            return
        result = manager.get_search_result(args.search_id)
        if result:
            print(json.dumps(result, ensure_ascii=False, indent=2))
        else:
            print(f"[ERROR] 未找到搜索记录: {args.search_id}")

    elif args.action == "update":
        if not all([args.search_id, args.github_id, args.status]):
            print("[ERROR] 缺少必需参数: --search-id, --github-id, --status")
            return
        manager.update_candidate_status(
            args.search_id,
            args.github_id,
            args.status,
            args.reason,
        )

    elif args.action == "get-candidate":
        if not args.github_id:
            print("[ERROR] 缺少 --github-id 参数")
            return
        result = manager.get_candidate_appearances(args.github_id)
        print(json.dumps(result, ensure_ascii=False, indent=2))

    elif args.action == "batch-update":
        if not args.search_id:
            print("[ERROR] 缺少 --search-id 参数")
            return
        count = 0
        for status_name, id_list_str in [("selected", args.selected), ("passed", args.passed), ("pending", args.pending)]:
            if not id_list_str:
                continue
            for gid in id_list_str.split(","):
                gid = gid.strip()
                if gid:
                    manager.update_candidate_status(args.search_id, gid, status_name)
                    count += 1
        print(f"[INFO] 批量更新完成，共 {count} 条")

    elif args.action == "get-selected":
        if not args.search_id:
            print("[ERROR] 缺少 --search-id 参数")
            return
        selected = manager.get_selected_candidates(args.search_id)
        print(json.dumps(selected, ensure_ascii=False, indent=2))

    elif args.action == "set-status":
        if not args.search_id or not args.status:
            print("[ERROR] 缺少必需参数: --search-id, --status")
            return
        manager.set_search_status(args.search_id, args.status)


if __name__ == "__main__":
    main()


path: scripts/github_search.py
content: #!/usr/bin/env python3
"""
GitHub Talent Hunter — Repo Search + User Search 双搜索引擎

评分算法 V3（100 分制）：
1. 领域匹配度（35分）  2. 可触达性（25分）  3. 代码影响力（20分）
4. 有效活跃度（12分）  5. 开源生态位（8分）

Usage:
    # 首次搜索
    python github_search.py --queries "language:c++ rocksdb pushed:>2025-01-01" \\
      --jd-keywords "rocksdb,lsm-tree" --jd-language "C++" \\
      --location "shenzhen" --target 20 -o results.json

    # 继续搜索
    python github_search.py --resume SEARCH_ID --target 20 -o results.json
"""

import os
import re
import sys
import json
import math
import time
import uuid
import argparse
import requests
from typing import List, Dict, Set, Optional, Tuple
from datetime import datetime

# 支持从 scripts/ 目录或项目根目录运行
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from location_data import LocationTarget, resolve_location, match_location, build_user_search_locations


class GitHubTalentHunterV3:
    """GitHub 人才猎手 V3 - 专业排序算法"""

    # Rate limiting 配置
    REQUEST_INTERVAL = 0.5  # 每次请求间隔（秒）
    RATE_LIMIT_WARNING_THRESHOLD = 200  # 剩余配额低于此值时警告

    def __init__(self, [REDACTED_SECRET] = None):
        self.token = token or os.getenv("GITHUB_TOKEN")
        if not self.token:
            raise ValueError("缺少 GITHUB_TOKEN 环境变量")

        self.headers = {
            "Authorization": f"Bearer {self.token}",
            "Content-Type": "application/json"
        }
        self.endpoint = "https://api.github.com/graphql"
        self._last_request_time = 0.0

    MAX_RETRIES = 2  # 502/503 重试次数

    def _graphql_request(self, query: str, variables: Optional[Dict] = None, timeout: int = 30) -> Optional[Dict]:
        """
        通用 GraphQL 请求，内置 rate limiting、重试和错误处理。

        Returns:
            响应的 data 字段，如果出错返回 None
        """
        payload = {"query": query}
        if variables:
            payload["variables"] = variables

        for attempt in range(1 + self.MAX_RETRIES):
            # Rate limiting
            elapsed = time.time() - self._last_request_time
            if elapsed < self.REQUEST_INTERVAL:
                time.sleep(self.REQUEST_INTERVAL - elapsed)

            try:
                response = requests.post(
                    self.endpoint,
                    headers=self.headers,
                    json=payload,
                    timeout=timeout
                )
                self._last_request_time = time.time()

                # 502/503 可重试
                if response.status_code in (502, 503) and attempt < self.MAX_RETRIES:
                    wait = 2 ** attempt  # 指数退避: 1s, 2s
                    print(f"[WARN] {response.status_code}，{wait}s 后重试 ({attempt+1}/{self.MAX_RETRIES})", file=sys.stderr)
                    time.sleep(wait)
                    continue

                response.raise_for_status()

                # 检查 rate limit
                remaining = response.headers.get("X-RateLimit-Remaining")
                if remaining and int(remaining) < self.RATE_LIMIT_WARNING_THRESHOLD:
                    reset_time = response.headers.get("X-RateLimit-Reset", "?")
                    print(f"[WARN] GitHub API 配额剩余: {remaining}，重置时间: {reset_time}", file=sys.stderr)

                data = response.json()
                if "errors" in data:
                    print(f"[ERROR] GraphQL 错误: {data['errors']}", file=sys.stderr)
                    return None

                return data.get("data")

            except requests.exceptions.Timeout:
                if attempt < self.MAX_RETRIES:
                    print(f"[WARN] 请求超时，重试 ({attempt+1}/{self.MAX_RETRIES})", file=sys.stderr)
                    continue
                print(f"[ERROR] GraphQL 请求超时 ({timeout}s)", file=sys.stderr)
                return None
            except requests.exceptions.RequestException as e:
                if attempt < self.MAX_RETRIES and "502" in str(e):
                    print(f"[WARN] {e}，重试 ({attempt+1}/{self.MAX_RETRIES})", file=sys.stderr)
                    time.sleep(2 ** attempt)
                    continue
                print(f"[ERROR] 请求失败: {e}", file=sys.stderr)
                return None

        return None
    
    def search_repos_page(self, query: str, cursor: Optional[str] = None, per_page: int = 50) -> tuple:
        """
        搜索单页仓库（支持分页）

        Returns:
            (repos, next_cursor, has_next_page)
        """
        graphql_query = """
        query SearchRepos($query: String!, $perPage: Int!, $cursor: String) {
          search(query: $query, type: REPOSITORY, first: $perPage, after: $cursor) {
            pageInfo {
              hasNextPage
              endCursor
            }
            nodes {
              ... on Repository {
                name
                description
                stargazerCount
                forkCount
                primaryLanguage { name }
                url
                owner {
                  login
                  ... on User {
                    name
                    bio
                    location
                    company
                    email
                    websiteUrl
                    twitterUsername
                    createdAt
                    followers { totalCount }
                    socialAccounts(first: 10) {
                      nodes {
                        provider
                        url
                      }
                    }
                  }
                }
                defaultBranchRef {
                  target {
                    ... on Commit {
                      history(first: 10) {
                        nodes {
                          author {
                            user {
                              login
                              name
                              bio
                              location
                              company
                              email
                              websiteUrl
                              twitterUsername
                              createdAt
                              followers { totalCount }
                              socialAccounts(first: 10) {
                                nodes {
                                  provider
                                  url
                                }
                              }
                            }
                          }
                          committedDate
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
        """

        cursor_info = f" (cursor: {cursor[:20]}...)" if cursor else " (第1页)"
        print(f"[DEBUG] 搜索仓库{cursor_info}: {query}", file=sys.stderr)

        variables = {
            "query": query,
            "perPage": per_page,
            "cursor": cursor
        }

        data = self._graphql_request(graphql_query, variables)
        if not data:
            return [], None, False

        search_result = data.get("search", {})
        repos = search_result.get("nodes", [])
        page_info = search_result.get("pageInfo", {})

        next_cursor = page_info.get("endCursor")
        has_next_page = page_info.get("hasNextPage", False)

        print(f"[INFO] 找到 {len(repos)} 个仓库，hasNextPage={has_next_page}", file=sys.stderr)
        return repos, next_cursor, has_next_page

    def fetch_user_repos_batch(self, logins: List[str], batch_size: int = 10) -> Dict[str, List]:
        """
        批量查询多个用户的仓库列表（Top 10 by stars）。
        用 GraphQL alias 一次查询多个用户，大幅减少 API 调用。

        Args:
            logins: GitHub 用户名列表
            batch_size: 每批查询的用户数（默认 10）

        Returns:
            {login: [repos]} 的字典
        """
        results = {}
        repo_fragment = """
            repositories(first: 10, orderBy: {field: STARGAZERS, direction: DESC}) {
              nodes {
                name
                stargazerCount
                forkCount
                description
                primaryLanguage { name }
              }
            }
        """

        for i in range(0, len(logins), batch_size):
            batch = logins[i:i + batch_size]
            # 构建批量查询，每个用户用 alias 区分
            user_queries = []
            for j, login in enumerate(batch):
                # GraphQL alias 不支持特殊字符，用 user_N 作为 alias
                user_queries.append(f'  user_{j}: user(login: "{login}") {{ {repo_fragment} }}')

            query = "query BatchUserRepos {\n" + "\n".join(user_queries) + "\n}"

            print(f"[INFO] 批量查询用户仓库 [{i+1}-{i+len(batch)}/{len(logins)}]", file=sys.stderr)
            data = self._graphql_request(query, timeout=30)

            if not data:
                # 批量失败时，逐个降级查询
                print(f"[WARN] 批量查询失败，降级为逐个查询", file=sys.stderr)
                for login in batch:
                    results[login] = self._fetch_single_user_repos(login)
                continue

            for j, login in enumerate(batch):
                user_data = data.get(f"user_{j}")
                if user_data:
                    repos = user_data.get("repositories", {}).get("nodes", [])
                    results[login] = repos
                else:
                    results[login] = []

        return results

    def _fetch_single_user_repos(self, login: str) -> List[Dict]:
        """单个用户仓库查询（批量查询的降级方案）"""
        graphql_query = """
        query GetUserRepos($login: String!) {
          user(login: $login) {
            repositories(first: 10, orderBy: {field: STARGAZERS, direction: DESC}) {
              nodes {
                name
                stargazerCount
                forkCount
                description
                primaryLanguage { name }
              }
            }
          }
        }
        """
        data = self._graphql_request(graphql_query, {"login": login}, timeout=10)
        if not data:
            return []

        user_data = data.get("user")
        if not user_data:
            return []

        return user_data.get("repositories", {}).get("nodes", [])

    def search_users_page(self, query: str, cursor: Optional[str] = None, per_page: int = 50) -> tuple:
        """
        GitHub User Search (type: USER)。
        直接搜人，支持 location:, followers:, language: 参数。
        返回的用户已包含 repositories，不需要二次查询。

        Returns:
            (users, next_cursor, has_next_page)
        """
        graphql_query = """
        query SearchUsers($query: String!, $perPage: Int!, $cursor: String) {
          search(query: $query, type: USER, first: $perPage, after: $cursor) {
            pageInfo {
              hasNextPage
              endCursor
            }
            nodes {
              ... on User {
                login
                name
                bio
                location
                company
                email
                websiteUrl
                twitterUsername
                createdAt
                isHireable
                status { message emoji }
                followers { totalCount }
                socialAccounts(first: 10) {
                  nodes {
                    provider
                    url
                    displayName
                  }
                }
                repositories(first: 10, orderBy: {field: STARGAZERS, direction: DESC}) {
                  nodes {
                    name
                    stargazerCount
                    forkCount
                    description
                    primaryLanguage { name }
                    pushedAt
                  }
                }
              }
            }
          }
        }
        """

        cursor_info = f" (cursor: {cursor[:20]}...)" if cursor else " (第1页)"
        print(f"[DEBUG] User Search{cursor_info}: {query}", file=sys.stderr)

        data = self._graphql_request(graphql_query, {
            "query": query,
            "perPage": per_page,
            "cursor": cursor
        })
        if not data:
            return [], None, False

        search_result = data.get("search", {})
        users = search_result.get("nodes", [])
        page_info = search_result.get("pageInfo", {})

        next_cursor = page_info.get("endCursor")
        has_next_page = page_info.get("hasNextPage", False)

        # 标记来源，User Search 已包含 repositories，跳过后续 batch fetch
        for user in users:
            if isinstance(user, dict) and "login" in user:
                user["_source"] = "user_search"

        print(f"[INFO] User Search 找到 {len(users)} 个用户，hasNextPage={has_next_page}", file=sys.stderr)
        return users, next_cursor, has_next_page

    def enrich_user_details_batch(self, users: List[Dict], batch_size: int = 5) -> None:
        """
        批量补充重量级字段（去重+过滤后调用）。
        直接修改 user dict，添加 contributionsCollection、pinnedItems、repositoryTopics。

        这些字段太重无法放在搜索查询中（会导致 RESOURCE_LIMITS_EXCEEDED），
        所以在搜索完成后单独批量查询。
        """
        logins = [u.get('login') for u in users if u.get('login')]
        if not logins:
            return

        enrich_fragment = """
            contributionsCollection {
              totalCommitContributions
              totalPullRequestContributions
              totalIssueContributions
              contributionCalendar { totalContributions }
            }
            pinnedItems(first: 6) {
              nodes {
                ... on Repository {
                  name description stargazerCount primaryLanguage { name }
                }
              }
            }
            repositories(first: 10, orderBy: {field: STARGAZERS, direction: DESC}) {
              nodes {
                name
                repositoryTopics(first: 10) { nodes { topic { name } } }
              }
            }
        """

        # login → user dict 的映射
        user_map = {u['login']: u for u in users if u.get('login')}
        enriched = 0

        for i in range(0, len(logins), batch_size):
            batch = logins[i:i + batch_size]
            user_queries = []
            for j, login in enumerate(batch):
                user_queries.append(f'  user_{j}: user(login: "{login}") {{ {enrich_fragment} }}')

            query = "{\n" + "\n".join(user_queries) + "\n}"

            print(f"[INFO] 补充候选人详情 [{i+1}-{i+len(batch)}/{len(logins)}]", file=sys.stderr)
            data = self._graphql_request(query, timeout=30)

            if not data:
                continue

            for j, login in enumerate(batch):
                user_data = data.get(f"user_{j}")
                if not user_data or login not in user_map:
                    continue

                u = user_map[login]

                # contributionsCollection
                cc = user_data.get("contributionsCollection", {})
                u["contributionsCollection"] = cc

                # pinnedItems
                pinned = user_data.get("pinnedItems", {}).get("nodes", [])
                u["pinnedItems"] = pinned

                # repositoryTopics → 合并到已有 repos
                enriched_repos = user_data.get("repositories", {}).get("nodes", [])
                existing_repos = u.get("repositories", {}).get("nodes", [])
                for er in enriched_repos:
                    for existing in existing_repos:
                        if existing.get("name") == er.get("name"):
                            topics = [t["topic"]["name"] for t in er.get("repositoryTopics", {}).get("nodes", [])]
                            existing["topics"] = topics
                            break

                enriched += 1

        print(f"[INFO] 已补充 {enriched}/{len(logins)} 个候选人的详细信息", file=sys.stderr)

    def extract_users_from_repos(self, repos: List[Dict]) -> List[Dict]:
        """从仓库列表中提取所有候选人（Owner + Contributors）"""
        all_users = []

        for repo in repos:
            # 提取 repo 最新 commit 时间（用于 Owner 活跃度）
            latest_commit_date = None
            branch_ref = repo.get("defaultBranchRef")
            if branch_ref:
                target = branch_ref.get("target")
                if target:
                    commits = target.get("history", {}).get("nodes") or []
                    if commits:
                        latest_commit_date = commits[0].get("committedDate")

            # 1. 提取仓库所有者（Owner）
            owner = repo.get("owner")
            if owner and isinstance(owner, dict) and "login" in owner:
                owner['is_owner'] = True
                owner['repo_stars'] = repo.get("stargazerCount", 0)
                owner['repo_forks'] = repo.get("forkCount", 0)
                # Owner 也获取活跃度数据（取 repo 最新 commit 时间）
                if latest_commit_date:
                    owner['last_commit_date'] = latest_commit_date
                all_users.append(owner)

            # 2. 提取最近的贡献者（Contributors from Commits）
            if branch_ref and branch_ref.get("target"):
                commits = branch_ref["target"].get("history", {}).get("nodes") or []
                for commit in commits:
                    author_user = commit.get("author", {}).get("user")
                    if author_user and isinstance(author_user, dict) and "login" in author_user:
                        author_user['is_owner'] = False
                        author_user['last_commit_date'] = commit.get("committedDate")
                        all_users.append(author_user)

        return all_users
    
    def deduplicate_users(self, users: List[Dict]) -> List[Dict]:
        """去重用户（基于 login），合并多次出现的数据"""
        unique_users = {}

        for user in users:
            login = user.get("login")
            if not login:
                continue

            if login in unique_users:
                existing = unique_users[login]
                # 合并 is_owner 标记（任一出现则为 True）
                if user.get('is_owner'):
                    existing['is_owner'] = True
                # 合并 repo_stars / repo_forks（取最大值）
                if user.get('repo_stars', 0) > existing.get('repo_stars', 0):
                    existing['repo_stars'] = user['repo_stars']
                if user.get('repo_forks', 0) > existing.get('repo_forks', 0):
                    existing['repo_forks'] = user['repo_forks']
                # 合并 last_commit_date（取最新的）
                new_date = user.get('last_commit_date')
                old_date = existing.get('last_commit_date')
                if new_date and (not old_date or new_date > old_date):
                    existing['last_commit_date'] = new_date
                # 补充缺失的 profile 字段
                for key in ['email', 'bio', 'location', 'company', 'name', 'websiteUrl', 'twitterUsername']:
                    if user.get(key) and not existing.get(key):
                        existing[key] = user[key]
                # 合并 socialAccounts
                if user.get('socialAccounts') and not existing.get('socialAccounts'):
                    existing['socialAccounts'] = user['socialAccounts']
            else:
                unique_users[login] = user

        print(f"[INFO] 去重后剩余 {len(unique_users)} 个用户", file=sys.stderr)
        return list(unique_users.values())
    
    # filter_by_email 已移除 — 邮箱现在是评分维度，不再做硬性过滤
    
    def filter_by_location_legacy(self, users: List[Dict], location_filter: str) -> List[Dict]:
        """
        地理位置过滤（AND 逻辑 - 必须满足）
        
        雯姐的新规则:
        1. Remote 自动通过（不受地理位置限制）
        2. 中文判断：GitHub 有中文 → 判定为中国
        3. 扩展城市关键词（14个城市 + 港澳台）
        
        Args:
            users: 候选人列表
            location_filter: 地理位置关键词（如 china, beijing, usa）
        
        Returns:
            过滤后的候选人列表
        """
        location_keywords = location_filter.lower().split(',')
        
        # 扩展中国相关关键词（省会城市 + 主要城市 + 港澳台）
        if 'china' in location_keywords:
            location_keywords.extend([
                '中国', 'chinese', 'cn', 'prc',
                
                # 直辖市
                'beijing', '北京', 'bj',
                'shanghai', '上海', 'sh',
                'tianjin', '天津', 'tj',
                'chongqing', '重庆', 'cq',
                
                # 省会 + 新一线城市
                'hangzhou', '杭州', 'hz',
                'chengdu', '成都', 'cd',
                'nanjing', '南京', 'nj',
                'wuhan', '武汉', 'wh',
                'xian', "xi'an", '西安',
                'suzhou', '苏州', 'sz',  # 注意：sz 也可能是深圳
                'shenzhen', '深圳',
                'guangzhou', '广州', 'gz',
                'xiamen', '厦门', 'xm',
                'qingdao', '青岛', 'qd',
                
                # 其他省会城市
                'zhengzhou', '郑州',
                'changsha', '长沙',
                'fuzhou', '福州',
                'hefei', '合肥',
                'nanchang', '南昌',
                'jinan', '济南',
                'taiyuan', '太原',
                'shijiazhuang', '石家庄',
                'harbin', '哈尔滨',
                'changchun', '长春',
                'shenyang', '沈阳',
                'hohhot', '呼和浩特',
                'urumqi', '乌鲁木齐',
                'lanzhou', '兰州',
                'yinchuan', '银川',
                'xining', '西宁',
                'lhasa', '拉萨',
                'kunming', '昆明',
                'guiyang', '贵阳',
                'nanning', '南宁',
                'haikou', '海口',
                
                # 主要二线城市
                'dongguan', '东莞',
                'foshan', '佛山',
                'ningbo', '宁波', 'nb',
                'wuxi', '无锡', 'wx',
                'changzhou', '常州',
                'dalian', '大连', 'dl',
                'zhuhai', '珠海',
                'wenzhou', '温州', 'wz',
                'shaoxing', '绍兴',
                'jiaxing', '嘉兴',
                'huzhou', '湖州',
                'jinhua', '金华',
                'taizhou', '台州',
                'quzhou', '衢州',
                'zhoushan', '舟山',
                'lishui', '丽水',
                
                # 港澳台
                'hong kong', 'hongkong', 'hk', '香港',
                'taiwan', 'taipei', 'tw', '台湾', '台北',
                'kaohsiung', '高雄',
                'taichung', '台中',
                'tainan', '台南',
                'macau', 'macao', '澳门',
                
                # 省份名（有些人只填省份）
                'guangdong', '广东',
                'jiangsu', '江苏',
                'zhejiang', '浙江',
                'shandong', '山东',
                'henan', '河南',
                'sichuan', '四川',
                'hubei', '湖北',
                'hunan', '湖南',
                'fujian', '福建',
                'anhui', '安徽',
                'hebei', '河北',
                'shaanxi', '陕西',
                'liaoning', '辽宁',
                'jilin', '吉林',
                'heilongjiang', '黑龙江',
            ])
        
        # 扩展新加坡相关关键词
        if 'singapore' in location_keywords:
            location_keywords.extend([
                'sg',  # 常用缩写
                'sgp',
                'singaporean',
                '新加坡',
            ])
        
        # 分离短缩写和长关键词，短缩写用词边界匹配避免误判
        short_keywords = [kw for kw in location_keywords if len(kw) <= 3 and kw.isascii()]
        long_keywords = [kw for kw in location_keywords if len(kw) > 3 or not kw.isascii()]

        # 预编译短缩写的正则（词边界匹配）
        short_patterns = [re.compile(r'\b' + re.escape(kw) + r'\b', re.IGNORECASE) for kw in short_keywords] if short_keywords else []

        def has_chinese(text):
            return bool(re.search(r'[\u4e00-\u9fff]', text))

        filtered = []
        for user in users:
            location = (user.get('location') or '').lower()
            name = user.get('name') or ''
            bio = user.get('bio') or ''

            # 规则1: Remote 自动通过
            if 'remote' in location:
                filtered.append(user)
                continue

            # 规则2: location 包含目标关键词
            # 长关键词（>=4字符或中文）用 in 匹配
            if any(keyword in location for keyword in long_keywords):
                filtered.append(user)
                continue
            # 短缩写用词边界匹配，避免 "Graz" 匹配 "gz" 等误判
            if any(pattern.search(location) for pattern in short_patterns):
                filtered.append(user)
                continue

            # 规则3: 中文判断（name 或 bio 包含中文）
            if 'china' in location_keywords and (has_chinese(name) or has_chinese(bio)):
                filtered.append(user)
                continue

        return filtered
    
    def filter_by_location(self, users: List[Dict], target: LocationTarget, strict: bool = False) -> List[Dict]:
        """
        地理位置过滤。

        strict=False（默认）: 层级回落 city → province → country → 中文启发式
        strict=True: 仅匹配指定城市/省份，不回落到国家。Remote 仍通过。
        """
        mode = "严格" if strict else "宽松"
        filtered = []
        for user in users:
            user_loc = user.get('location') or ''
            user_name = user.get('name') or ''
            user_bio = user.get('bio') or ''
            matched, reason = match_location(user_loc, user_name, user_bio, target, strict=strict)
            if matched:
                user['_location_match_reason'] = reason
                filtered.append(user)
        print(f"[INFO] 地理位置过滤（{mode}）: {len(users)} → {len(filtered)} 人", file=sys.stderr)
        return filtered

    def _get_follower_count(self, user: Dict) -> int:
        """安全提取 follower 数量"""
        followers = user.get('followers', {})
        if isinstance(followers, dict):
            return followers.get('totalCount', 0)
        return followers or 0

    def calculate_score(self, user: Dict, jd_keywords: List[str], jd_language: str) -> Dict:
        """
        候选人综合评分算法 V3（100 分制）

        设计原则（猎头视角）：
        - 领域匹配是前提：找错人一切白费
        - 可触达性决定转化率：联系不上的大牛不如联系得上的牛人
        - 影响力体现技术深度：但不应被 star 通胀稀释
        - 活跃度预测响应概率：活跃的人更可能回复
        - 生态位是锦上添花：区分 leader vs follower

        维度与权重：
        1. 领域匹配度（Relevance）  — 35 分  最重要，错了后面都白搭
        2. 可触达性（Reachability）  — 25 分  能联系上才有意义
        3. 代码影响力（Impact）      — 20 分  技术深度的代理指标
        4. 有效活跃度（Activity）    — 12 分  活跃度越高越可能回复
        5. 开源生态位（Ecosystem）   —  8 分  区分度最低的维度
        """
        scores = {
            "relevance": 0.0,      # 领域匹配度（35分）
            "reachability": 0.0,   # 可触达性（25分）
            "impact": 0.0,         # 代码影响力（20分）
            "activity": 0.0,       # 有效活跃度（12分）
            "contribution": 0.0,   # 开源生态位（8分）
            "total": 0.0
        }

        repos = user.get('repositories', {}).get('nodes', [])

        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        # 1. 领域匹配度（Relevance — 35 分）
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

        # 1A. 主语言匹配（15 分）：Top 5 仓库中有目标语言即可
        for repo in repos[:5]:
            lang = repo.get('primaryLanguage') or {}
            if lang and lang.get('name', '').lower() == jd_language.lower():
                scores['relevance'] += 15
                break

        # 1B. 生态关键词匹配（20 分）：按命中数分级
        #   匹配源：repo name + description + repositoryTopics（标签匹配更精准）
        #   命中 3+ 个关键词 → 20 分（深度匹配）
        #   命中 2 个 → 15 分
        #   命中 1 个 → 10 分
        matched_keywords = set()
        for repo in repos[:5]:
            desc = (repo.get('description') or '').lower()
            repo_name = repo.get('name', '').lower()
            # 提取 topics 标签
            topics = [t.lower() for t in repo.get('topics', [])]
            text = f"{desc} {repo_name} {' '.join(topics)}"
            for keyword in jd_keywords:
                if keyword.lower() in text:
                    matched_keywords.add(keyword.lower())

        kw_count = len(matched_keywords)
        if kw_count >= 3:
            scores['relevance'] += 20
        elif kw_count == 2:
            scores['relevance'] += 15
        elif kw_count == 1:
            scores['relevance'] += 10

        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        # 2. 可触达性（Reachability — 25 分）
        #    分级计分，多渠道可叠加，上限 25 分
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

        reachability_raw = 0.0

        # 2A. 公开邮箱（+10）— 最直接的触达方式
        if user.get('email'):
            reachability_raw += 10

        # 2B. 个人网站/博客（+6）— 可以找到更多联系方式，也是破冰素材
        if user.get('websiteUrl'):
            reachability_raw += 6

        # 2C. Twitter/X（+5）— DM 是有效触达渠道
        if user.get('twitterUsername'):
            reachability_raw += 5

        # 2D. 其他社交账号（每个 +3，上限 +6）— LinkedIn、知乎等
        social_nodes = user.get("socialAccounts", {}).get("nodes", [])
        other_social_count = 0
        for account in social_nodes:
            provider = (account.get("provider") or "").lower()
            # 排除已单独计分的 twitter
            if provider and provider not in ("twitter", "x"):
                other_social_count += 1
        reachability_raw += min(other_social_count * 3, 6)

        # 2E. Profile 完整度（+3）— bio 不为空说明愿意被人了解
        if user.get('bio'):
            reachability_raw += 3

        # 2F. isHireable（+5）— 候选人主动标记"可被雇佣"，回复概率极高
        if user.get('isHireable'):
            reachability_raw += 5

        scores['reachability'] = min(reachability_raw, 25)

        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        # 3. 代码影响力（Impact — 20 分）
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

        total_stars = sum(r.get('stargazerCount', 0) for r in repos)
        total_forks = sum(r.get('forkCount', 0) for r in repos)
        follower_count = self._get_follower_count(user)

        # 3A. Star 影响力（上限 12 分）— 用 log 曲线避免被 star 通胀稀释
        #   10 stars → 3 分, 100 → 6 分, 1000 → 9 分, 5000+ → 12 分
        import math
        if total_stars > 0:
            star_score = min(math.log10(total_stars) * 3, 12)
            scores['impact'] += round(star_score, 1)

        # 3B. Fork 复用度（上限 4 分）
        if total_forks > 0:
            fork_score = min(math.log10(total_forks) * 2, 4)
            scores['impact'] += round(fork_score, 1)

        # 3C. 社区关注度（上限 4 分）
        if follower_count > 0:
            follower_score = min(math.log10(follower_count) * 2, 4)
            scores['impact'] += round(follower_score, 1)

        scores['impact'] = min(scores['impact'], 20)

        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        # 4. 有效活跃度（Activity — 12 分）
        #    综合三个信号：last_commit_date + pushedAt + totalContributions
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

        # 信号 A: 最近活跃时间（last_commit_date 或 repo pushedAt）
        recency_score = 0
        best_date = user.get('last_commit_date') or ''
        # 也检查 repos 的 pushedAt（User Search 用户没有 last_commit_date）
        for repo in repos[:5]:
            pushed = repo.get('pushedAt', '')
            if pushed and pushed > best_date:
                best_date = pushed

        if best_date:
            try:
                dt = datetime.fromisoformat(best_date.replace('Z', '+00:00'))
                days_ago = (datetime.now(dt.tzinfo) - dt).days
                if days_ago < 30:
                    recency_score = 8
                elif days_ago < 90:
                    recency_score = 5
                elif days_ago < 180:
                    recency_score = 3
            except Exception:
                pass

        # 信号 B: 贡献量（来自 contributionsCollection）
        volume_score = 0
        cc = user.get('contributionsCollection', {})
        total_contributions = cc.get('contributionCalendar', {}).get('totalContributions', 0)
        if total_contributions >= 1000:
            volume_score = 4    # 年贡献 1000+ = 非常活跃
        elif total_contributions >= 300:
            volume_score = 3
        elif total_contributions >= 100:
            volume_score = 2
        elif total_contributions > 0:
            volume_score = 1

        scores['activity'] = min(recency_score + volume_score, 12)

        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        # 5. 开源生态位（Ecosystem — 8 分）
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

        if user.get('is_owner') and repos:
            max_stars = max((r.get('stargazerCount', 0) for r in repos), default=0)
            if max_stars > 500:
                scores['contribution'] = 8    # 顶级 Owner
            elif max_stars > 100:
                scores['contribution'] = 5    # 有影响力的 Owner
            elif max_stars > 20:
                scores['contribution'] = 3    # 活跃 Owner

        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        # 总分（100 分制）
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

        scores['total'] = round(
            scores['relevance'] + scores['reachability'] + scores['impact'] +
            scores['activity'] + scores['contribution'], 1
        )

        return scores
    
    def build_candidate_profile(self, user: Dict, scores: Dict) -> Dict:
        """构建候选人完整画像"""
        username = user.get("login", "")

        # 提取社交账号
        social_accounts = {}
        social_nodes = user.get("socialAccounts", {}).get("nodes", [])
        for account in social_nodes:
            provider = account.get("provider", "").lower()
            url = account.get("url", "")
            if provider and url:
                social_accounts[provider] = url

        follower_count = self._get_follower_count(user)

        # 计算开发年限
        created_at = user.get("createdAt") or ""
        dev_years = 0
        if created_at:
            try:
                created = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
                dev_years = max(0, (datetime.now(created.tzinfo) - created).days // 365)
            except Exception:
                pass

        # 提取代表作：优先 pinnedItems（候选人自选），兜底 Top Star 仓库
        pinned_items = user.get('pinnedItems', [])
        repos = user.get('repositories', {}).get('nodes', [])

        # pinnedItems 作为代表作（如果有）
        pinned_repos = []
        if pinned_items:
            for item in pinned_items[:3]:
                if isinstance(item, dict) and item.get('name'):
                    primary_lang = item.get('primaryLanguage') or {}
                    pinned_repos.append({
                        "name": item.get('name'),
                        "stars": item.get('stargazerCount', 0),
                        "language": primary_lang.get('name', '') if isinstance(primary_lang, dict) else '',
                        "description": item.get('description', ''),
                        "pinned": True
                    })

        # Top Star 仓库（兜底或补充）
        top_repos = []
        for repo in repos[:3]:
            primary_lang = repo.get('primaryLanguage') or {}
            top_repos.append({
                "name": repo.get('name'),
                "stars": repo.get('stargazerCount', 0),
                "forks": repo.get('forkCount', 0),
                "language": primary_lang.get('name', '') if isinstance(primary_lang, dict) else '',
                "description": repo.get('description', ''),
                "topics": repo.get('topics', []),
                "pushed_at": repo.get('pushedAt', ''),
            })

        # 代表作 = pinnedItems 优先，没有则用 top_repos
        showcase_repos = pinned_repos if pinned_repos else top_repos
        
        # 活跃状态
        activity_label = "⚪ 不活跃"
        if scores.get("activity", 0) >= 10:
            activity_label = "🟢 活跃"
        elif scores.get("activity", 0) >= 6:
            activity_label = "🟡 近期活跃"
        elif scores.get("activity", 0) > 0:
            activity_label = "🔵 半年内活跃"

        # 匹配度标签
        total = scores.get("total", 0)
        if total >= 80:
            match_label = "🔥 极度匹配"
        elif total >= 65:
            match_label = "⭐ 高度匹配"
        elif total >= 50:
            match_label = "👍 匹配"
        else:
            match_label = "📋 参考"

        # 联系渠道图标
        contact_icons = []
        if user.get("email"):
            contact_icons.append("📧")
        if user.get("websiteUrl"):
            contact_icons.append("🌐")
        if user.get("twitterUsername"):
            contact_icons.append("🐦")
        if social_nodes:
            for acc in social_nodes:
                p = (acc.get("provider") or "").lower()
                if p and p not in ("twitter", "x"):
                    contact_icons.append("🔗")
                    break

        # 贡献统计
        cc = user.get("contributionsCollection", {})
        contributions = {
            "total": cc.get("contributionCalendar", {}).get("totalContributions", 0),
            "commits": cc.get("totalCommitContributions", 0),
            "pull_requests": cc.get("totalPullRequestContributions", 0),
            "issues": cc.get("totalIssueContributions", 0),
        }

        # isHireable
        is_hireable = user.get("isHireable", False)

        # 用户状态
        user_status = user.get("status") or {}
        status_message = user_status.get("message", "")

        # isHireable 标记
        if is_hireable:
            activity_label = activity_label + " | 🟢 求职中"

        return {
            "github_id": username,
            "name": user.get("name") or username,
            "bio": user.get("bio") or "",
            "location": user.get("location") or "未知",
            "company": user.get("company") or "",
            "blog": user.get("websiteUrl") or "",
            "email": user.get("email") or "",
            "twitter": user.get("twitterUsername") or "",
            "followers": follower_count,
            "created_at": created_at,
            "dev_years": dev_years,
            "is_hireable": is_hireable,
            "user_status": status_message,
            "profile_url": f"https://github.com/{username}",
            "social_accounts": social_accounts,
            "showcase_repos": showcase_repos,
            "top_repos": top_repos,
            "contributions": contributions,
            "last_commit_date": user.get("last_commit_date") or "",
            "is_owner": user.get("is_owner", False),
            "scores": scores,
            "total_score": scores['total'],
            "match_label": match_label,
            "activity_label": activity_label,
            "contact_icons": " ".join(contact_icons),
            "status": "pending"
        }
    
    def deep_search(
        self,
        queries: List[str],
        jd_keywords: List[str],
        jd_language: str,
        location_filter=None,
        pages: int = 3,
        start_page: int = 1,
        seen_github_ids: Set[str] = None
    ) -> Dict:
        """深度搜索 + 雯姐的专业排序（支持多轮）"""
        if seen_github_ids is None:
            seen_github_ids = set()
        
        print(f"\n[INFO] ===== 开始深度搜索 =====", file=sys.stderr)
        print(f"[INFO] 页数范围: 第 {start_page} 页 ~ 第 {start_page + pages - 1} 页", file=sys.stderr)
        print(f"[INFO] 每页仓库数: 30 个", file=sys.stderr)
        print(f"[INFO] JD 关键词: {jd_keywords}", file=sys.stderr)
        print(f"[INFO] JD 语言: {jd_language}", file=sys.stderr)
        if location_filter:
            print(f"[INFO] 地理位置过滤: {location_filter}", file=sys.stderr)
        if seen_github_ids:
            print(f"[INFO] 已出现过的候选人: {len(seen_github_ids)} 个（将自动过滤）", file=sys.stderr)
        
        all_raw_users = []
        
        for query in queries:
            print(f"\n[INFO] 查询: {query}", file=sys.stderr)
            
            # 跳过前面的页（如果 start_page > 1）
            cursor = None
            current_page = 1
            
            # 先跳到 start_page
            while current_page < start_page:
                _, cursor, has_next_page = self.search_repos_page(query, cursor=cursor, per_page=30)
                if not has_next_page:
                    print(f"[WARN] 已到最后一页（第 {current_page} 页），无法跳到第 {start_page} 页", file=sys.stderr)
                    break
                current_page += 1
                print(f"[DEBUG] 跳过第 {current_page-1} 页（cursor 前进）", file=sys.stderr)
            
            # 开始实际搜索
            for i in range(pages):
                page_number = start_page + i
                repos, next_cursor, has_next_page = self.search_repos_page(query, cursor=cursor, per_page=30)
                
                if not repos:
                    print(f"[WARN] 第 {page_number} 页没有找到仓库，停止搜索", file=sys.stderr)
                    break
                
                users = self.extract_users_from_repos(repos)
                all_raw_users.extend(users)
                
                print(f"[INFO] 第 {page_number} 页提取到 {len(users)} 个用户", file=sys.stderr)
                
                # 更新 cursor 用于下一页
                cursor = next_cursor
                
                # 如果没有下一页了，停止
                if not has_next_page:
                    print(f"[INFO] 已到最后一页（第 {page_number} 页），停止搜索", file=sys.stderr)
                    break
        
        print(f"\n[INFO] ===== 数据处理 =====", file=sys.stderr)
        raw_count = len(all_raw_users)
        print(f"[INFO] 原始用户数: {raw_count}", file=sys.stderr)
        
        # Step 1: 去重
        unique_users = self.deduplicate_users(all_raw_users)
        unique_count = len(unique_users)
        
        # Step 2: 过滤已出现过的候选人（多轮去重）
        if seen_github_ids:
            before_count = len(unique_users)
            unique_users = [u for u in unique_users if u.get('login') not in seen_github_ids]
            print(f"[INFO] 多轮去重: {before_count} → {len(unique_users)} 人（过滤掉 {before_count - len(unique_users)} 个已出现的）", file=sys.stderr)
            unique_count = len(unique_users)
        
        # Step 3: 地理位置过滤（如果指定了 location_filter）
        # 雯姐规则：Remote 自动通过 + 中文判断
        filtered_users = unique_users
        location_filtered_count = unique_count  # 默认等于去重后的数量
        if location_filter:
            before_count = len(filtered_users)
            # 支持 str 或 LocationTarget
            if isinstance(location_filter, str):
                loc_target = resolve_location(location_filter)
            else:
                loc_target = location_filter
            if loc_target:
                filtered_users = self.filter_by_location(filtered_users, loc_target)
            location_filtered_count = len(filtered_users)
            print(f"[INFO] 地理位置过滤: {before_count} → {location_filtered_count} 人", file=sys.stderr)
        
        # Step 4: 批量查询每个候选人的仓库列表
        print(f"\n[INFO] ===== 批量查询候选人仓库（{len(filtered_users)} 人）=====", file=sys.stderr)
        logins = [u.get("login") for u in filtered_users if u.get("login")]
        repos_map = self.fetch_user_repos_batch(logins)
        for user in filtered_users:
            login = user.get("login")
            user['repositories'] = {'nodes': repos_map.get(login, [])}
        
        # Step 5: 评分
        scored_candidates = []
        for user in filtered_users:
            scores = self.calculate_score(user, jd_keywords, jd_language)
            candidate = self.build_candidate_profile(user, scores)
            scored_candidates.append(candidate)
        
        # Step 6: 排序（按总分从高到低）
        sorted_candidates = sorted(scored_candidates, key=lambda x: x['total_score'], reverse=True)
        
        print(f"\n[INFO] ===== 搜索完成 =====", file=sys.stderr)
        print(f"[SUCCESS] 共找到 {len(sorted_candidates)} 个符合条件的候选人", file=sys.stderr)
        
        return {
            "candidates": sorted_candidates,
            "total": len(sorted_candidates),
            "pages_searched": pages,
            "queries": queries,
            "jd_keywords": jd_keywords,
            "jd_language": jd_language,
            # 数据漏斗（雯姐要求：展示过滤过程）
            "raw_count": raw_count,
            "unique_count": unique_count,
            "location_filtered": location_filtered_count
        }


def main():
    parser = argparse.ArgumentParser(description="GitHub Talent Hunter — Repo + User 双搜索引擎")
    parser.add_argument("--queries", nargs="+", help="Repo Search 查询列表（来自 JD Parser）")
    parser.add_argument("--jd-keywords", help="JD 核心关键词（逗号分隔）")
    parser.add_argument("--jd-language", help="JD 主语言（如 C++, Python）")
    parser.add_argument("--jd-title", help="职位名称（用于记录）")
    parser.add_argument("--location", help="地理位置（如 shenzhen, china, singapore）")
    parser.add_argument("--location-strict", action="store_true", help="严格位置匹配（不回落到国家级，仅匹配城市/省份）")
    parser.add_argument("--target", type=int, default=20, help="目标候选人数（默认20）")
    parser.add_argument("--resume", help="继续搜索（传入 search_id）")
    parser.add_argument("--output", "-o", help="输出文件路径")
    parser.add_argument("--token", help="GitHub Personal Access Token")

    args = parser.parse_args()

    from candidate_manager import CandidateManager
    manager = CandidateManager()

    # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    # 参数初始化
    # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

    if args.resume:
        # 继续搜索模式
        print(f"[INFO] 继续搜索 (search_id={args.resume})", file=sys.stderr)
        prev = manager.get_search_result(args.resume)
        if not prev:
            print(f"[ERROR] 未找到搜索记录: {args.resume}", file=sys.stderr)
            sys.exit(1)

        search_id = args.resume
        search_params = prev.get("search_params", {})
        repo_queries = search_params.get("repo_queries", prev.get("queries", []))
        jd_keywords = search_params.get("jd_keywords", prev.get("jd_keywords", []))
        jd_language = search_params.get("jd_language", prev.get("jd_language", ""))
        jd_title = prev.get("jd_title", "")
        location_raw = search_params.get("location_raw", prev.get("location_filter"))
        seen_github_ids = set(prev.get("seen_github_ids", []))
        repo_page_offsets = prev.get("repo_page_offsets", {})
        user_page_offsets = prev.get("user_page_offsets", {})
        rounds_searched = prev.get("rounds_searched", 0)

        # 加载已有候选人
        all_candidates = {c["github_id"]: c for c in prev.get("candidates", [])}
        if not seen_github_ids:
            seen_github_ids = set(all_candidates.keys())

        print(f"[INFO] 已有 {len(all_candidates)} 个候选人，{len(seen_github_ids)} 个 seen_ids", file=sys.stderr)
    else:
        # 新搜索
        if not all([args.queries, args.jd_keywords, args.jd_language]):
            print("[ERROR] 新搜索需要 --queries, --jd-keywords, --jd-language", file=sys.stderr)
            sys.exit(1)

        search_id = str(uuid.uuid4())
        repo_queries = args.queries
        jd_keywords = [kw.strip() for kw in args.jd_keywords.split(",")]
        jd_language = args.jd_language
        jd_title = args.jd_title or f"{jd_language} 工程师"
        location_raw = args.location
        seen_github_ids = set()
        repo_page_offsets = {}
        user_page_offsets = {}
        rounds_searched = 0
        all_candidates = {}

        print(f"[INFO] 新搜索 (search_id={search_id})", file=sys.stderr)

    # 解析 location
    location_target = resolve_location(location_raw) if location_raw else None
    location_strict = getattr(args, 'location_strict', False)

    # 自动构建 User Search queries
    user_queries = []
    if location_target:
        locs = build_user_search_locations(location_target)
        lang = jd_language.lower()
        user_queries = [f"language:{lang} location:{loc} followers:>3" for loc in locs[:4]]
        print(f"[INFO] User Search queries: {user_queries}", file=sys.stderr)

    # 初始化搜索引擎
    try:
        hunter = GitHubTalentHunterV3([REDACTED_SECRET])
    except ValueError as e:
        print(f"[ERROR] {e}", file=sys.stderr)
        sys.exit(1)

    # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    # 搜索循环
    # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

    PAGES_PER_ROUND = 3       # 每轮每条 query 搜 3 页 × 100 条 = 300 条
    USER_PAGES_PER_ROUND = 2  # User Search 每轮 2 页
    target = args.target
    zero_rounds = 0
    status = "in_progress"

    # 漏斗统计
    funnel = {
        "raw_from_repos": 0,
        "raw_from_users": 0,
        "after_dedup": 0,
        "after_location_filter": 0,
        "final_scored": 0,
    }

    print(f"\n{'='*60}", file=sys.stderr)
    print(f"🔍 搜索目标: {target} 人 | 语言: {jd_language} | 位置: {location_raw or '不限'}", file=sys.stderr)
    print(f"   Repo queries: {len(repo_queries)} 条 | User queries: {len(user_queries)} 条", file=sys.stderr)
    print(f"{'='*60}\n", file=sys.stderr)

    while True:
        rounds_searched += 1
        new_raw_users = []
        any_has_more = False

        print(f"\n{'─'*50}", file=sys.stderr)
        print(f"📍 Round {rounds_searched}", file=sys.stderr)
        print(f"{'─'*50}", file=sys.stderr)

        # ── Repo Search ──
        for query in repo_queries:
            start_offset = repo_page_offsets.get(query, 0)
            if start_offset >= 20:  # 20 页 × 50 = 1000（GitHub 硬限制）
                continue

            cursor = None
            # 快进到上次位置
            for skip_i in range(start_offset):
                _, cursor, has_next = hunter.search_repos_page(query, cursor)
                if not has_next:
                    break

            pages_fetched = 0
            for page_i in range(PAGES_PER_ROUND):
                page_num = start_offset + page_i + 1
                if page_num > 20:  # 硬限制 20页×50=1000
                    break
                repos, cursor, has_next = hunter.search_repos_page(query, cursor)
                if not repos:
                    break

                users = hunter.extract_users_from_repos(repos)
                new_raw_users.extend(users)
                funnel["raw_from_repos"] += len(users)
                pages_fetched += 1

                if has_next:
                    any_has_more = True
                if not has_next:
                    break

            repo_page_offsets[query] = start_offset + pages_fetched

        # ── User Search ──
        for query in user_queries:
            start_offset = user_page_offsets.get(query, 0)
            if start_offset >= 10:
                continue

            cursor = None
            for skip_i in range(start_offset):
                _, cursor, has_next = hunter.search_users_page(query, cursor)
                if not has_next:
                    break

            pages_fetched = 0
            for page_i in range(USER_PAGES_PER_ROUND):
                page_num = start_offset + page_i + 1
                if page_num > 10:
                    break
                users, cursor, has_next = hunter.search_users_page(query, cursor)
                if not users:
                    break

                new_raw_users.extend(users)
                funnel["raw_from_users"] += len(users)
                pages_fetched += 1

                if has_next:
                    any_has_more = True
                if not has_next:
                    break

            user_page_offsets[query] = start_offset + pages_fetched

        # ── 去重 + 过滤 + 评分 ──
        deduped = hunter.deduplicate_users(new_raw_users)
        funnel["after_dedup"] += len(deduped)

        # 排除已 seen
        new_only = [u for u in deduped if u.get('login') not in seen_github_ids]
        print(f"[INFO] 新增（排除 seen）: {len(new_only)} 人", file=sys.stderr)

        # Location 过滤
        if location_target:
            before = len(new_only)
            new_only = hunter.filter_by_location(new_only, location_target, strict=location_strict)
            funnel["after_location_filter"] += len(new_only)
        else:
            funnel["after_location_filter"] += len(new_only)

        # 批量查仓库（仅对 Repo Search 来的用户，User Search 已包含）
        needs_repos = [u for u in new_only if u.get('_source') != 'user_search']
        if needs_repos:
            logins = [u['login'] for u in needs_repos if u.get('login')]
            repos_map = hunter.fetch_user_repos_batch(logins)
            for u in needs_repos:
                u['repositories'] = {'nodes': repos_map.get(u.get('login', ''), [])}

        # 批量补充重量级字段（contributionsCollection、pinnedItems、repositoryTopics）
        if new_only:
            hunter.enrich_user_details_batch(new_only)

        # 评分 + 构建 profile
        new_scored = 0
        for u in new_only:
            login = u.get('login')
            if not login:
                continue
            scores = hunter.calculate_score(u, jd_keywords, jd_language)
            candidate = hunter.build_candidate_profile(u, scores)
            if u.get('_location_match_reason'):
                candidate['location_match'] = u['_location_match_reason']
            all_candidates[login] = candidate
            seen_github_ids.add(login)
            new_scored += 1

        funnel["final_scored"] = len(all_candidates)

        print(f"\n📊 本轮新增: {new_scored} 人 | 累计: {len(all_candidates)} / {target}", file=sys.stderr)

        # ── 停止条件 ──
        if len(all_candidates) >= target:
            status = "threshold_reached"
            print(f"\n✅ 达到目标 {len(all_candidates)}/{target}", file=sys.stderr)
            break

        if not any_has_more:
            status = "exhausted"
            print(f"\n⚠️ 搜索结果耗尽，共 {len(all_candidates)} 人", file=sys.stderr)
            break

        if new_scored == 0:
            zero_rounds += 1
            if zero_rounds >= 2:
                status = "zero_yield"
                print(f"\n⚠️ 连续 {zero_rounds} 轮无新增，停止搜索", file=sys.stderr)
                break
        else:
            zero_rounds = 0

    # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    # 输出
    # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

    sorted_candidates = sorted(all_candidates.values(), key=lambda x: x.get('total_score', 0), reverse=True)

    result = {
        "search_id": search_id,
        "jd_title": jd_title,
        "status": status,
        "candidates": sorted_candidates,
        "total": len(sorted_candidates),
        "target": target,
        "has_more": any_has_more,
        "rounds_searched": rounds_searched,
        "funnel": funnel,
        "search_params": {
            "repo_queries": repo_queries,
            "user_queries": user_queries,
            "jd_keywords": jd_keywords,
            "jd_language": jd_language,
            "location_raw": location_raw,
        },
    }

    # 保存到 candidate_manager
    manager.save_search_result(
        search_id=search_id,
        jd_title=jd_title,
        queries=repo_queries,
        location_filter=location_raw,
        candidates=sorted_candidates,
        search_params=result["search_params"],
        seen_github_ids=list(seen_github_ids),
        repo_page_offsets=repo_page_offsets,
        user_page_offsets=user_page_offsets,
        rounds_searched=rounds_searched,
        status=status,
    )

    # 输出 JSON
    if args.output:
        os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
        with open(args.output, 'w', encoding='utf-8') as f:
            json.dump(result, f, ensure_ascii=False, indent=2)
        print(f"\n📁 结果已保存: {args.output}", file=sys.stderr)
    else:
        print(json.dumps(result, ensure_ascii=False, indent=2))

    # 打印漏斗摘要
    print(f"\n{'='*60}", file=sys.stderr)
    print(f"📊 搜索漏斗", file=sys.stderr)
    print(f"{'='*60}", file=sys.stderr)
    print(f"  Repo Search 原始: {funnel['raw_from_repos']} 人", file=sys.stderr)
    print(f"  User Search 原始: {funnel['raw_from_users']} 人", file=sys.stderr)
    print(f"  去重后: {funnel['after_dedup']} 人", file=sys.stderr)
    print(f"  Location 过滤后: {funnel['after_location_filter']} 人", file=sys.stderr)
    print(f"  最终结果: {funnel['final_scored']} 人", file=sys.stderr)
    print(f"  状态: {status}", file=sys.stderr)
    print(f"{'='*60}\n", file=sys.stderr)


if __name__ == "__main__":
    main()


path: scripts/location_data.py
content: #!/usr/bin/env python3
"""
地理位置数据和匹配逻辑。

设计原则：
- 层级结构：city → province → country
- 覆盖全球主要技术中心
- 短缩写仅保留全球广泛认可的（cn, sg, hk, tw, jp, uk, us）
- 中文启发式作为最后回落（仅 china 目标）
"""

import re
from dataclasses import dataclass, field
from typing import Optional, List, Tuple, Dict


@dataclass
class LocationTarget:
    """JD Parser 解析出的目标地理位置"""
    city: Optional[str] = None
    province: Optional[str] = None
    country: Optional[str] = None
    raw: str = ""


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 全球地理位置层级数据
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#
# 结构: { country_key: { "names": [...], "codes": [...], "provinces": { ... } } }
# province 结构: { province_key: { "names": [...], "cities": { city_key: [...aliases] } } }

COUNTRIES: Dict = {
    "china": {
        "names": ["中国", "china", "chinese"],
        "codes": ["cn"],
        "provinces": {
            # 直辖市（省=市）
            "beijing": {
                "names": ["北京", "beijing"],
                "cities": {"beijing": ["北京", "beijing", "peking"]}
            },
            "shanghai": {
                "names": ["上海", "shanghai"],
                "cities": {"shanghai": ["上海", "shanghai"]}
            },
            "tianjin": {
                "names": ["天津", "tianjin"],
                "cities": {"tianjin": ["天津", "tianjin"]}
            },
            "chongqing": {
                "names": ["重庆", "chongqing"],
                "cities": {"chongqing": ["重庆", "chongqing"]}
            },
            # 广东
            "guangdong": {
                "names": ["广东", "guangdong"],
                "cities": {
                    "shenzhen": ["深圳", "shenzhen"],
                    "guangzhou": ["广州", "guangzhou", "canton"],
                    "dongguan": ["东莞", "dongguan"],
                    "foshan": ["佛山", "foshan"],
                    "zhuhai": ["珠海", "zhuhai"],
                    "zhongshan": ["中山", "zhongshan"],
                    "huizhou": ["惠州", "huizhou"],
                }
            },
            # 浙江
            "zhejiang": {
                "names": ["浙江", "zhejiang"],
                "cities": {
                    "hangzhou": ["杭州", "hangzhou"],
                    "ningbo": ["宁波", "ningbo"],
                    "wenzhou": ["温州", "wenzhou"],
                    "jiaxing": ["嘉兴", "jiaxing"],
                    "shaoxing": ["绍兴", "shaoxing"],
                }
            },
            # 江苏
            "jiangsu": {
                "names": ["江苏", "jiangsu"],
                "cities": {
                    "nanjing": ["南京", "nanjing", "nanking"],
                    "suzhou": ["苏州", "suzhou"],
                    "wuxi": ["无锡", "wuxi"],
                    "changzhou": ["常州", "changzhou"],
                    "nantong": ["南通", "nantong"],
                }
            },
            # 四川
            "sichuan": {
                "names": ["四川", "sichuan"],
                "cities": {
                    "chengdu": ["成都", "chengdu"],
                    "mianyang": ["绵阳", "mianyang"],
                }
            },
            # 湖北
            "hubei": {
                "names": ["湖北", "hubei"],
                "cities": {
                    "wuhan": ["武汉", "wuhan"],
                }
            },
            # 湖南
            "hunan": {
                "names": ["湖南", "hunan"],
                "cities": {
                    "changsha": ["长沙", "changsha"],
                }
            },
            # 陕西
            "shaanxi": {
                "names": ["陕西", "shaanxi"],
                "cities": {
                    "xian": ["西安", "xian", "xi'an"],
                }
            },
            # 福建
            "fujian": {
                "names": ["福建", "fujian"],
                "cities": {
                    "fuzhou": ["福州", "fuzhou"],
                    "xiamen": ["厦门", "xiamen", "amoy"],
                    "quanzhou": ["泉州", "quanzhou"],
                }
            },
            # 山东
            "shandong": {
                "names": ["山东", "shandong"],
                "cities": {
                    "jinan": ["济南", "jinan"],
                    "qingdao": ["青岛", "qingdao"],
                }
            },
            # 河南
            "henan": {
                "names": ["河南", "henan"],
                "cities": {
                    "zhengzhou": ["郑州", "zhengzhou"],
                }
            },
            # 安徽
            "anhui": {
                "names": ["安徽", "anhui"],
                "cities": {
                    "hefei": ["合肥", "hefei"],
                }
            },
            # 辽宁
            "liaoning": {
                "names": ["辽宁", "liaoning"],
                "cities": {
                    "shenyang": ["沈阳", "shenyang"],
                    "dalian": ["大连", "dalian"],
                }
            },
            # 吉林
            "jilin": {
                "names": ["吉林", "jilin"],
                "cities": {
                    "changchun": ["长春", "changchun"],
                }
            },
            # 黑龙江
            "heilongjiang": {
                "names": ["黑龙江", "heilongjiang"],
                "cities": {
                    "harbin": ["哈尔滨", "harbin"],
                }
            },
            # 河北
            "hebei": {
                "names": ["河北", "hebei"],
                "cities": {
                    "shijiazhuang": ["石家庄", "shijiazhuang"],
                }
            },
            # 云南
            "yunnan": {
                "names": ["云南", "yunnan"],
                "cities": {
                    "kunming": ["昆明", "kunming"],
                }
            },
            # 贵州
            "guizhou": {
                "names": ["贵州", "guizhou"],
                "cities": {
                    "guiyang": ["贵阳", "guiyang"],
                }
            },
            # 广西
            "guangxi": {
                "names": ["广西", "guangxi"],
                "cities": {
                    "nanning": ["南宁", "nanning"],
                }
            },
            # 海南
            "hainan": {
                "names": ["海南", "hainan"],
                "cities": {
                    "haikou": ["海口", "haikou"],
                    "sanya": ["三亚", "sanya"],
                }
            },
            # 甘肃
            "gansu": {
                "names": ["甘肃", "gansu"],
                "cities": {"lanzhou": ["兰州", "lanzhou"]}
            },
            # 内蒙古
            "inner_mongolia": {
                "names": ["内蒙古", "inner mongolia"],
                "cities": {"hohhot": ["呼和浩特", "hohhot"]}
            },
            # 新疆
            "xinjiang": {
                "names": ["新疆", "xinjiang"],
                "cities": {"urumqi": ["乌鲁木齐", "urumqi"]}
            },
            # 宁夏
            "ningxia": {
                "names": ["宁夏", "ningxia"],
                "cities": {"yinchuan": ["银川", "yinchuan"]}
            },
            # 青海
            "qinghai": {
                "names": ["青海", "qinghai"],
                "cities": {"xining": ["西宁", "xining"]}
            },
            # 西藏
            "tibet": {
                "names": ["西藏", "tibet"],
                "cities": {"lhasa": ["拉萨", "lhasa"]}
            },
            # 港澳台
            "hong_kong": {
                "names": ["香港", "hong kong", "hongkong"],
                "cities": {"hong_kong": ["香港", "hong kong", "hongkong"]}
            },
            "taiwan": {
                "names": ["台湾", "taiwan"],
                "cities": {
                    "taipei": ["台北", "taipei"],
                    "taichung": ["台中", "taichung"],
                    "kaohsiung": ["高雄", "kaohsiung"],
                    "tainan": ["台南", "tainan"],
                    "hsinchu": ["新竹", "hsinchu"],
                }
            },
            "macau": {
                "names": ["澳门", "macau", "macao"],
                "cities": {"macau": ["澳门", "macau", "macao"]}
            },
        }
    },

    "singapore": {
        "names": ["新加坡", "singapore", "singaporean"],
        "codes": ["sg"],
        "provinces": {}
    },

    "japan": {
        "names": ["日本", "japan", "japanese"],
        "codes": ["jp"],
        "provinces": {
            "tokyo": {
                "names": ["東京", "东京", "tokyo"],
                "cities": {"tokyo": ["東京", "东京", "tokyo"]}
            },
            "osaka": {
                "names": ["大阪", "osaka"],
                "cities": {"osaka": ["大阪", "osaka"]}
            },
            "kyoto": {
                "names": ["京都", "kyoto"],
                "cities": {"kyoto": ["京都", "kyoto"]}
            },
            "fukuoka": {
                "names": ["福岡", "福冈", "fukuoka"],
                "cities": {"fukuoka": ["福岡", "福冈", "fukuoka"]}
            },
            "yokohama": {
                "names": ["横浜", "横滨", "yokohama"],
                "cities": {"yokohama": ["横浜", "横滨", "yokohama"]}
            },
        }
    },

    "south_korea": {
        "names": ["韩国", "south korea", "korea", "korean"],
        "codes": ["kr"],
        "provinces": {
            "seoul": {
                "names": ["首尔", "서울", "seoul"],
                "cities": {"seoul": ["首尔", "서울", "seoul"]}
            },
            "busan": {
                "names": ["釜山", "부산", "busan"],
                "cities": {"busan": ["釜山", "부산", "busan"]}
            },
        }
    },

    "usa": {
        "names": ["美国", "united states", "america"],
        "codes": ["us", "usa"],
        "provinces": {
            "california": {
                "names": ["加州", "california", "ca"],
                "cities": {
                    "san_francisco": ["旧金山", "san francisco", "sf", "bay area"],
                    "los_angeles": ["洛杉矶", "los angeles", "la"],
                    "san_jose": ["san jose", "圣何塞"],
                    "palo_alto": ["palo alto"],
                    "mountain_view": ["mountain view"],
                    "san_diego": ["san diego", "圣地亚哥"],
                }
            },
            "washington": {
                "names": ["华盛顿州", "washington"],
                "cities": {
                    "seattle": ["西雅图", "seattle"],
                    "redmond": ["redmond"],
                }
            },
            "new_york": {
                "names": ["纽约州", "new york"],
                "cities": {
                    "new_york_city": ["纽约", "new york", "nyc", "new york city"],
                }
            },
            "texas": {
                "names": ["德州", "texas"],
                "cities": {
                    "austin": ["奥斯汀", "austin"],
                    "dallas": ["达拉斯", "dallas"],
                }
            },
            "massachusetts": {
                "names": ["马萨诸塞", "massachusetts"],
                "cities": {
                    "boston": ["波士顿", "boston"],
                    "cambridge": ["剑桥", "cambridge"],
                }
            },
        }
    },

    "uk": {
        "names": ["英国", "united kingdom", "britain"],
        "codes": ["uk", "gb"],
        "provinces": {
            "england": {
                "names": ["英格兰", "england"],
                "cities": {
                    "london": ["伦敦", "london"],
                    "cambridge": ["剑桥", "cambridge"],
                    "manchester": ["曼彻斯特", "manchester"],
                }
            },
        }
    },

    "germany": {
        "names": ["德国", "germany", "deutschland"],
        "codes": ["de"],
        "provinces": {
            "berlin": {
                "names": ["柏林", "berlin"],
                "cities": {"berlin": ["柏林", "berlin"]}
            },
            "bavaria": {
                "names": ["巴伐利亚", "bavaria", "bayern"],
                "cities": {"munich": ["慕尼黑", "munich", "münchen"]}
            },
        }
    },

    "india": {
        "names": ["印度", "india", "indian"],
        "codes": ["in"],
        "provinces": {
            "karnataka": {
                "names": ["卡纳塔克", "karnataka"],
                "cities": {"bangalore": ["班加罗尔", "bangalore", "bengaluru"]}
            },
            "telangana": {
                "names": ["特伦甘纳", "telangana"],
                "cities": {"hyderabad": ["海得拉巴", "hyderabad"]}
            },
            "maharashtra": {
                "names": ["马哈拉施特拉", "maharashtra"],
                "cities": {
                    "mumbai": ["孟买", "mumbai", "bombay"],
                    "pune": ["浦那", "pune"],
                }
            },
            "delhi": {
                "names": ["德里", "delhi"],
                "cities": {"delhi": ["德里", "delhi", "new delhi"]}
            },
        }
    },

    "canada": {
        "names": ["加拿大", "canada", "canadian"],
        "codes": ["ca"],
        "provinces": {
            "ontario": {
                "names": ["安大略", "ontario"],
                "cities": {
                    "toronto": ["多伦多", "toronto"],
                    "waterloo": ["滑铁卢", "waterloo"],
                    "ottawa": ["渥太华", "ottawa"],
                }
            },
            "british_columbia": {
                "names": ["不列颠哥伦比亚", "british columbia", "bc"],
                "cities": {"vancouver": ["温哥华", "vancouver"]}
            },
        }
    },

    "australia": {
        "names": ["澳大利亚", "australia", "australian"],
        "codes": ["au"],
        "provinces": {
            "nsw": {
                "names": ["新南威尔士", "new south wales", "nsw"],
                "cities": {"sydney": ["悉尼", "sydney"]}
            },
            "victoria": {
                "names": ["维多利亚", "victoria"],
                "cities": {"melbourne": ["墨尔本", "melbourne"]}
            },
        }
    },

    # 区域聚合
    "southeast_asia": {
        "names": ["东南亚", "southeast asia", "sea"],
        "codes": [],
        "provinces": {}
    },
}

# 港澳台的特殊短码
SPECIAL_CODES = {
    "hk": ("china", "hong_kong"),
    "tw": ("china", "taiwan"),
}


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 内部查找索引（启动时构建，O(1) 查找）
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# alias → (country, province, city) 的反向索引
_ALIAS_INDEX: Dict[str, Tuple[str, Optional[str], Optional[str]]] = {}


def _build_index():
    """构建别名反向索引"""
    for country_key, country_data in COUNTRIES.items():
        # 国家名和代码
        for name in country_data["names"]:
            _ALIAS_INDEX[name.lower()] = (country_key, None, None)
        for code in country_data.get("codes", []):
            _ALIAS_INDEX[code.lower()] = (country_key, None, None)

        # 省份和城市
        for prov_key, prov_data in country_data.get("provinces", {}).items():
            for name in prov_data["names"]:
                _ALIAS_INDEX[name.lower()] = (country_key, prov_key, None)
            for city_key, city_aliases in prov_data.get("cities", {}).items():
                for alias in city_aliases:
                    _ALIAS_INDEX[alias.lower()] = (country_key, prov_key, city_key)

    # 特殊短码
    for code, (country, province) in SPECIAL_CODES.items():
        _ALIAS_INDEX[code.lower()] = (country, province, None)


_build_index()


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 公共 API
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

def resolve_location(raw: str) -> Optional[LocationTarget]:
    """
    将 JD Parser 输出的 location 字符串解析为 LocationTarget。

    支持输入：
    - 城市名（中英文）: "深圳", "shenzhen"
    - 省份名: "guangdong", "广东"
    - 国家名: "china", "日本"
    - 短码: "hk", "sg"
    - 多城市: "shanghai,hong kong"（取第一个作为主目标）
    - null / "" → None

    返回:
    - LocationTarget 或 None（不限地区）

    Examples:
    >>> resolve_location("深圳")
    LocationTarget(city="shenzhen", province="guangdong", country="china", raw="深圳")
    >>> resolve_location("china")
    LocationTarget(country="china", raw="china")
    >>> resolve_location("")
    None
    """
    if not raw or raw.strip().lower() in ("null", "none", ""):
        return None

    raw = raw.strip()
    # 多城市时取第一个
    first_loc = raw.split(",")[0].strip()
    key = first_loc.lower()

    # 在索引中查找
    if key in _ALIAS_INDEX:
        country, province, city = _ALIAS_INDEX[key]
        return LocationTarget(city=city, province=province, country=country, raw=raw)

    # 尝试模糊匹配（location 字符串可能包含多部分如 "Beijing, China"）
    for alias, (country, province, city) in _ALIAS_INDEX.items():
        if alias in key or key in alias:
            return LocationTarget(city=city, province=province, country=country, raw=raw)

    # 无法解析时，作为国家级兜底
    return LocationTarget(country=key, raw=raw)


def match_location(
    user_location: str,
    user_name: str,
    user_bio: str,
    target: LocationTarget,
    strict: bool = False,
) -> Tuple[bool, str]:
    """
    匹配用户的地理位置。

    strict=False（默认，宽松模式）：层级回落 city → province → country → 中文启发式
    strict=True（严格模式）：仅匹配指定城市/省份，不回落到国家级。Remote 仍然通过。

    匹配顺序:
    0. Remote → 直接通过（两种模式均通过）
    1. 精确城市匹配
    2. 省份匹配（严格模式下，这是最宽的范围）
    3. 国家回落（仅宽松模式）
    4. 中文启发式（仅宽松模式 + country=china）
    5. 不匹配

    Returns:
        (matched: bool, reason: str)
    """
    loc_lower = (user_location or "").lower()

    # 规则 0: Remote 通过（仅宽松模式）
    # 严格模式下不通过：用户说"必须是北京"时，不想看到 Remote 的人
    if "remote" in loc_lower and not strict:
        return True, "remote"

    # 规则 1: 精确城市匹配
    if target.city:
        city_aliases = _get_city_aliases(target.country, target.province, target.city)
        if _text_contains_any(loc_lower, city_aliases):
            return True, f"city:{target.city}"

    # 规则 2: 省份匹配
    if target.province:
        prov_aliases = _get_province_aliases(target.country, target.province)
        if _text_contains_any(loc_lower, prov_aliases):
            return True, f"province:{target.province}"
        # 也检查该省所有城市
        all_city_aliases = _get_all_city_aliases_in_province(target.country, target.province)
        if _text_contains_any(loc_lower, all_city_aliases):
            return True, f"province:{target.province}"

    # ── 严格模式到此为止，不回落到国家 ──
    if strict:
        return False, "no_match"

    # 规则 3: 国家回落（仅宽松模式）
    if target.country:
        country_aliases = _get_country_aliases(target.country)
        if _text_contains_any(loc_lower, country_aliases):
            return True, f"country:{target.country}"
        all_aliases = _get_all_aliases_in_country(target.country)
        if _text_contains_any(loc_lower, all_aliases):
            return True, f"country:{target.country}"

    # 规则 4: 中文启发式（仅宽松模式 + china）
    if target.country == "china":
        if _has_chinese(user_name) or _has_chinese(user_bio):
            return True, "chinese_detected"

    return False, "no_match"


def build_user_search_locations(target: LocationTarget) -> List[str]:
    """
    根据 LocationTarget 生成 User Search 的 location 参数列表。
    包含中英文变体，因为 GitHub User Search location: 是整词文本匹配。

    Examples:
    >>> build_user_search_locations(LocationTarget(city="shenzhen", province="guangdong", country="china"))
    ["shenzhen", "深圳", "guangdong", "china"]
    >>> build_user_search_locations(LocationTarget(country="singapore"))
    ["singapore", "新加坡"]
    """
    locations = []
    seen = set()

    def _add(name: str):
        if name.lower() not in seen:
            seen.add(name.lower())
            locations.append(name)

    # 城市级别（最精确）
    if target.city:
        city_aliases = _get_city_aliases(target.country, target.province, target.city)
        for alias in city_aliases:
            _add(alias)

    # 省份级别
    if target.province:
        prov_aliases = _get_province_aliases(target.country, target.province)
        for alias in prov_aliases:
            _add(alias)

    # 国家级别（最宽泛）
    if target.country:
        country_aliases = _get_country_aliases(target.country)
        for alias in country_aliases:
            _add(alias)

    return locations


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 内部辅助函数
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

def _has_chinese(text: str) -> bool:
    """检测文本是否包含中文字符"""
    if not text:
        return False
    return bool(re.search(r'[\u4e00-\u9fff]', text))


def _text_contains_any(text: str, aliases: List[str]) -> bool:
    """检查 text 是否包含任一别名（大小写不敏感）"""
    text_lower = text.lower()
    for alias in aliases:
        if alias.lower() in text_lower:
            return True
    return False


def _get_country_aliases(country_key: str) -> List[str]:
    """获取国家的所有别名"""
    country = COUNTRIES.get(country_key, {})
    aliases = list(country.get("names", []))
    aliases.extend(country.get("codes", []))
    return aliases


def _get_province_aliases(country_key: str, province_key: str) -> List[str]:
    """获取省份的所有别名"""
    country = COUNTRIES.get(country_key, {})
    province = country.get("provinces", {}).get(province_key, {})
    return list(province.get("names", []))


def _get_city_aliases(country_key: str, province_key: str, city_key: str) -> List[str]:
    """获取城市的所有别名"""
    country = COUNTRIES.get(country_key, {})
    province = country.get("provinces", {}).get(province_key, {})
    return list(province.get("cities", {}).get(city_key, []))


def _get_all_city_aliases_in_province(country_key: str, province_key: str) -> List[str]:
    """获取一个省份下所有城市的别名"""
    country = COUNTRIES.get(country_key, {})
    province = country.get("provinces", {}).get(province_key, {})
    aliases = []
    for city_aliases in province.get("cities", {}).values():
        aliases.extend(city_aliases)
    return aliases


def _get_all_aliases_in_country(country_key: str) -> List[str]:
    """获取一个国家下所有省份和城市的别名"""
    country = COUNTRIES.get(country_key, {})
    aliases = list(country.get("names", []))
    aliases.extend(country.get("codes", []))
    for prov_data in country.get("provinces", {}).values():
        aliases.extend(prov_data.get("names", []))
        for city_aliases in prov_data.get("cities", {}).values():
            aliases.extend(city_aliases)
    return aliases


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# CLI（用于测试）
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

if __name__ == "__main__":
    import sys

    if len(sys.argv) < 2:
        print("Usage: python location_data.py <location>")
        print("Example: python location_data.py 深圳")
        sys.exit(1)

    raw = sys.argv[1]
    target = resolve_location(raw)
    if target:
        print(f"Resolved: {target}")
        print(f"User Search locations: {build_user_search_locations(target)}")
    else:
        print("Location: 不限地区 (None)")


path: prompts/jd_parser_prompt.md
content: # Role (角色)
你是一位硅谷顶尖的 AI 科技猎头兼开源情报（OSINT）专家。你极其精通现代软件工程、AI 大模型底层架构，并且深谙 GitHub 的高级搜索语法。

# Task (任务)
你的任务是接收一段口语化、非标准化的 HR 招聘需求（JD）或业务人员的自然语言输入，剥离掉其中的"废话（如：薪资优厚、团队氛围好）"，精准提取核心技术指纹，并将其翻译成可以直接用于 GitHub API 查询的参数组合。

# Workflow (工作流)
1. **意图拆解**：识别用户输入中的"必需要求（Must-have）"和"加分项（Nice-to-have）"。
2. **黑话降维翻译 (Jargon Translation)**：
   - 如果用户说"大模型底层/推理优化"，你必须自动将其转译为 GitHub 上对应的硬核生态词：`"vllm", "TensorRT-LLM", "CUDA", "Triton"`。
   - 如果用户说"高并发/云原生"，转译为：`"Kubernetes", "eBPF", "Golang", "Envoy"`。
3. **构建 GitHub 查询语法 (Query Construction)**：
   - 利用 GitHub 高级搜索语法（如 `language:`, `location:`, `stars:`, `pushed:`）。
   - 过滤不活跃项目：默认添加 `pushed:>2025-01-01`（确保近期活跃）。不要添加 stars 限制（冷门技术可能没有高星仓库）。

# Output Format (输出格式)
你必须且只能输出一个合法的 JSON 对象，不要包含任何 Markdown 标记符（如 ```json），不要包含任何解释性文本。下游的 Python 脚本将直接解析你的输出。

JSON 结构必须严格如下：
```
{
  "search_queries": [
    // 生成 2-3 个不同维度的 GitHub 搜索字符串(q parameter)，以扩大命中率
    "language:cuda vllm location:beijing pushed:>2025-01-01",
    "language:c++ tensorrt-llm location:beijing pushed:>2025-01-01"
  ],
  "target_languages": ["CUDA", "C++", "Python"], // 提取的核心编程语言
  "ecosystem_keywords": ["vllm", "tensorrt-llm", "triton"], // 提取的开源项目/框架锚点
  "location": "beijing", // 如果用户未提供，则输出 null
  "reasoning": "简短的一句话解释为什么生成这些查询参数（仅供调试，不上屏幕）"
}
```

# Examples (示例)

## Example 1

**User Input:**
```
帮我在上海找一个做大模型推理架构的，最好懂 C++ 和 CUDA，年限不限，急招。
```

**Your JSON Output:**
```json
{
  "search_queries": [
    "language:cuda vllm location:shanghai pushed:>2025-01-01",
    "language:c++ inference triton location:shanghai pushed:>2025-01-01"
  ],
  "target_languages": ["C++", "CUDA"],
  "ecosystem_keywords": ["vllm", "triton", "inference"],
  "location": "shanghai",
  "reasoning": "将'大模型推理架构'转译为 vllm 和 triton 等核心开源库，并限定了地理位置和活跃度以保证质量。"
}
```

## Example 2

**User Input:**
```
找5个北京的 Rust 大神，分布式系统方向，最好给 etcd 或 TiKV 贡献过代码。
```

**Your JSON Output:**
```json
{
  "search_queries": [
    "language:rust etcd location:beijing pushed:>2025-01-01",
    "language:rust tikv location:beijing pushed:>2025-01-01",
    "language:rust raft consensus location:beijing pushed:>2025-01-01"
  ],
  "target_languages": ["Rust"],
  "ecosystem_keywords": ["etcd", "tikv", "raft", "consensus"],
  "location": "beijing",
  "reasoning": "用户明确提到 etcd 和 TiKV，直接作为生态锚点；补充 raft 和 consensus 作为分布式系统的通用关键词。"
}
```

## Example 3

**User Input:**
```
我们需要一个云原生架构师，精通 K8s、Istio，会写 Go 最好，不限地区。
```

**Your JSON Output:**
```json
{
  "search_queries": [
    "language:go kubernetes istio pushed:>2025-01-01",
    "language:go envoy service-mesh pushed:>2025-01-01"
  ],
  "target_languages": ["Go"],
  "ecosystem_keywords": ["kubernetes", "istio", "envoy", "service-mesh"],
  "location": null,
  "reasoning": "云原生架构师核心技术栈是 K8s + Istio，补充 Envoy 和 service-mesh 作为扩展搜索维度。"
}
```

---

# Anti-Patterns (反面案例)

## ❌ 错误示例1: 关键词过于宽泛

**User Input:**
```
找个做 AI 的
```

**错误输出:**
```json
{
  "search_queries": ["language:python ai machine-learning"],
  ...
}
```

**问题**: "AI" 太宽泛，会匹配大量教程、课程作业。

**正确做法**: 如果用户确实说不清，默认聚焦高影响力项目：
```json
{
  "search_queries": [
    "language:python pytorch stars:>100 pushed:>2025-01-01",
    "language:python transformers huggingface0"
  ],
  "reasoning": "用户输入过于宽泛，默认聚焦 PyTorch 和 Transformers 等主流 AI 生态，并提高 stars 阈值以过滤低质量项目。"
}
```

## ❌ 错误示例2: 忽略活跃度

**User Input:**
```
找个 Kubernetes 专家
```

**错误输出:**
```json
{
  "search_queries": ["language:go kubernetes"],
  ...
}
```

**问题**: 可能匹配到3年前就停更的项目维护者（已转行/离职）。

**正确做法**: 强制加上 `pushed:>2025-01-01` 和 `stars:>5`。

---

# Constraints (约束条件)

1. **输出必须是纯 JSON**，不包含任何 Markdown 代码块标记（`​`​`​`json`）
2. **search_queries 必须生成 2-3 个**，从不同维度覆盖（语言、生态、关键词组合）
3. **必须包含活跃度过滤**：`pushed:>2025-01-01`（不要加 stars 限制，冷门技术可能没有高星仓库）
4. **🚨 一条 search_query 中最多只能出现一个 `language:` 标签**。GitHub API 不支持同时搜索多种语言。如果需要搜多种语言，请拆分成多条 query。
5. **🚨 严禁根据语种猜测地理位置**！只要 JD 没写明确的城市或国家，或者明确写了"远程"/"不限地区"，`location` 必须输出 `null`，并且在 `search_queries` 中严禁出现 `location:` 参数！
6. **负向排除 (Negative Keywords)**：如果 JD 中明确表示"不要"、"排除"某项技术（如：不要 Hadoop），必须在 query 中使用减号排除，例如 `-hadoop`。
7. **多城市分流策略**：如果 JD 提及多个可选城市（如：北京或上海），绝对不能输出 `null`。你必须在 `search_queries` 中将城市拆分，例如第一条 query 带 `location:beijing`，第二条带 `location:shanghai`，而 JSON 根节点的 `location` 字段可以填入其中一个主要城市或设为 `null`。
8. **历史技术栈过渡 (X 转 Y 语言)**：如果 JD 要求"候选人从 X 语言转到 Y 语言"（如：C++ 转 Go），请注意 GitHub 搜索无法直接查询语言切换的历史。处理策略：强制将目标语言 Y 设为唯一的 `language:` 标签（如 `language:go`），并将过去的语言 X 作为普通的文本关键词（如 `"c++"`）放入查询串中尝试匹配，或者直接放弃对历史语言的硬性过滤。
9. **reasoning 字段必须简洁**，一句话说清楚（≤50字）


path: prompts/pitch_writer_prompt.md
content: # Pitch Writer — 个性化触达话术生成

你的任务是为每位候选人生成一封**让对方愿意回复**的触达消息。不是模板，不是群发，是基于候选人真实背景的一对一定制。

---

## 前置信息收集（⚠️ 如果以下信息缺失，OpenClaw 必须先询问用户）

在生成话术之前，必须确认以下信息。如果用户没有主动提供，OpenClaw 应主动询问：

| 信息 | 为什么需要 | 示例 |
|------|----------|------|
| **发送人身份** | 决定语气和姿态 | CTO / 技术合伙人 / HR / 猎头 / CEO |
| **发送人姓名** | 署名 | "李明" |
| **公司名称** | 让对方知道谁在联系他 | "字节跳动" / "一家 A 轮 AI 创业公司" |
| **公司一句话介绍**（可选） | 如果不是知名公司，需要简短说明 | "我们在做企业级 RAG 平台" |
| **触达目的** | 决定话术方向 | 全职招聘 / 技术顾问 / 开源合作 / 技术交流 |
| **触达渠道** | 决定消息长度和格式 | 邮件 / Twitter DM / LinkedIn / 微信 |

---

## 核心原则

### 1. 语气匹配发送人身份

| 发送人 | 语气 | 姿态 |
|--------|------|------|
| **CTO / 技术合伙人** | 技术对等，可以谈具体技术细节 | "我在你的项目里看到...我们也遇到了类似问题" |
| **CEO / 创始人** | 商业视角 + 技术尊重，谈愿景和方向 | "我们在做X方向，你在Y领域的积累正是我们需要的" |
| **HR / 猎头** | 专业但不居高临下，展现对候选人的了解 | "我们技术团队关注到你的项目...想和你聊聊可能的合作" |
| **合伙人 / 投资人** | 商务范 + 技术认可 | "在调研X方向时注意到你的工作..." |

### 2. 引用候选人的真实背景（核心价值）

这是区别于群发消息的关键——让对方感到"你真的了解我"。

**切入点优先级**：
1. **候选人置顶项目（pinnedItems）**（对方主动展示的 = 最引以为傲的项目，引用效果最佳）
2. **代表作及其影响力**（如 "你的 fumadocs 被 11000+ 开发者使用"）
3. **具体的技术方向**（如 "你在微前端架构方面的积累"）
4. **公司/团队背景**（如 "你在字节跳动 flowgram 团队的经历"）
5. **博客文章/技术分享**（如果有）

**禁止**：
- ❌ 泛泛赞美（"你很优秀"、"你很活跃"）
- ❌ 引用无关紧要的内容（Update README、Bump version）
- ❌ 编造不存在的技术细节

### 3. 不同目的的话术方向

| 目的 | 话术重心 | 行动邀请 |
|------|---------|---------|
| **全职招聘** | 我们在做什么 + 为什么需要你 | "方便聊聊吗？" / "下周有空喝杯咖啡？" |
| **技术顾问** | 我们遇到了什么挑战 + 你的经验很匹配 | "想请教一下你在X方向的实践经验" |
| **开源合作** | 我们的项目和你的项目有协同 | "想和你探讨一下合作的可能性" |
| **技术交流** | 对你做的X方向很感兴趣 | "方便线上聊一下吗？" |

### 4. 不同渠道的长度控制

| 渠道 | 长度上限 | 格式要求 |
|------|---------|---------|
| **邮件** | 150字（中文）/ 200 words（英文） | 有主题行，正文 4-5 句 |
| **Twitter DM** | 80字 | 极简，1-2 句直奔主题 |
| **LinkedIn** | 120字 | 职业化语气 |
| **微信** | 100字 | 口语化，像朋友介绍 |

---

## 绝对禁止的 HR 套话

以下用语**绝不出现**，无论发送人是谁：

- "薪资丰厚" / "待遇优厚" / "有竞争力的薪酬"
- "五险一金" / "弹性工作" / "股票期权"
- "急招" / "机会难得" / "行业领先"
- "诚邀加入" / "期待您的回复" / "恭候佳音"
- "我们是一家..." 开头的长篇自我介绍
- "Dear XX" / "尊敬的XX" 这种群发模板语气

---

## 生成流程

### Step 1: 分析候选人，选择切入点

根据候选人的 profile 数据（来自 Step 2 确认的信息），确定最有力的切入点：

- 代表作名称 + 影响力数据
- 技术方向 + 与 JD 的关联
- 公司背景（如果是知名公司）
- bio 中的兴趣关键词

### Step 2: 匹配发送人身份，确定语气

根据发送人身份选择合适的语气和姿态（见上表）。

### Step 3: 生成话术

按照以下结构（不是死板模板，灵活组合）：

1. **开头**：一句话表明你了解对方（引用具体项目/背景）
2. **中间**：说明联系原因（我们在做什么 / 为什么找你）
3. **结尾**：低压力的行动邀请（聊聊 / 请教 / 交流）
4. **署名**：发送人姓名 + 职位 + 公司

### Step 4: 自检

生成后检查：
- [ ] 是否引用了候选人的真实背景？（不是泛泛赞美）
- [ ] 语气是否匹配发送人身份？（CTO 不该像 HR，HR 不该装 CTO）
- [ ] 是否有 HR 禁用套话？
- [ ] 字数是否在渠道限制内？
- [ ] 行动邀请是否低压力？（"聊聊"而非"面试"）
- [ ] 如果候选人的 GitHub 信息有限，是否避免了编造细节？
- [ ] 引用的数据是否准确？（如性能对比的基准对象、Star 数量级）不确定时用模糊表述（"远超同类" 而非 "快 6 倍"）
- [ ] 署名中是否有占位符？邮箱/联系方式如果用户未提供，提醒用户补充，不要留 `[你的邮箱]`

---

## 示例

### 示例 1: CTO 通过邮件联系开源框架作者

**发送人**：张三，CTO，某 AI 创业公司
**候选人**：fuma-nama，fumadocs (11.3K★) 作者
**目的**：全职招聘

```
主题：关于 fumadocs 和我们文档平台的交流

Hi Fuma，

一直在用 fumadocs，我们内部的开发者文档平台就是基于它搭建的。
最近在做 AI 文档搜索的集成，想把 RAG 能力嵌入到文档框架里，
但在文档结构化解析这块遇到了一些挑战。

想和你聊聊这个方向，也聊聊是否有兴趣一起把这件事做得更好。
下周方便吗？

张三
CTO | XX AI
[REDACTED_SECRET]
```

### 示例 2: 猎头通过 Twitter DM 联系字节工程师

**发送人**：李四，猎头
**候选人**：xiamidaxia，字节跳动 flowgram.ai 核心开发者
**目的**：全职招聘

```
Hi，关注到你在 flowgram.ai 的工作，在工作流引擎方向积累很深。
有个做 AI Agent 平台的团队在找这个方向的技术负责人，方便私聊了解一下吗？
```

### 示例 3: CEO 通过邮件联系技术大牛

**发送人**：王五，CEO/创始人
**候选人**：unadlib，mutative (2K★) 作者
**目的**：技术合伙人

```
主题：一个关于前端基础设施的想法

Hi Michael，

我是王五，XX 的创始人。之前在做技术选型时用到了 mutative，
比 Immer 快这么多还保持了 API 兼容，这个工程判断力让我印象深刻。

我们在做的方向是 [一句话介绍]，技术架构层有很多需要这种
"底层性能优化 + 上层开发者体验"的决策。想和你聊聊，
不一定是工作机会，也可能是技术顾问的形式。

方便约个时间吗？

王五
CEO | XX
[REDACTED_SECRET]
```

### 示例 4: HR 通过邮件联系（当候选人信息有限时）

**发送人**：赵六，HR
**候选人**：某人，仅知道在 React 方向有几个项目
**目的**：全职招聘

```
主题：一个 React 方向的技术交流

Hi XX，

我们技术团队在了解 React 生态的活跃开发者时注意到了你的项目，
尤其是 [项目名] 在 [某方面] 的实现。

团队目前在搭建 [简短说明]，方向比较契合，想和你聊聊看是否有兴趣了解。
方便的话回复这封邮件就行，我来约时间。

赵六
XX 公司 技术招聘
[REDACTED_SECRET]
```

---

## 特殊情况处理

| 情况 | 处理方式 |
|------|---------|
| 候选人无公开邮箱 | 在话术中不要提"看到你的邮箱"，根据实际渠道调整开头 |
| 候选人近期不活跃 | 不要提"最近看到你..."，改用"之前注意到你在X方向的工作" |
| 候选人信息很少（无 bio、项目少） | 不要编造，坦诚以项目本身为切入点，缩短篇幅 |
| 候选人是知名大厂在职 | 不要贬低对方公司，以技术方向差异化吸引 |
| 候选人 isHireable=true | 话术可以更直接谈工作机会，不需要绕弯子 |
| 发送人不愿暴露公司名 | 用"一家做X方向的团队"替代，但不能完全匿名 |


path: SECURITY.md
content: # 🔒 安全说明 (Security Policy)

## OpenClaw 安全扫描提示

如果 OpenClaw 安全扫描提示本 skill 为 **"Suspicious"（可疑）**，这是**正常且预期**的行为。

### 为什么会被标记？

1. **需要外部 API 凭证（GitHub Token）**
   - 本 skill 使用 GitHub API 搜索开发者
   - 需要用户自行申请并配置 GitHub Personal Access Token
   - Token 存储在本地 `.env` 文件中（不会上传到 git）

2. **包含 Prompt 模板**
   - `prompts/jd_parser_prompt.md` - 用于解析招聘需求
   - `prompts/pitch_writer_prompt.md` - 用于生成触达话术
   - 这些是**功能需要**，不是恶意 prompt injection

3. **元数据中省略了凭证配置**
   - 出于安全考虑，skill 不包含预配置的 Token
   - 用户必须自行配置（防止凭证泄露）

---

## 安全保证

### ✅ 我们做到了什么

| 安全措施 | 说明 |
|---------|------|
| **不包含真实凭证** | `.env` 文件为空模板，需用户自行配置 |
| **不自动执行操作** | 所有候选人需人工确认，触达话术需人工审核 |
| **仅使用公开数据** | 只使用 GitHub 公开 API，不爬取私密信息 |
| **Human-in-the-Loop** | 用户逐一确认候选人，不批量发送消息 |
| **本地数据存储** | 搜索结果存储在本地，不上传到任何服务器 |
| **遵循 .gitignore** | 敏感文件（Token、搜索结果）不会被上传 |

### ❌ 我们不会做什么

- ❌ 不购买/爬取私密数据
- ❌ 不绕过 GitHub 反爬机制
- ❌ 不自动发送邮件或消息
- ❌ 不将候选人数据上传到任何服务器
- ❌ 不存储或共享用户的 GitHub Token

---

## GitHub Token 安全配置

### 如何安全地配置 Token

1. **申请 Token**
   - 访问 https://github.com/settings/tokens
   - 创建新 token，只勾选 `read:user` 和 `read:org` 权限（最小权限原则）
   - 复制 token

2. **配置 Token**
   ```bash
   # 方式1: 使用 .env 文件（推荐，持久化）
   echo "GITHUB_TOKEN=your_token_here" > ~/.openclaw/workspace/skills/ai-talent-hunter/.env
   
   # 方式2: 临时环境变量（不持久化）
   export GITHUB_TOKEN=ghp_your_token_here
   ```

3. **验证 .gitignore**
   ```bash
   # 确认 .env 在 .gitignore 中
   grep ".env" ~/.openclaw/workspace/skills/ai-talent-hunter/.gitignore
   ```

### Token 安全红线

| ❌ 绝对不要 | ✅ 应该这样 |
|------------|------------|
| 把 Token 提交到 git | 使用 `.env` 文件（已在 .gitignore 中） |
| 在代码中硬编码 Token | 从环境变量读取 |
| 给予过高权限（如 repo、admin） | 只给 `read:user` 和 `read:org` |
| 分享给他人 | 每人使用自己的 Token |

---

## 数据隐私

### 搜索结果存储位置

- **本地 JSONL 文件**：`~/.openclaw/workspace/skills/ai-talent-hunter/data/candidates.jsonl`
- **不上传到云端**：所有数据存储在本地，不会发送到任何第三方服务器
- **已在 .gitignore**：`data/` 文件夹不会被 git 追踪

### 候选人隐私保护

- ✅ 只使用 GitHub 公开信息（Profile、公开仓库、公开邮箱）
- ✅ 不爬取私密仓库或非公开数据
- ✅ Human-in-the-Loop：用户逐一确认候选人，避免大撒网式骚扰
- ✅ 触达话术仅作草稿，需人工审核后手动发送

---

## 合规说明

本 skill 遵守以下原则：

1. **GitHub API 使用条款**
   - 使用 GitHub 官方 GraphQL API
   - 遵守 Rate Limiting（每次请求间隔 0.5 秒）
   - 不绕过任何反爬机制

2. **反垃圾邮件**
   - 不自动批量发送邮件
   - 用户必须逐一确认每位候选人
   - 生成的话术仅作草稿，需人工审核

3. **数据保护**
   - 不收集用户的 GitHub Token
   - 不上传候选人数据到任何服务器
   - 不与第三方共享搜索结果

---

## 审计与透明

### 代码审计

本 skill 的核心脚本：
- `scripts/github_search.py` - GitHub API 调用和搜索逻辑
- `scripts/candidate_manager.py` - 候选人状态管理
- `scripts/location_data.py` - 地理位置匹配规则

所有代码开源，欢迎审计。

### 报告安全问题

如果你发现任何安全漏洞，请通过以下方式联系：
- 在 ClawHub 上私信作者
- 或在 GitHub 上提交 Security Advisory

---

## 常见问题

### Q: 为什么 OpenClaw 标记为 "Suspicious"？
A: 因为本 skill 需要外部 API 凭证（GitHub Token）并包含 Prompt 模板。这是功能需要，不是安全威胁。

### Q: 我的 GitHub Token 会被上传吗？
A: 不会。Token 存储在 `.env` 文件中，该文件已在 `.gitignore` 中，不会被 git 追踪。

### Q: 搜索结果会被分享给其他人吗？
A: 不会。所有数据存储在本地，不会上传到任何服务器。

### Q: 这个 skill 会自动发送邮件吗？
A: 不会。所有触达话术仅作草稿，需要你人工审核后手动发送。

---

_最后更新：2026-03-26_


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector:  ()