Metadata-Version: 2.5
Name: recruiter-msg
Version: 0.1.3
Summary: LinkedIn recruiter message automation toolkit
Author-email: Chandler <275737875@qq.com>
License-Expression: MIT
License-File: LICENSE
Keywords: automation,linkedin,messaging,recruiter
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.9
Requires-Dist: beautifulsoup4>=4.9.0
Requires-Dist: diskcache>=5.0.0
Requires-Dist: openai>=1.0.0
Requires-Dist: openpyxl>=3.0.0
Requires-Dist: pandas>=1.3.0
Requires-Dist: python-dotenv>=1.0.0
Requires-Dist: pyyaml>=6.0.0
Requires-Dist: selenium>=4.0.0
Requires-Dist: tenacity>=8.0.0
Requires-Dist: typer>=0.9.0
Provides-Extra: dev
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
Requires-Dist: pytest>=7.0.0; extra == 'dev'
Requires-Dist: ruff>=0.1.0; extra == 'dev'
Description-Content-Type: text/markdown

# recruiter-msg

LinkedIn 招聘者消息自动化工具包 —— 基于 Selenium 的 LinkedIn Recruiter 消息批量发送、候选人筛选与去重管理工具。

---

## 功能特性

- **批量消息发送**：从 Excel 读取候选人列表，自动导航至 LinkedIn Recruiter 页面并发送定制消息
- **智能去重**：基于 DiskCache + JSON 断点的双重去重机制，避免重复发送
- **候选人筛选**：支持基于关键词和 LLM 的姓名/职位评估，自动过滤非技术岗位
- **URN 缓存**：自动缓存公开 URL 与 Recruiter URN 的映射关系，加速后续访问
- **简历提取**：从 LinkedIn 页面自动提取候选人资料文本并保存
- **LLM 集成**：支持 OpenAI 兼容 API（DeepSeek、Ollama 等）进行智能评估
- **模板变量**：消息正文支持 `{name}`、`{title}`、`{email}`、`{linkedin_url}` 动态替换
- **CLI 命令行**：提供完整的 Typer CLI 工具，开箱即用
- **配置化管理**：支持 YAML 配置文件，字段可选省略，内置默认值，零配置启动

---

## 安装

### 从 PyPI 安装

```bash
pip install recruiter-msg
```

---

## 快速开始

### 第一步：初始化配置

```bash
recruiter-msg init
```

此命令会在当前目录生成 `.env` 和 `config.yaml` 两个配置文件：

**`.env` 文件**（LLM 配置，可选）：

```ini
# LLM 配置
LLM_API_KEY=your-api-key-here
LLM_BASE_URL=http://api.openai.com/v1
LLM_MODEL=deepseek-v3.1-terminus-chat
LLM_TIMEOUT=120
LLM_MAX_RETRIES=3
```

> 如果不使用 LLM 评估功能，可跳过 `.env` 配置。

**`config.yaml` 文件**（LinkedIn 自动化配置）：

```yaml
# config.yaml — LinkedIn 自动化配置
# 字段说明：
#   - status_keywords: 消息状态关键词（如 Accepted、Deleted），用于判断是否已发送
#   - subject_keywords: 消息主题关键词（如 Agent），用于判断是否已发送
#   - recruiter: 招聘者标识，匹配消息发送者姓名
#   - blacklist: 黑名单（可选）
#   - linkedin_list: LinkedIn 列表（可选）

status_keywords:
  - Accepted
  - Deleted

subject_keywords:
  - Agent

recruiter: chandler

blacklist: []
linkedin_list: []
```

**字段默认值**：

| 字段 | 默认值 | 说明 |
|------|--------|------|
| `status_keywords` | `["Accepted", "Deleted"]` | 消息状态关键词列表 |
| `subject_keywords` | `[]` | 消息主题关键词列表 |
| `recruiter` | `"chandler"` | 招聘者姓名 |
| `blacklist` | `[]` | 黑名单 URL 列表 |
| `linkedin_list` | `[]` | LinkedIn 列表 |

> 所有字段均为可选，省略时使用默认值。`config.yaml` 文件本身也是可选的，不存在时自动使用代码内置默认值。

### 第二步：准备 Cookie 文件

本工具需要 LinkedIn Recruiter 的登录 Cookie 来访问招聘者页面：

1. 用浏览器登录 [LinkedIn Recruiter](https://www.linkedin.com/talent/home)
2. 安装 Cookie 导出浏览器插件（推荐 [EditThisCookie](https://www.editthiscookie.com/) 或 [Cookie-Editor](https://cookie-editor.com/)）
3. 导出 Cookie 为 JSON 格式
4. 保存为 `cookie.json` 到当前目录（或通过 `--cookie-file` 指定路径）

### 第三步：准备候选人 Excel 文件

创建一个 Excel 文件（`.xlsx`），包含以下列：

| 列名              | 说明                                                         |
| ----------------- | ------------------------------------------------------------ |
| `url`             | LinkedIn 个人资料 URL（公开链接或 Recruiter 链接）           |
| `tag`             | 标签，用于过滤（如 `"agent"`）                               |
| `recommend_title` | 推荐职位标题，同时用作消息主题                               |
| `email_content`   | 消息正文内容，支持模板变量替换                               |

**模板变量**：在 `email_content` 中可使用以下变量，发送时自动替换：

- `{name}` — 候选人姓名（从 LinkedIn 页面解析）
- `{title}` — 候选人当前职位
- `{email}` — 候选人邮箱（需配置邮箱数据源）
- `{linkedin_url}` — 候选人 LinkedIn URL

**示例 Excel 数据**：

| url | tag | recommend_title | email_content |
|-----|-----|----------------|---------------|
| https://linkedin.com/in/zhangsan | agent | 高级Java工程师 | 你好 {name}，我们对你的背景很感兴趣... |
| https://linkedin.com/in/lisi | agent | AI算法工程师 | Hi {name}，有一个 {title} 的机会想和你聊聊... |

### 第四步：运行自动化

```bash
# 基本运行（discover 模式，使用 URN 缓存）
recruiter-msg run --config config.yaml --excel ./candidates.xlsx --tag agent --mode discover

# 公开链接模式（首次运行，自动发现 URN 并缓存）
recruiter-msg run --config config.yaml --excel ./candidates.xlsx --tag agent --mode public-url

# Recruiter 链接模式（Excel 中直接提供 talent/profile 链接）
recruiter-msg run --config config.yaml --excel ./candidates.xlsx --tag agent --mode recruiter-url

# 启用 LLM 评估（智能筛选候选人）
recruiter-msg run --config config.yaml --excel ./candidates.xlsx --llm --mode discover

# 指定切片范围（只处理第 5-10 位候选人）
recruiter-msg run --config config.yaml --excel ./candidates.xlsx --start 4 --end 10

# 自定义延时（避免触发频率限制）
recruiter-msg run --config config.yaml --excel ./candidates.xlsx --delay-min 2.0 --delay-max 5.0

# 无头模式（不显示浏览器窗口）
recruiter-msg run --config config.yaml --excel ./candidates.xlsx --headless

# 详细日志输出
recruiter-msg run --config config.yaml --excel ./candidates.xlsx -v

# 不指定 --config，自动查找 ./config.yaml，不存在则使用默认值
recruiter-msg run --excel ./candidates.xlsx --tag agent
```

### 处理模式说明

| 模式             | 适用场景 | 说明 |
|------------------|----------|------|
| `discover`       | 已有 URN 缓存 | Excel 提供公开链接，先查 URN 缓存，有则直接跳转招聘者页面，无缓存则失败 |
| `public-url`     | 首次运行 | Excel 提供公开链接，从公开页面导航并自动发现招聘者链接，缓存 URN |
| `recruiter-url`  | 仅有招聘者链接 | Excel 提供招聘者链接，直接访问并回填 URN 缓存 |

### 其他命令

```bash
# 列出 Excel 中的候选人
recruiter-msg list-candidates --excel ./candidates.xlsx --tag agent --limit 20

# 检查某个标识符是否已发送
recruiter-msg check-duplicate "https://linkedin.com/in/zhangsan"

# 初始化配置文件（生成 .env 和 config.yaml，使用 --force 覆盖已有文件）
recruiter-msg init --force
```

---

## 发送单条私信（send-one）

`send-one` 是专门用来「给指定的一个 LinkedIn 人才，发送一条你亲手写好的私信」的命令。它和 `run`（批量群发）不同——`run` 是照着 Excel 名单一个个发，而 `send-one` 是一次只发一个人，消息内容完全由你现场决定。

### 用一句话理解它

想象你要**单独**给一位候选人发消息，而不是群发。你需要说清楚三件事：

1. **发给谁** → `--url`
2. **标题是什么** → `--subject`
3. **正文说什么** → `--body`（或 `--body-file`）

这就好比点外卖：先给收货地址（url），再说要什么餐（正文），最后备注口味（主题）。

### 最小可用命令（照着改就能跑）

把下面三处换成你自己的内容即可：

```bash
recruiter-msg send-one \
  --url "https://www.linkedin.com/talent/profile/xxx/" \
  --subject "关于 Agent 合作机会" \
  --body "你好，我是..."
```

> 说明：`\` 是换行符，表示「这一行还没结束，接着下一行」。整条命令其实是一句话。

### 参数逐个讲解

| 参数 | 必填？ | 默认值 | 通俗解释 |
|------|:----:|--------|---------|
| `--url` | ✅ 是 | 无 | 收件人的 LinkedIn 人才主页地址 |
| `--subject` | ✅ 是 | 无 | 消息标题（一句话说明来意） |
| `--body` | 二选一 | `""`（空） | 消息正文，直接写在命令行里 |
| `--body-file` | 二选一 | 无 | 正文写在一个 `.txt` 文件里，适合长消息 |
| `--cookie-file` | 否 | `cookie.json` | 你的登录凭证文件（相当于「门禁卡」） |
| `--recorder` | 否 | `send_log.jsonl` | 发送记录保存到哪个文件 |
| `--headless` | 否 | 关闭 | 是否隐藏浏览器窗口（`--headless` 开启） |
| `--verbose` / `-v` | 否 | 关闭 | 是否打印详细日志（排错时用） |

> **注意**：`--body` 和 `--body-file` 只能选一个。两个都不写，程序会提醒你「请提供消息正文」。

### 正文的两种写法

**写法一：直接写在命令行（适合短消息）**

```bash
recruiter-msg send-one \
  --url "https://www.linkedin.com/talent/profile/xxx/" \
  --subject "关于 Agent 合作机会" \
  --body "你好，我是..."
```

**写法二：从文件读取（适合长消息）**

先把正文写进一个文本文件，例如 `message_body.txt`：

```
你好，我叫李明，
最近我们团队在招 Agent 方向的工程师，
看到你的背景很匹配，想和你聊聊机会。
```

然后指定这个文件：

```bash
recruiter-msg send-one \
  --url "https://www.linkedin.com/talent/profile/xxx/" \
  --subject "关于 Agent 合作机会" \
  --body-file message_body.txt
```

### 命令执行后会发生什么？

程序会按顺序做这几件事（每一步都像「流水线的一环」）：

1. **检查正文**：确认你给了消息内容，没给就直接报错退出。
2. **检查 Cookie**：确认 `cookie.json` 存在，不存在会提示你先去准备。
3. **打开浏览器**：登录 LinkedIn 招聘者页面。
4. **跳到对方主页**：打开 `--url` 指定的那个人。
5. **打开消息框**：点开私信对话框。
6. **填主题和正文**：把 `--subject` 和 `--body` 填进去。
7. **点击发送**。
8. **写一条记录**：把「谁、何时、发给谁、发了什么、结果如何」追加到 `send_log.jsonl` 文件末尾。

### 发送结果怎么看？

命令结束后，屏幕会显示：

```
发送结果: success
记录已写入: C:\...\send_log.jsonl
```

`status`（发送结果）有**三种**取值：

| 状态 | 含义 | 通俗解释 |
|------|------|---------|
| `success` | 成功 | 消息发出去了 |
| `failed` | 失败 | 半路出了问题（原因会显示在 `reason` 里） |
| `skipped` | 跳过 | 比如对方的「发消息」按钮不可用，所以没发 |

**查看详细记录**：打开 `send_log.jsonl`，每行是一条完整记录，例如：

```json
{"timestamp": "2026-08-26T10:30:00", "url": "https://www.linkedin.com/talent/profile/xxx/", "subject": "关于 Agent 合作机会", "body": "你好，我是...", "status": "success", "reason": ""}
```

> 中文之所以能直接看懂，是因为记录时用了 `ensure_ascii=False`，不会变成 `\u4f60\u597d` 这种乱码。

### 完整命令示例

```bash
# 带 cookie 和记录文件的完整写法
recruiter-msg send-one \
  --url "https://www.linkedin.com/talent/profile/xxx/" \
  --subject "关于 Agent 合作机会" \
  --body "你好，我是..." \
  --cookie-file cookie.json \
  --recorder send_log.jsonl

# 无头模式（不弹出浏览器窗口）+ 详细日志
recruiter-msg send-one \
  --url "https://www.linkedin.com/talent/profile/xxx/" \
  --subject "关于 Agent 合作机会" \
  --body "你好，我是..." \
  --headless --verbose
```

---

## 项目架构

### 目录结构

```
recruiter-msg/
├── pyproject.toml              # 项目构建配置与依赖声明
├── config.yaml                 # YAML 配置文件（可选）
├── README.md                   # 项目文档
├── LICENSE                     # MIT 许可证
├── src/recruiter_msg/
│   ├── __init__.py             # 包入口，导出所有公共 API
│   ├── models.py               # 数据模型定义
│   ├── interfaces.py           # 抽象接口（策略模式）
│   ├── exceptions.py           # 自定义异常
│   ├── browser.py              # 浏览器驱动管理（单例）
│   ├── checker.py              # 去重检查器实现
│   ├── datasource.py           # 候选人数据源实现
│   ├── parser.py               # LinkedIn 页面解析器
│   ├── navigator.py            # LinkedIn 页面导航器
│   ├── sender.py               # LinkedIn 消息发送器
│   ├── evaluator.py            # 评估器实现
│   ├── strategy.py             # 消息策略实现
│   ├── orchestrator.py         # 核心编排器与工厂函数
│   ├── single_orchestrator.py  # 单条消息编排器
│   ├── recorder.py             # 发送记录器
│   ├── resume.py               # 简历文本提取
│   ├── llm.py                  # LLM 客户端
│   └── cli.py                  # CLI 命令行入口
└── tests/                      # 测试目录
```

### 核心设计模式

本项目采用 **策略模式（Strategy Pattern）** 设计，所有核心功能通过抽象接口定义，具体实现可替换：

```
┌─────────────────────────────────────────────────────────────┐
│                    CLI (cli.py / Typer)                      │
│                  recruiter-msg run / list / ...              │
└──────────────────────────┬──────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│              Orchestrator (orchestrator.py)                  │
│           LinkedInAutomationOrchestrator                     │
│                                                              │
│  process_candidate() / process_candidate_urn()              │
│  process_candidate_direct_urn()                              │
│                                                              │
│  编排完整流程：导航 → 解析 → 评估 → 去重 → 生成消息 → 发送    │
└──┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬─────────┘
   │      │      │      │      │      │      │      │
   ▼      ▼      ▼      ▼      ▼      ▼      ▼      ▼
┌─────┐┌─────┐┌─────┐┌─────┐┌─────┐┌─────┐┌─────┐┌─────┐
│Nav  ││Parse││Name ││Title││Dedup││Msg  ││Send ││Data │
│     ││     ││Eval ││Eval ││Chk  ││Strat││     ││Src  │
└──┬──┘└──┬──┘└──┬──┘└──┬──┘└──┬──┘└──┬──┘└──┬──┘└──┬──┘
   │       │       │       │       │       │       │       │
   ▼       ▼       ▼       ▼       ▼       ▼       ▼       ▼
 接口层 (interfaces.py) — 9 个抽象基类
```

### 接口与实现对照表

| 抽象接口                | 实现类                          | 职责                         |
| ----------------------- | ------------------------------- | ---------------------------- |
| `ProfileParser`         | `LinkedInProfileParser`         | 解析 LinkedIn 个人资料页面   |
| `MessageStrategy`       | `ExcelMessageStrategy`          | 基于模板生成消息内容         |
| `DuplicateChecker`      | `DiskCacheDuplicateChecker`     | 基于 DiskCache 的去重        |
|                         | `JsonCheckpointDuplicateChecker`| 基于 JSON 文件的断点去重     |
|                         | `CompositeDuplicateChecker`     | 组合多个去重检查器           |
| `Navigator`             | `LinkedInNavigator`             | LinkedIn 页面导航与交互      |
| `MessageSender`         | `LinkedInMessageSender`         | 消息输入与发送               |
| `NameEvaluator`         | `LLMNameEvaluator`              | 基于 LLM 的姓名分类评估      |
| `TitleEvaluator`        | `KeywordTitleEvaluator`         | 基于关键词的职位筛选         |
|                         | `LLMTitleEvaluator`             | 基于 LLM 的技术岗位识别      |
| `CandidateDataSource`   | `ExcelCandidateDataSource`      | 从 Excel 加载候选人数据      |
| `EmailDataSource`       | （需用户自行实现）              | 邮箱数据获取                 |
| `SendRecorder`          | `JsonlSendRecorder`             | 发送记录（JSONL 格式，追加写） |

### 数据模型

```
Config                    全局配置（状态关键词、主题关键词、招聘者标识、黑名单等）
                          - 支持从 YAML 文件加载：Config.from_yaml("config.yaml")
                          - 所有字段均有默认值，可省略
Candidate                 候选人完整数据（URL、ID、URN、消息标题/正文、标签）
CandidateProfile          从页面解析的候选人资料（姓名、职位、LinkedIn URL）
MessageContent            消息内容（标题 + 正文）
MessageThread             消息线程（发件人、主题、状态、日期、预览）
SendRequest               单条发送请求（URL + 主题 + 正文）
SendResult                单条发送结果（状态 + 原因 + 时间戳）
```

### 处理流程

以 `discover` 模式为例，单个候选人的完整处理流程：

```
1. 查询 URN 缓存（公开 URL → Recruiter URN）
2. 导航至 Recruiter 个人资料页
3. 解析候选人资料（姓名、职位）
4. 评估姓名（可选，LLM 判断国籍过滤）
5. 评估职位（关键词过滤或 LLM 判断技术岗）
6. 保存简历文本（可选）
7. 检查消息历史（避免重复发送）
8. 检查缓存去重
9. 检查消息按钮可用性
10. 打开消息对话框
11. 生成消息内容（模板变量替换）
12. 发送消息
13. 标记为已发送（写入去重缓存 + 断点文件）
```

---

## 编程接口使用

除了 CLI 命令行，也可以在 Python 代码中直接调用：

```python
from recruiter_msg import (
    Config,
    LinkedInAutomationOrchestrator,
    create_default_orchestrator,
    LLMClient,
)

# 1. 创建配置（方式一：从 YAML 文件加载）
config = Config.from_yaml("./config.yaml")

# 1. 创建配置（方式二：直接构造）
config = Config(
    status_keywords=["Accepted", "Deleted"],
    subject_keywords=["Agent"],
    recruiter="chandler",
    blacklist=[],
    linkedin_list=[],
)

# 2. 创建编排器和数据源（需要先初始化 Selenium driver）
orchestrator, data_source = create_default_orchestrator(
    driver=driver,
    config=config,
    excel_path="./candidates.xlsx",
    tag_filter="agent",
    use_llm=True,               # 启用 LLM 评估
    urn_cache_dir="./cache_dir", # URN 缓存目录
    resume_dir="./resume",       # 简历保存目录
)

# 3. 加载候选人
candidates = data_source.fetch_candidates(tag_filter="agent")

# 4. 逐个处理
for candidate in candidates:
    result = orchestrator.process_candidate_urn(candidate)
    print(f"{'成功' if result else '失败'}: {candidate.linkedin_url}")
```

### 单条消息（send-one 的 Python API）

如果你想在自己的 Python 代码里发单条消息，可以这样做：

```python
from recruiter_msg import (
    SingleMessageOrchestrator,
    LinkedInNavigator,
    LinkedInMessageSender,
    JsonlSendRecorder,
    SendRequest,
)

# 1. 组装编排器（driver 需先用 Selenium 登录，同批量流程）
orchestrator = SingleMessageOrchestrator(
    driver=driver,
    navigator=LinkedInNavigator(),
    sender=LinkedInMessageSender(),
    recorder=JsonlSendRecorder("send_log.jsonl"),
)

# 2. 构造发送请求
request = SendRequest(
    url="https://www.linkedin.com/talent/profile/xxx/",
    subject="关于 Agent 合作机会",
    body="你好，我是...",
)

# 3. 发送
result = orchestrator.send_one(request)

# 4. 查看结果
print(result.status)      # success / failed / skipped
print(result.reason)      # 失败或跳过原因（成功时为空字符串）
print(result.timestamp)   # ISO 格式时间戳
```

**三个可替换组件**（依赖注入，方便你定制）：

| 组件 | 接口 | 默认实现 | 你想定制时怎么做 |
|------|------|---------|----------------|
| `navigator` | `Navigator` | `LinkedInNavigator` | 改等待时间、选择器、点击策略 |
| `sender` | `MessageSender` | `LinkedInMessageSender` | 改用键盘模拟、加截图 |
| `recorder` | `SendRecorder` | `JsonlSendRecorder` | 改写数据库、上报远端、发通知 |

```python
# 示例：换成自定义记录器
class MyRecorder(SendRecorder):
    def record(self, request, result):
        save_to_database(request, result)

orchestrator = SingleMessageOrchestrator(
    driver=driver,
    navigator=LinkedInNavigator(),
    sender=LinkedInMessageSender(),
    recorder=MyRecorder(),
)
result = orchestrator.send_one(SendRequest(url="...", subject="...", body="..."))
```

### 浏览器驱动（BrowserManager）

`BrowserManager` 是浏览器驱动的「单例管家」——整个进程里只有一个它，driver 只启动一次、之后重复复用，避免反复打开浏览器窗口浪费资源。

```python
from recruiter_msg import BrowserManager

# 单例：不论调用多少次 BrowserManager()，拿到的都是同一个实例
manager = BrowserManager()

# 第一次调用会真正启动浏览器，之后直接复用（不会重复启动）
driver = manager.get_driver(cookie_file="cookie.json", headless=False)
same_driver = manager.get_driver()
assert driver is same_driver

# 用完关闭（可选；单次命令进程结束会自动回收）
manager.close()
```

> 这个类在 CLI 内部已经自动使用（`run` 和 `send-one` 都通过它获取 driver），你在自己的代码里也可以直接调用。

---

## 完全定制化指南

> 费曼法开场：把这个项目想象成一条「发消息的流水线」。

### 一句话理解定制

项目 = **编排器（流水线）** + **一堆零件（工位）**。

流水线只负责「按顺序把活儿传给下一个工位」，至于每个工位具体怎么干，**都能换成你自己的实现**——这就是「定制」。

> 类比：编排器是流水线，导航器、发送器、评估器、消息生成、记录器是流水线上的「工位」。流水线只传原料，每个工位的手艺你可以随便换师傅。

### 定制的三种力度

| 力度 | 怎么做 | 难度 | 例子 |
|------|--------|:----:|------|
| ① 改参数 | 不动代码结构，只传个数字/字符串 | 简单 | `LinkedInNavigator(wait_timeout=30)` |
| ② 继承改方法 | 继承原类，只重写想改的那一个方法 | 推荐 | 重写 `navigate_to_profile` 加失败重试 |
| ③ 从头实现接口 | 自己写一个类，实现接口全部方法 | 进阶 | 自己实现整个 `Navigator` |

> 最常用的是第 ② 种：**继承原类，只重写想改的那一个方法，其它用 `super()` 保留**。

### 可定制的工位总表

| 工位 | 接口 | 默认实现 | 一句话职责 |
|------|------|---------|-----------|
| 数据源 | `CandidateDataSource` | `ExcelCandidateDataSource` | 从 Excel 读候选人 |
| 资料解析 | `ProfileParser` | `LinkedInProfileParser` | 从页面抓姓名、职位 |
| 姓名评估 | `NameEvaluator` | `LLMNameEvaluator` | 判断名字国籍（过滤） |
| 职位评估 | `TitleEvaluator` | `KeywordTitleEvaluator` | 判断是否技术岗（筛选） |
| 去重 | `DuplicateChecker` | `CompositeDuplicateChecker` | 避免重复发送 |
| 消息生成 | `MessageStrategy` | `ExcelMessageStrategy` | 把模板变成正文 |
| 导航 | `Navigator` | `LinkedInNavigator` | 打开页面、点按钮 |
| 发送 | `MessageSender` | `LinkedInMessageSender` | 填主题正文并发送 |
| 记录 | `SendRecorder` | `JsonlSendRecorder` | 记录发送结果 |

### 案例 1：配置 LLM（给流水线装「大脑」）

```python
from recruiter_msg import LLMClient

# 方式一：自动读 .env（推荐）—— 读取顺序：当前目录 > 家目录
client = LLMClient()

# 方式二：直接传参
client = LLMClient(
    api_key="sk-xxx",
    base_url="https://api.deepseek.com/v1",
    model="deepseek-chat",
)

# 方式三：本地 Ollama（免费、离线）
client = LLMClient(
    api_key="ollama",
    base_url="http://localhost:11434/v1",
    model="qwen2.5:7b",
)
```

### 案例 2：自定义消息生成（让 AI 替你写正文）

```python
from recruiter_msg import MessageStrategy, MessageContent

class AIGeneratedMessageStrategy(MessageStrategy):
    """让 LLM 根据候选人信息现场写一条招聘私信"""

    def __init__(self, llm_client):
        self.llm_client = llm_client

    def generate_content(self, candidate, profile):
        body = self.llm_client.chat(
            "你是资深技术招聘官，写一条简短、礼貌、个性化的招聘私信。",
            f"候选人：{profile.name}，当前职位：{profile.title}，我们在招：{candidate.recommend_title}",
        )
        return MessageContent(title=candidate.recommend_title, body=body.strip())
```

### 案例 3：自定义职位筛选（无需 LLM，用关键词）

```python
from recruiter_msg import KeywordTitleEvaluator

# 标题里出现这些词的人会被跳过
title_filter = KeywordTitleEvaluator(
    exclude_keywords=["recruiter", "hr", "talent acquisition", "staffing"]
)
```

### 想看完整的 23 章手把手教程？

上面只是「入门三连」。更详细的定制教程（导航失败重试、键盘模拟输入、写 CSV、截图留证、从头实现接口等 23 个章节）请见 **`docs/customized_feature.md`**。

**核心心法就一句话**：定制 = 换零件，哪里改查总表，三种力度选一种。

---

## LLM 客户端配置

`LLMClient` 支持任何 OpenAI 兼容的 API 服务。

### .env 文件加载优先级

`LLMClient` 初始化时自动加载 `.env` 文件，优先级如下：

1. **显式路径**：通过 `env_file` 参数指定（最高优先级）
2. **当前目录**：`./.env`
3. **家目录**：`~/.env`（最低优先级，支持跨项目复用）

> 若当前目录和家目录均无 `.env` 文件，抛出 `EnvNotFoundError`；若 `.env` 存在但缺少 `LLM_API_KEY`，抛出 `ValueError`。

### .env 文件内容

```ini
LLM_API_KEY=your-api-key-here
LLM_BASE_URL=https://api.deepseek.com/v1
LLM_MODEL=deepseek-chat
LLM_TIMEOUT=120
LLM_MAX_RETRIES=3
```

### 使用示例

```python
from recruiter_msg import LLMClient

# 方式一：自动加载 .env（推荐）
# 优先级：当前目录 > 家目录
client = LLMClient()

# 方式二：显式指定 .env 路径
client = LLMClient(env_file="/custom/path/.env")

# 方式三：直接传参（覆盖 .env 配置）
client = LLMClient(
    api_key="sk-xxx",
    base_url="https://api.deepseek.com/v1",
    model="deepseek-chat",
    timeout=120,
    max_retries=3,
)

# 方式四：使用本地 Ollama
client = LLMClient(
    api_key="ollama",
    base_url="http://localhost:11434/v1",
    model="qwen2.5:7b",
)

# 调用
response = client.chat("你是助手", "你好")
result = client.chat_json("你是分类专家", "判断以下职位是否为技术岗：Java工程师")
```

### 异常说明

| 异常 | 触发条件 | 处理建议 |
|------|---------|---------|
| `EnvNotFoundError` | 当前目录和家目录均无 `.env` | 按异常消息中的教程创建 `.env` |
| `ValueError` | `.env` 存在但缺少 `LLM_API_KEY` | 在 `.env` 中补充 `LLM_API_KEY` 配置 |

---

## 常见问题

### Q: Cookie 过期怎么办？

重新导出 LinkedIn Cookie 并替换 `cookie.json` 文件即可。LinkedIn Cookie 通常有效期为数天至数周。

### Q: 如何避免被 LinkedIn 限制？

- 使用 `--delay-min` 和 `--delay-max` 设置合理的操作间隔（建议 2-5 秒）
- 使用 `--start` 和 `--end` 分批处理候选人
- 避免短时间内大量操作

### Q: URN 缓存有什么用？

LinkedIn Recruiter 页面使用 URN（如 `ACoAABxxx`）标识候选人，而 Excel 中通常只有公开链接。URN 缓存将公开 URL 映射到 Recruiter URN，避免每次都需要从公开页面重新发现 Recruiter 链接，大幅提升处理速度。

---

## 版本更新日志

### v1.0.1 (2026-08-26)

**更新**：`.env` 文件加载优先级优化

#### 新增功能
- **多路径 `.env` 加载**：支持当前目录 → 家目录回退，家目录 `.env` 可跨项目复用
- **显式路径指定**：`LLMClient` 新增 `env_file` 参数，支持自定义 `.env` 路径
- **新增异常类**：`EnvNotFoundError`，未找到 `.env` 时抛出含完整配置教程的异常

#### 破坏性变更
- **移除默认密钥**：`LLM_API_KEY` 不再使用内置默认值 `"sk-1234"`，未配置时抛出 `ValueError`
- **强制配置**：未找到 `.env` 文件时抛出 `EnvNotFoundError`，不再静默使用假密钥

#### 异常说明
| 异常 | 触发条件 |
|------|---------|
| `EnvNotFoundError` | 当前目录和家目录均无 `.env` 文件 |
| `ValueError` | `.env` 存在但缺少 `LLM_API_KEY` 配置 |

#### 加载优先级
1. 显式路径（`env_file` 参数）
2. 当前目录（`./.env`）
3. 家目录（`~/.env`）

### v1.0.0 (2026-08-24)

**重大更新**：配置化改造与代码清理

#### 新增功能
- **YAML 配置支持**：新增 `config.yaml` 配置文件，支持字段默认值和可选省略
- **零配置启动**：`--config` 参数可选，文件不存在时自动回退到代码内置默认值
- **init 命令增强**：`init` 命令同时生成 `.env` 和 `config.yaml`，并提供完整的使用和配置教程

#### 配置变更
| 字段 | 默认值 | 说明 |
|------|--------|------|
| `status_keywords` | `["Accepted", "Deleted"]` | 消息状态关键词列表 |
| `subject_keywords` | `[]` | 消息主题关键词列表 |
| `recruiter` | `"chandler"` | 招聘者姓名 |
| `blacklist` | `[]` | 黑名单 URL 列表 |
| `linkedin_list` | `[]` | LinkedIn 列表 |

#### CLI 变更
- **新增**：`run` 命令 `--config` 参数（默认 `./config.yaml`）
- **删除**：`run` 命令 `--recruiter` / `-r` 参数（改用 `config.yaml` 配置）

#### 代码优化
- **修复**：关键词匹配逻辑改为大小写不敏感（`k.lower()` 转换）
- **清理**：移除所有调试输出和注释代码，保持代码整洁

#### 依赖变更
- 新增 `pyyaml>=6.0.0` 依赖

#### 向后兼容性
- 原有 CLI 调用方式 `recruiter-msg run --recruiter monica` 不再支持
- 新 CLI 调用方式 `recruiter-msg run --config config.yaml`
- 若 `config.yaml` 省略 `recruiter` 字段，仍使用默认值 `"chandler"`

---

## 免责声明

**本工具仅供学习和研究目的使用。**

1. **服务条款**：本工具的自动化操作可能违反 LinkedIn 的服务条款（Terms of Service）和用户协议。使用本工具即表示你知悉并自行承担因违反服务条款而导致的一切后果，包括但不限于账号被限制、封禁或法律追诉。

2. **合规使用**：使用者应确保其使用方式符合所在地区的法律法规，包括但不限于数据隐私法（如 GDPR、CCPA 等）、反垃圾信息法以及劳动就业相关法规。

3. **无担保**：本工具按"原样"（AS IS）提供，不作任何明示或暗示的担保，包括但不限于适销性、特定用途适用性和非侵权性。作者不对使用本工具所产生的任何直接、间接、附带、特殊或后果性损害承担责任。

4. **风险自担**：使用者应当充分理解本工具的功能和潜在风险，自行评估并承担所有使用风险。作者不对因使用本工具而导致的任何损失、数据泄露、账号问题或其他不良后果承担任何责任。

5. **非商业用途**：本工具不鼓励也不支持任何形式的商业滥用、大规模自动化骚扰或垃圾信息发送行为。

6. **修改与终止**：作者保留随时修改、更新或终止本工具的权利，无需事先通知。

**使用本工具即表示你已阅读、理解并同意以上免责声明的所有条款。如果你不同意，请勿使用本工具。**
