generated from kgod/ai-review-template
937 lines
43 KiB
Markdown
937 lines
43 KiB
Markdown
# AI 招聘网站智能爬虫系统
|
||
|
||
## 项目目标
|
||
|
||
用户输入**公司名称**,系统自动完成:
|
||
1. 通过搜索引擎找到该公司的官网招聘页面
|
||
2. AI 分析页面结构,提取爬虫所需的 CSS 选择器配置
|
||
3. 使用配置自动爬取所有岗位数据
|
||
|
||
**核心理念**:让 AI Agent 像人一样「看懂」网页,自动生成爬虫配置,无需针对每个网站手写选择器。
|
||
|
||
---
|
||
|
||
## 整体流程
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ 用户输入: 公司名称 │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Step 1: search_company │
|
||
│ 功能: 搜索引擎查找公司官网招聘页面 │
|
||
│ 输出: 招聘列表页 URL │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Step 2: page_analysis_graph │
|
||
│ 功能: 分析列表页,找到岗位项的 CSS 选择器 │
|
||
│ 输出: job_item_selector, change_type │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Step 3: next_page_graph │
|
||
│ 功能: 分析分页组件,找到「下一页」按钮的选择器 │
|
||
│ 输出: next_page_selector, change_type │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Step 4: detail_analysis_graph │
|
||
│ 功能: 进入详情页,提取各字段(标题/薪资/要求等)的选择器 │
|
||
│ 输出: field_selectors │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Step 5: crawler (待开发) │
|
||
│ 功能: 使用上述选择器配置,自动翻页爬取所有岗位数据 │
|
||
│ 输出: 结构化岗位数据列表 │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 模块说明
|
||
|
||
### 1. search_company_graph - 搜索招聘页面
|
||
|
||
**目的**: 根据公司名,通过搜索引擎找到其官网招聘列表页
|
||
|
||
**技术方案**: LangGraph 状态机 + Playwright + LLM 结构化输出
|
||
|
||
**流程**:
|
||
```
|
||
Node 0: route_entry (入口路由)
|
||
有已知 candidate_urls → 跳过搜索,直接 Node 3
|
||
无 → 进入 Node 1 搜索
|
||
│
|
||
▼
|
||
Node 1: search_sogou
|
||
搜狗搜索 "{company_name} 招聘 官网"
|
||
提取结构化搜索结果(标题、URL、描述)
|
||
│
|
||
▼
|
||
Node 2: extract_links
|
||
LLM 分析搜索结果
|
||
提取最多 3 个候选 URL(按可能性排序)
|
||
│
|
||
▼
|
||
Node 3: visit_and_verify
|
||
访问候选 URL
|
||
LLM 判断是否为招聘列表页
|
||
判断标准:
|
||
- 包含多个岗位信息(职位名称)
|
||
- 页面有分页控件
|
||
- 或有岗位搜索/筛选功能
|
||
- 或有明确的岗位分类导航
|
||
│
|
||
✔️ 是 → 返回 URL ✅
|
||
❌ 不是 → 进入 Node 4
|
||
│
|
||
▼
|
||
Node 4: navigate_to_recruitment
|
||
LLM 找页面上的招聘入口(避开已点击过的)
|
||
点击入口 → 回 Node 3 验证
|
||
循环最多 10 次,超过则换下一个候选 URL
|
||
```
|
||
|
||
**输入/输出**:
|
||
```python
|
||
from src.search_company_graph import search_company_recruitment
|
||
|
||
# 输入
|
||
company_name: str # 公司名称,如 "联想"
|
||
input_url: str # 已知招聘地址(可选),有值时跳过搜索直接验证
|
||
headless: bool # 是否无头模式,默认 False
|
||
|
||
# 输出 (成功)
|
||
["https://jobs.lenovo.com.cn/..."] # 招聘页面 URL 列表
|
||
|
||
# 输出 (失败)
|
||
[] # 空列表,表示未找到
|
||
```
|
||
|
||
**文件结构**:
|
||
```
|
||
src/search_company_graph/
|
||
├── __init__.py # 导出 search_company_recruitment
|
||
├── state.py # SearchState TypedDict
|
||
├── prompts.py # LLM 提示词模板
|
||
├── nodes.py # 4 个节点实现
|
||
├── graph.py # LangGraph 流程定义
|
||
└── main.py # 入口函数
|
||
```
|
||
|
||
---
|
||
|
||
### 2. page_analysis_graph - 岗位列表分析
|
||
|
||
**目的**: 分析招聘列表页,找到可点击进入岗位详情的元素 CSS 选择器
|
||
|
||
**流程**: 获取HTML → LLM找列表区域 → LLM找可点击元素 → 点击验证能否进入详情
|
||
|
||
**选择器优先级**: 岗位名称链接 > 申请/查看按钮 > 详情图标 > 整个岗位卡片
|
||
|
||
**输入/输出**:
|
||
```python
|
||
# 输入
|
||
url: str # 招聘列表页URL
|
||
headless: bool # 是否无头模式
|
||
|
||
# 输出 (成功)
|
||
{
|
||
"status": "success",
|
||
"selector": ".job-list a.title", # 可点击元素选择器
|
||
"item_count": 20, # 匹配到的岗位数
|
||
"change_type": "redirect" # 点击后的变化类型
|
||
}
|
||
|
||
# change_type: redirect(跳转) / new_tab(新标签) / in_page(页内变化)
|
||
|
||
# 输出 (失败)
|
||
{
|
||
"status": "failed",
|
||
"reason": "未找到招聘区域", # 失败原因
|
||
"tried_selectors": ["...", ...] # 尝试过的选择器
|
||
}
|
||
|
||
# 特殊情况: item_count=0 时标记为失败,不触发下一步
|
||
```
|
||
|
||
---
|
||
|
||
### 3. next_page_graph - 分页分析
|
||
|
||
**目的**: 找到「下一页」按钮的 CSS 选择器,用于翻页爬取
|
||
|
||
**流程**: 获取HTML → LLM找分页区域 → LLM找下一页按钮 → 点击验证页面变化
|
||
|
||
**输入/输出**:
|
||
```python
|
||
# 输入
|
||
url: str # 招聘列表页URL
|
||
headless: bool
|
||
|
||
# 输出 (成功 - 有分页)
|
||
{
|
||
"status": "success",
|
||
"selector": ".pagination .next", # 下一页按钮选择器
|
||
"change_type": "url_change", # 点击后的变化类型
|
||
"has_pagination": 1 # 有分页
|
||
}
|
||
|
||
# 输出 (成功 - 无分页)
|
||
{
|
||
"status": "success",
|
||
"selector": null,
|
||
"change_type": null,
|
||
"has_pagination": 0 # 无分页,一页展示完
|
||
}
|
||
|
||
# change_type: url_change(URL变化) / content_change(内容变化) / new_tab
|
||
|
||
# 输出 (失败)
|
||
{
|
||
"status": "failed",
|
||
"reason": "点击后无变化",
|
||
"tried_selectors": ["...", ...]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4. detail_analysis_graph - 详情页分析
|
||
|
||
**目的**: 从列表进入详情页,提取岗位各字段的 CSS 选择器
|
||
|
||
**流程**:
|
||
```
|
||
open_list_page → click_first_job → find_detail_area(仅in_page) → extract_field_selectors → validate_data
|
||
```
|
||
|
||
**字段提取原则**:
|
||
- `job_title` 和 `description` 是核心字段,必须提取
|
||
- `description` 包含完整岗位信息(职责、要求、公司介绍等都放这里)
|
||
- 其他字段(salary/location/company/experience/education)只有在页面上有**独立、明确的元素**时才提取,否则为 null
|
||
|
||
**输入/输出**:
|
||
```python
|
||
# 输入
|
||
url: str # 招聘列表页URL
|
||
job_item_selector: str # 来自 page_analysis_graph
|
||
change_type: str # 来自 page_analysis_graph (redirect/new_tab/in_page)
|
||
headless: bool
|
||
|
||
# 输出 (成功)
|
||
{
|
||
"status": "success",
|
||
"detail_area_selector": ".job-detail", # 详情区域选择器(仅in_page有)
|
||
"fields": {
|
||
"job_title": {"selector": "h1.title", "sample": "Python工程师"},
|
||
"description": {"selector": [".desc", ".req"], "sample": "负责...\n要求..."},
|
||
"salary": {"selector": ".salary", "sample": "25-40K"}, # 或 null
|
||
"location": {"selector": ".loc", "sample": "北京"}, # 或 null
|
||
"company": {"selector": ".company", "sample": "XX公司"}, # 或 null
|
||
"experience": {"selector": ".exp", "sample": "3-5年"}, # 或 null
|
||
"education": {"selector": ".edu", "sample": "本科"} # 或 null
|
||
}
|
||
}
|
||
```
|
||
|
||
**支持字段**: job_title, description, salary, location, company, experience, education, detail_url
|
||
|
||
```python
|
||
# 输出 (失败)
|
||
{
|
||
"status": "failed",
|
||
"error": "验证失败,无法提取有效字段" # 失败原因
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 5. crawler - 数据爬取 (待开发)
|
||
|
||
**目的**: 使用分析得到的选择器配置,自动爬取所有岗位数据
|
||
|
||
**预期流程**:
|
||
1. 打开列表页
|
||
2. 使用 job_item_selector 获取当前页所有岗位
|
||
3. 逐个点击进入详情页,用 field_selectors 提取数据
|
||
4. 使用 next_page_selector 翻页
|
||
5. 重复直到无下一页
|
||
|
||
**预期输入/输出**:
|
||
```python
|
||
# 输入
|
||
config: {
|
||
"url": "https://...",
|
||
"job_item_selector": "...",
|
||
"next_page_selector": "...",
|
||
"change_type": "...",
|
||
"field_selectors": {...}
|
||
}
|
||
|
||
# 输出
|
||
[
|
||
{"job_title": "...", "salary": "...", "location": "...", ...},
|
||
{"job_title": "...", "salary": "...", "location": "...", ...},
|
||
...
|
||
]
|
||
```
|
||
|
||
---
|
||
|
||
## 技术栈
|
||
|
||
- **LangGraph**: AI Agent 工作流编排
|
||
- **Playwright**: 浏览器自动化
|
||
- **LangChain + Pydantic**: LLM 调用与结构化输出
|
||
- **doubao-1-5-pro-32k**: 主力 LLM 模型
|
||
|
||
---
|
||
|
||
## 开发状态
|
||
|
||
| 模块 | 状态 | 说明 |
|
||
|-----------------------|------|------------|
|
||
| search_company | ✅ 已完成 | 基于MCP的搜索方案 |
|
||
| page_analysis_graph | ✅ 已完成 | 岗位选择器提取 |
|
||
| next_page_graph | ✅ 已完成 | 分页选择器提取 |
|
||
| detail_analysis_graph | ✅ 已完成 | 详情字段选择器提取 |
|
||
| crawler | ✅ 已完成 | 数据爬取模块 |
|
||
| company_generator | ✅ 已完成 | AI生成公司名 |
|
||
| company_importer | ✅ 已完成 | Excel表格批量导入公司 |
|
||
|
||
|
||
---
|
||
|
||
### 7. company_importer - Excel 公司名单批量导入
|
||
|
||
**目的**: 从 Excel 表格批量导入公司名称及已知招聘地址,自动创建爬虫任务
|
||
|
||
**模块位置**: `src/company_importer/`
|
||
|
||
**核心逻辑**:
|
||
1. 读取 Excel(支持超链接提取真实 URL)
|
||
2. URL 验证与清洗(过滤邮箱、微信公众号、无效地址)
|
||
3. 内存去重 + 数据库唯一约束兜底
|
||
4. 逐行插入 `app_crawl_task` + `app_task_search`(带 `input_url`)
|
||
|
||
**URL 过滤规则**:
|
||
- 邮箱地址(含 `@`)→ 跳过
|
||
- 微信公众号(`mp.weixin.qq.com`)→ 跳过
|
||
- 无协议头 → 自动补 `https://`
|
||
- 无域名或域名无点 → 无效
|
||
|
||
**输入/输出**:
|
||
```python
|
||
from src.company_importer import import_companies
|
||
|
||
# 输入
|
||
result = import_companies(
|
||
file_path="xxx.xlsx", # Excel 文件路径
|
||
company_col=3, # 公司名所在列(从1开始,如 C列=3)
|
||
url_col=9, # 招聘地址所在列(如 I列=9)
|
||
)
|
||
|
||
# 输出
|
||
{
|
||
"total_rows": 100,
|
||
"inserted_count": 60,
|
||
"skipped_empty_name": 5,
|
||
"skipped_empty_url": 10,
|
||
"skipped_email": 3,
|
||
"skipped_weixin": 2,
|
||
"skipped_invalid_url": 5,
|
||
"skipped_duplicate": 15,
|
||
"inserted_companies": ["公司A", "公司B", ...]
|
||
}
|
||
```
|
||
|
||
**与 Step1 的联动**: 导入时将已知 URL 写入 `app_task_search.input_url`,Step1 执行时检测到 `input_url` 非空,跳过搜索直接进入验证流程。
|
||
|
||
**文件结构**:
|
||
```
|
||
src/company_importer/
|
||
├── __init__.py # 导出 import_companies
|
||
├── importer.py # 导入核心逻辑
|
||
├── url_validator.py # URL 提取、验证、清洗
|
||
└── __main__.py # 测试入口 (python -m src.company_importer)
|
||
```
|
||
|
||
---
|
||
|
||
## 数据库表结构
|
||
|
||
### 主表:app_crawl_task(爬虫任务主表)
|
||
|
||
```sql
|
||
CREATE TABLE `app_crawl_task`
|
||
(
|
||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||
`company_name` VARCHAR(255) NOT NULL COMMENT '公司名称',
|
||
|
||
-- 配置阶段(前4步)
|
||
`config_step` INT NULL DEFAULT 1 COMMENT '【配置阶段】当前步骤: 1=搜索招聘页, 2=列表分析, 3=分页分析, 4=详情分析',
|
||
`config_status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '【配置阶段】状态: pending=待执行, running=执行中, success=成功, failed=失败',
|
||
|
||
-- 爬取阶段(第5步)
|
||
`crawl_status` VARCHAR(32) DEFAULT NULL COMMENT '【爬取阶段】状态: pending=待执行, running=执行中, success=成功, failed=失败',
|
||
`total_crawl_times` INT NOT NULL DEFAULT 0 COMMENT '【爬取阶段】累计爬取次数',
|
||
`last_crawl_at` DATETIME DEFAULT NULL COMMENT '【爬取阶段】上次爬取时间',
|
||
|
||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE INDEX `uk_company_name` (`company_name`),
|
||
INDEX `idx_config_status` (`config_status`),
|
||
INDEX `idx_crawl_status` (`crawl_status`)
|
||
) ENGINE = InnoDB
|
||
DEFAULT CHARSET = utf8mb4 COMMENT ='爬虫任务主表';
|
||
|
||
-- 业务规则:
|
||
-- 1. 项目入口表,创建时检查 company_name 唯一索引,防止重复
|
||
-- 2. 状态初始为 pending(待执行),保存后禁止任何编辑
|
||
-- 3. 创建成功后,自动在 app_task_search 表创建一条关联任务
|
||
-- 4. 其他状态更新同步至本表
|
||
-- 5. job_periodic_crawl定时任务检查上次 crawl_status = success 且 last_crawl_at 大于15天, 更新crawl_status 为 pending,并创建app_task_crawl数据
|
||
|
||
```
|
||
|
||
|
||
|
||
|
||
### Step 1:app_task_search(搜索招聘页面)
|
||
|
||
```sql
|
||
CREATE TABLE `app_task_search`
|
||
(
|
||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||
`crawl_task_id` BIGINT NOT NULL COMMENT '关联主表ID',
|
||
`status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '任务状态: pending=待执行, running=执行中, success=成功, failed=失败',
|
||
`retry_count` INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
|
||
`max_retry` INT NOT NULL DEFAULT 3 COMMENT '最大重试次数',
|
||
`input_company_name` VARCHAR(255) NOT NULL COMMENT '【输入】公司名称',
|
||
`input_url` VARCHAR(1024) DEFAULT NULL COMMENT '【输入】已知招聘地址(表格导入时填入,有值时Step1跳过搜索直接验证)',
|
||
`output_url` VARCHAR(512) DEFAULT NULL COMMENT '【输出】招聘页URL',
|
||
`error_message` TEXT DEFAULT NULL COMMENT '错误信息',
|
||
`started_at` DATETIME DEFAULT NULL COMMENT '任务开始执行时间',
|
||
`finished_at` DATETIME DEFAULT NULL COMMENT '任务完成时间',
|
||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||
PRIMARY KEY (`id`),
|
||
INDEX `idx_crawl_task_id` (`crawl_task_id`),
|
||
INDEX `idx_status` (`status`)
|
||
) ENGINE = InnoDB
|
||
DEFAULT CHARSET = utf8mb4 COMMENT = 'Step1-搜索招聘页面任务表';
|
||
|
||
-- 业务规则:
|
||
-- 1. app_crawl_task新建数据成功时默认创建 input_company_name 来自主表
|
||
-- 2. 若由 company_importer 导入,input_url 存储已知招聘地址
|
||
-- 3. 定时任务扫描 status='pending' 的记录执行搜索,修改app_crawl_task 的 config_step=1, config_status='running'
|
||
-- 4. input_url 非空时,跳过搜索引擎搜索,直接将 input_url 作为候选链接验证
|
||
-- 5. 执行成功后 status='success',output_url 存储搜索到的招聘页URL
|
||
-- 6. 执行失败 retry_count+1,达到 max_retry 后 status='failed',修改app_crawl_task 的 config_status='failed'
|
||
-- 7. 成功后自动创建 app_task_page_analysis 任务
|
||
```
|
||
|
||
|
||
|
||
### Step 2:app_task_page_analysis(岗位列表分析)
|
||
|
||
```sql
|
||
CREATE TABLE `app_task_page_analysis`
|
||
(
|
||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||
`crawl_task_id` BIGINT NOT NULL COMMENT '关联主表ID',
|
||
`status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '任务状态: pending=待执行, running=执行中, success=成功, failed=失败',
|
||
`retry_count` INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
|
||
`max_retry` INT NOT NULL DEFAULT 3 COMMENT '最大重试次数',
|
||
`input_url` VARCHAR(1024) NOT NULL COMMENT '【输入】招聘页URL',
|
||
`output_selector` VARCHAR(512) DEFAULT NULL COMMENT '【输出】岗位项CSS选择器',
|
||
`output_change_type` VARCHAR(32) DEFAULT NULL COMMENT '【输出】点击变化类型: redirect=跳转, new_tab=新标签, in_page=页内变化',
|
||
`output_item_count` INT DEFAULT NULL COMMENT '【输出】匹配到的岗位数量',
|
||
`error_message` TEXT DEFAULT NULL COMMENT '错误信息',
|
||
`started_at` DATETIME DEFAULT NULL COMMENT '任务开始执行时间',
|
||
`finished_at` DATETIME DEFAULT NULL COMMENT '任务完成时间',
|
||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||
PRIMARY KEY (`id`),
|
||
INDEX `idx_crawl_task_id` (`crawl_task_id`),
|
||
INDEX `idx_status` (`status`)
|
||
) ENGINE = InnoDB
|
||
DEFAULT CHARSET = utf8mb4 COMMENT = 'Step2-岗位列表分析任务表';
|
||
|
||
-- 业务规则:
|
||
-- 1. 由 Step1 成功后自动创建,input_url 来自 app_task_search.output_url
|
||
-- 2. 定时任务扫描 status='pending' 的记录执行列表页分析,修改 app_crawl_task.config_step=2
|
||
-- 3. 执行成功后 status='success',输出岗位选择器、点击变化类型、匹配数量 app_crawl_task.config_status=success
|
||
-- 4. 执行失败 retry_count+1,达到 max_retry 后 status='failed',修改 app_crawl_task.config_status='failed'
|
||
-- 5. 成功后自动创建 app_task_next_page 任务(Step3
|
||
```
|
||
|
||
|
||
|
||
|
||
### Step 3:app_task_next_page(分页分析)
|
||
|
||
```sql
|
||
CREATE TABLE `app_task_next_page`
|
||
(
|
||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||
`crawl_task_id` BIGINT NOT NULL COMMENT '关联主表ID',
|
||
`status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '任务状态: pending=待执行, running=执行中, success=成功, failed=失败',
|
||
`retry_count` INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
|
||
`max_retry` INT NOT NULL DEFAULT 3 COMMENT '最大重试次数',
|
||
`input_url` VARCHAR(1024) NOT NULL COMMENT '【输入】招聘页URL',
|
||
`output_selector` VARCHAR(512) DEFAULT NULL COMMENT '【输出】下一页按钮选择器',
|
||
`output_change_type` VARCHAR(32) DEFAULT NULL COMMENT '【输出】翻页变化类型: url_change=URL变化, content_change=内容变化, new_tab=新页面',
|
||
`has_pagination` TINYINT(1) DEFAULT NULL COMMENT '是否有分页: 0=仅一页, 1=有分页',
|
||
`error_message` TEXT DEFAULT NULL COMMENT '错误信息',
|
||
`started_at` DATETIME DEFAULT NULL COMMENT '任务开始执行时间',
|
||
`finished_at` DATETIME DEFAULT NULL COMMENT '任务完成时间',
|
||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||
PRIMARY KEY (`id`),
|
||
INDEX `idx_crawl_task_id` (`crawl_task_id`),
|
||
INDEX `idx_status` (`status`)
|
||
) ENGINE = InnoDB
|
||
DEFAULT CHARSET = utf8mb4 COMMENT = 'Step3-分页分析任务表';
|
||
|
||
-- 业务规则:
|
||
-- 1. 由 Step2 成功后自动创建,input_url 来自 app_task_page_analysis.input_url
|
||
-- 2. 定时任务扫描 status='pending' 的记录执行分页分析,修改 app_crawl_task.config_step=3
|
||
-- 3. 执行成功后 status='success',输出下一页选择器、翻页变化类型、是否有分页
|
||
-- 4. 执行失败 retry_count+1,达到 max_retry 后 status='failed',修改 app_crawl_task.config_status='failed'
|
||
-- 5. 成功后自动创建 app_task_detail_analysis 任务(Step4)
|
||
```
|
||
|
||
|
||
|
||
|
||
### Step 4:app_task_detail_analysis(详情页分析)
|
||
|
||
```sql
|
||
CREATE TABLE `app_task_detail_analysis`
|
||
(
|
||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||
`crawl_task_id` BIGINT NOT NULL COMMENT '关联主表ID',
|
||
`status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '任务状态: pending=待执行, running=执行中, success=成功, failed=失败',
|
||
`retry_count` INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
|
||
`max_retry` INT NOT NULL DEFAULT 3 COMMENT '最大重试次数',
|
||
`input_url` VARCHAR(1024) NOT NULL COMMENT '【输入】招聘页URL',
|
||
`input_job_selector` VARCHAR(512) NOT NULL COMMENT '【输入】岗位项选择器(来自Step2)',
|
||
`input_change_type` VARCHAR(32) NOT NULL COMMENT '【输入】点击变化类型(来自Step2): redirect=跳转, new_tab=新标签, in_page=页内变化',
|
||
`output_detail_selector` VARCHAR(512) DEFAULT NULL COMMENT '【输出】详情区域选择器(仅in_page时有值)',
|
||
`output_fields` JSON DEFAULT NULL COMMENT '【输出】字段选择器配置JSON, 结构: {"字段名": {"selector": "CSS选择器" 或 ["选择器1","选择器2"], "sample": "示例值"} | null}, 字段: job_title=职位名称, description=岗位描述(数组), salary=薪资, location=地点, company=公司, experience=经验, education=学历',
|
||
`error_message` TEXT DEFAULT NULL COMMENT '错误信息',
|
||
`started_at` DATETIME DEFAULT NULL COMMENT '任务开始执行时间',
|
||
`finished_at` DATETIME DEFAULT NULL COMMENT '任务完成时间',
|
||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||
PRIMARY KEY (`id`),
|
||
INDEX `idx_crawl_task_id` (`crawl_task_id`),
|
||
INDEX `idx_status` (`status`)
|
||
) ENGINE = InnoDB
|
||
DEFAULT CHARSET = utf8mb4 COMMENT = 'Step4-详情页分析任务表';
|
||
|
||
-- 业务规则:
|
||
-- 1. 由 Step3 成功后自动创建,input_url/input_job_selector/input_change_type 来自 Step2/Step3
|
||
-- 2. 定时任务扫描 status='pending' 的记录执行详情页分析,修改 app_crawl_task.config_step=4
|
||
-- 3. 执行成功后 status='success',输出详情区域选择器、字段选择器配置
|
||
-- 4. 执行失败 retry_count+1,达到 max_retry 后 status='failed',修改 app_crawl_task.config_status='failed'
|
||
-- 5. 成功后修改 app_crawl_task.config_status='success',自动创建 app_task_crawl 任务(Step5)
|
||
```
|
||
|
||
### Step 5:app_task_crawl(数据爬取)
|
||
|
||
```sql
|
||
CREATE TABLE `app_task_crawl`
|
||
(
|
||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||
`crawl_task_id` BIGINT NOT NULL COMMENT '关联主表ID',
|
||
`status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '任务状态: pending=待执行, running=执行中, success=成功, failed=失败',
|
||
`retry_count` INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
|
||
`max_retry` INT NOT NULL DEFAULT 3 COMMENT '最大重试次数',
|
||
`input_config` JSON NOT NULL COMMENT '【输入】完整爬取配置JSON, 包含: url=招聘页URL, job_item_selector=岗位项选择器, job_change_type=点击变化类型, next_page_selector=下一页选择器, next_change_type=翻页变化类型, detail_area_selector=详情区域选择器, fields=字段选择器配置',
|
||
`crawled_count` INT DEFAULT NULL COMMENT '【输出】本次爬取数量',
|
||
`error_message` TEXT DEFAULT NULL COMMENT '错误信息',
|
||
`started_at` DATETIME DEFAULT NULL COMMENT '任务开始执行时间',
|
||
`finished_at` DATETIME DEFAULT NULL COMMENT '任务完成时间',
|
||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||
PRIMARY KEY (`id`),
|
||
INDEX `idx_crawl_task_id` (`crawl_task_id`),
|
||
INDEX `idx_status` (`status`)
|
||
) ENGINE = InnoDB
|
||
DEFAULT CHARSET = utf8mb4 COMMENT = 'Step5-数据爬取任务表';
|
||
|
||
-- 业务规则:
|
||
-- 1. 由 Step4 成功后自动创建 / 或者job_periodic_crawl定时任务创建,input_config 汇总 Step1-4 的所有输出配置
|
||
-- 2. 定时任务扫描 status='pending' 的记录执行数据爬取,修改 app_crawl_task.crawl_status='running'
|
||
-- 3. 执行成功后 status='success',爬取的岗位数据存入 app_job_data 表
|
||
-- 4. 执行失败 retry_count+1,达到 max_retry 后 status='failed',修改 app_crawl_task.crawl_status='failed'
|
||
-- 5. 成功后修改 app_crawl_task: crawl_status='success', total_crawl_times+1, last_crawl_at=当前时间
|
||
-- 6. 周期任务:当 last_crawl_at 超过15天时,自动创建新的爬取任务
|
||
```
|
||
|
||
|
||
|
||
|
||
|
||
### 岗位数据表:app_job_data
|
||
|
||
```sql
|
||
CREATE TABLE `app_job_data`
|
||
(
|
||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||
`task_crawl_id` BIGINT NOT NULL COMMENT '关联爬取任务ID',
|
||
`job_title` VARCHAR(255) DEFAULT NULL COMMENT '职位名称',
|
||
`salary` VARCHAR(128) DEFAULT NULL COMMENT '薪资',
|
||
`location` VARCHAR(128) DEFAULT NULL COMMENT '工作地点',
|
||
`company` VARCHAR(255) DEFAULT NULL COMMENT '公司名称',
|
||
`experience` VARCHAR(64) DEFAULT NULL COMMENT '经验要求',
|
||
`education` VARCHAR(64) DEFAULT NULL COMMENT '学历要求',
|
||
`description` TEXT DEFAULT NULL COMMENT '岗位详情(职责+要求+介绍)',
|
||
`detail_url` VARCHAR(1024) DEFAULT NULL COMMENT '详情页URL',
|
||
`content_hash` VARCHAR(64) NOT NULL COMMENT '内容哈希值: job_title+salary+location+company+experience+education+description+detail_url 的MD5,用于查重',
|
||
`sources` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '数据来源: 0=官网, 1=平台',
|
||
`is_independent_url` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否独立URL: 0=页内展示, 1=独立页面',
|
||
`is_valid` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否有效: 0=无效, 1=有效',
|
||
`expire_at` DATETIME DEFAULT NULL COMMENT '有效期',
|
||
`check_status` VARCHAR(32) DEFAULT 'pending' COMMENT '验证状态: pending=待验证, checking=验证中, checked=已验证',
|
||
`last_check_at` DATETIME DEFAULT NULL COMMENT '上次验证时间',
|
||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||
PRIMARY KEY (`id`),
|
||
INDEX `idx_task_crawl_id` (`task_crawl_id`),
|
||
INDEX `idx_content_hash` (`content_hash`),
|
||
INDEX `idx_job_title` (`job_title`),
|
||
INDEX `idx_is_valid` (`is_valid`)
|
||
) ENGINE = InnoDB
|
||
DEFAULT CHARSET = utf8mb4 COMMENT ='岗位数据表';
|
||
|
||
-- 业务规则:
|
||
-- 1. 由 Step5 爬取成功后批量创建,每条岗位数据关联到 task_crawl_id
|
||
-- 2. 插入前查询:相同 content_hash 且 is_valid=1 的记录存在则跳过,否则插入
|
||
-- 3. is_independent_url 根据 Step2 的 change_type 决定:redirect/new_tab=1, in_page=0
|
||
-- 4. 定时任务检查有效性:expire_at 已过期 → is_valid=0
|
||
-- 5. 定时任务检查有效性:访问 detail_url 返回404 → is_valid=0
|
||
-- 6. 查询时默认过滤 is_valid=1 的有效数据
|
||
```
|
||
|
||
|
||
|
||
|
||
---
|
||
|
||
## 定时任务设计
|
||
|
||
### 任务总览
|
||
|
||
| 任务名 | 频率 | 并发 | 说明 |
|
||
|--------|------|------|------|
|
||
| job_step1_search | 1分钟 | 串行 | 搜索招聘页面 |
|
||
| job_step2_page | 1分钟 | 串行 | 分析岗位列表 |
|
||
| job_step3_next | 1分钟 | 串行 | 分析分页 |
|
||
| job_step4_detail | 1分钟 | 串行 | 分析详情页 |
|
||
| job_step5_crawl | 1分钟 | 串行 | 数据爬取 |
|
||
| job_periodic_crawl | 10分钟 | 串行 | 周期爬取 |
|
||
| job_check_validity | 1分钟 | 按域名并发 | 有效性检查 |
|
||
| job_generate_company | 1天 | 单实例 | AI生成公司名 |
|
||
|
||
---
|
||
|
||
### job_step1_search(搜索招聘页面)
|
||
|
||
```
|
||
1. 查询 WHERE status='pending' LIMIT 1
|
||
2. 若无记录 → 结束
|
||
3. 更新 status='running', started_at=NOW()
|
||
4. 调用 search_company_graph 执行搜索
|
||
- 若 input_url 非空:将 input_url 作为 candidate_url 传入,跳过搜索直接验证
|
||
- 若 input_url 为空:正常执行搜索引擎搜索流程
|
||
5. 成功:
|
||
- 更新 status='success', output_url=结果URL, finished_at=NOW()
|
||
- 更新 app_crawl_task: config_status='running', config_step=1
|
||
- 创建 app_task_page_analysis 记录 (input_url=output_url)
|
||
6. 失败:
|
||
- retry_count += 1, error_message=错误信息, finished_at=NOW()
|
||
- 若 retry_count >= max_retry:
|
||
- status='failed'
|
||
- app_crawl_task.config_status='failed'
|
||
- 否则: status='pending' (下次继续重试)
|
||
|
||
异常处理: status='running'卡住的任务需手动重置为'pending'
|
||
```
|
||
|
||
---
|
||
|
||
### job_step2_page(岗位列表分析)
|
||
|
||
```
|
||
1. 查询 WHERE status='pending' LIMIT 1
|
||
2. 若无记录 → 结束
|
||
3. 更新 status='running', started_at=NOW()
|
||
4. 调用 page_analysis_graph(input_url)
|
||
5. 成功:
|
||
- 更新 status='success', finished_at=NOW()
|
||
- 保存 output_selector, output_change_type, output_item_count
|
||
- 更新 app_crawl_task: config_step=2
|
||
- 创建 app_task_next_page 记录 (input_url 来自当前任务)
|
||
6. 失败:
|
||
- retry_count += 1, error_message=错误信息, finished_at=NOW()
|
||
- 若 retry_count >= max_retry:
|
||
- status='failed'
|
||
- app_crawl_task.config_status='failed'
|
||
- 否则: status='pending'
|
||
|
||
异常处理: status='running'卡住的任务需手动重置为'pending'
|
||
```
|
||
|
||
---
|
||
|
||
### job_step3_next(分页分析)
|
||
|
||
```
|
||
1. 查询 WHERE status='pending' LIMIT 1
|
||
2. 若无记录 → 结束
|
||
3. 更新 status='running', started_at=NOW()
|
||
4. 调用 next_page_graph(input_url)
|
||
5. 成功:
|
||
- 更新 status='success', finished_at=NOW()
|
||
- 保存 output_selector, output_change_type, has_pagination
|
||
- 更新 app_crawl_task: config_step=3
|
||
- 通过 crawl_task_id 查询 Step2 输出
|
||
- 创建 app_task_detail_analysis 记录:
|
||
- input_url 来自 Step2
|
||
- input_job_selector 来自 Step2.output_selector
|
||
- input_change_type 来自 Step2.output_change_type
|
||
6. 失败:
|
||
- retry_count += 1, error_message=错误信息, finished_at=NOW()
|
||
- 若 retry_count >= max_retry:
|
||
- status='failed'
|
||
- app_crawl_task.config_status='failed'
|
||
- 否则: status='pending'
|
||
|
||
异常处理: status='running'卡住的任务需手动重置为'pending'
|
||
```
|
||
|
||
---
|
||
|
||
### job_step4_detail(详情页分析)
|
||
|
||
```
|
||
1. 查询 WHERE status='pending' LIMIT 1
|
||
2. 若无记录 → 结束
|
||
3. 更新 status='running', started_at=NOW()
|
||
4. 调用 detail_analysis_graph(input_url, input_job_selector, input_change_type)
|
||
5. 成功:
|
||
- 更新 status='success', finished_at=NOW()
|
||
- 保存 output_detail_selector, output_fields
|
||
- 更新 app_crawl_task: config_step=4, config_status='success'
|
||
- 通过 crawl_task_id 查询 Step1-4 输出,汇总 input_config:
|
||
{
|
||
url, job_item_selector, job_change_type,
|
||
next_page_selector, next_change_type, has_pagination,
|
||
detail_area_selector, fields
|
||
}
|
||
- 创建 app_task_crawl 记录
|
||
- 更新 app_crawl_task: crawl_status='pending'
|
||
6. 失败:
|
||
- retry_count += 1, error_message=错误信息, finished_at=NOW()
|
||
- 若 retry_count >= max_retry:
|
||
- status='failed'
|
||
- app_crawl_task.config_status='failed'
|
||
- 否则: status='pending'
|
||
|
||
异常处理: status='running'卡住的任务需手动重置为'pending'
|
||
```
|
||
|
||
---
|
||
|
||
### job_step5_crawl(数据爬取)
|
||
|
||
```
|
||
1. 查询 WHERE status='pending' LIMIT 1
|
||
2. 若无记录 → 结束
|
||
3. 更新 status='running', started_at=NOW()
|
||
4. 更新 app_crawl_task: crawl_status='running'
|
||
5. 调用 crawler(input_config)
|
||
6. 成功:
|
||
- 更新 status='success', crawled_count=爬取数量, finished_at=NOW()
|
||
- 批量插入 app_job_data:
|
||
- 插入前查询: 相同 content_hash 且 is_valid=1 的记录存在则跳过
|
||
- 不存在则插入, expire_at=NOW()+15天
|
||
- 更新 app_crawl_task:
|
||
- crawl_status='success'
|
||
- total_crawl_times += 1
|
||
- last_crawl_at = NOW()
|
||
7. 失败:
|
||
- retry_count += 1, error_message=错误信息, finished_at=NOW()
|
||
- 若 retry_count >= max_retry:
|
||
- status='failed'
|
||
- app_crawl_task.crawl_status='failed'
|
||
- 否则: status='pending' (主表 crawl_status 保持 running)
|
||
|
||
异常处理: status='running'卡住的任务需手动重置为'pending'
|
||
```
|
||
|
||
---
|
||
|
||
### job_periodic_crawl(周期爬取)
|
||
|
||
```
|
||
1. 查询 app_crawl_task WHERE
|
||
crawl_status='success'
|
||
AND last_crawl_at < DATE_SUB(NOW(), INTERVAL 15 DAY)
|
||
2. 遍历每条记录:
|
||
- 更新 crawl_status='pending'
|
||
- 通过 crawl_task_id 查询 Step1-4 的输出,汇总 input_config
|
||
- 创建 app_task_crawl 记录
|
||
```
|
||
|
||
---
|
||
|
||
### job_check_validity(有效性检查)
|
||
|
||
```
|
||
1. 两次查询合并(避免OR影响索引):
|
||
a. 正常待验证: WHERE is_valid=1 AND check_status='pending' AND expire_at <= NOW() LIMIT 20
|
||
b. 超时恢复: WHERE is_valid=1 AND check_status='checking' AND last_check_at < NOW() - 2小时 LIMIT 20
|
||
代码层合并结果取前 20 条
|
||
2. 若无记录 → 结束
|
||
3. 批量更新 check_status='checking', last_check_at=NOW()
|
||
4. 分流处理:
|
||
A. is_independent_url=0 (页内展示):
|
||
- 直接过期: is_valid=0
|
||
- 更新 check_status='pending'
|
||
|
||
B. is_independent_url=1 (独立页面):
|
||
- 按 detail_url 域名分组
|
||
- 不同域名并发,同域名串行验证
|
||
- GET 访问 detail_url:
|
||
- 状态码 200 → expire_at = NOW() + 配置天数
|
||
- 其他(超时/404/非200等) → is_valid=0
|
||
- 每条完成后更新 check_status='pending'
|
||
```
|
||
|
||
---
|
||
|
||
### job_generate_company(AI生成公司名)
|
||
|
||
**目的**: 使用 AI 自动生成可能有官网招聘信息的公司名称,自动创建爬取任务
|
||
|
||
**模块位置**: `src/company_generator/`
|
||
|
||
**配置参数**:
|
||
- `max_company_count`: 公司总数上限,默认 5000
|
||
- `generate_company_count`: 每次生成数量,默认 10
|
||
- `job_generate_company_interval`: 执行间隔,默认 86400秒(1天)
|
||
|
||
**执行流程**:
|
||
```
|
||
1. 检查公司总数
|
||
SELECT COUNT(*) FROM app_crawl_task
|
||
若 >= max_company_count(5000) → 结束
|
||
|
||
2. 查询所有已有公司名 → 存入内存 set
|
||
|
||
3. 随机抽样 500 个传给 LLM
|
||
- 使用 V3_2 模型
|
||
- with_structured_output(CompanyList) 结构化输出
|
||
- 生成 10 个公司名
|
||
|
||
4. 遍历生成结果
|
||
- 长度校验(2-15字符)→ 不符则跳过
|
||
- 内存 set 判重 → 已存在则跳过
|
||
- INSERT app_crawl_task (company_name)
|
||
- INSERT app_task_search (crawl_task_id, input_company_name)
|
||
- 数据库唯一约束兆底
|
||
|
||
5. 返回结果
|
||
{ inserted_count, skipped_count, inserted_companies }
|
||
```
|
||
|
||
**公司名命名规范**:
|
||
- 使用公司常用简称(如"华为"而非"华为技术有限公司")
|
||
- 不加"集团"、"有限公司"、"股份有限公司"等后缀
|
||
- 不加"(中国)"、"(北京)"等地域标注
|
||
- 2-15个字符
|
||
|
||
**去重策略**:
|
||
1. Prompt 传递已有公司名(随机抽样 500 个)
|
||
2. 代码层内存 set 判重(全量)
|
||
3. 数据库唯一约束兆底
|
||
|
||
---
|
||
|
||
### 任务执行流程图
|
||
|
||
```
|
||
┌─────────────────┐
|
||
│ 用户创建任务 │
|
||
│ (输入公司名称) │
|
||
└────────┬────────┘
|
||
│
|
||
▼
|
||
┌────────────────────────────────────────────────────────────────┐
|
||
│ job_step1_search (每1分钟) │
|
||
│ 查询: app_task_search WHERE status='pending' │
|
||
│ 执行: 搜索招聘页面URL │
|
||
│ 成功: status='success', 创建Step2任务 │
|
||
└────────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌────────────────────────────────────────────────────────────────┐
|
||
│ job_step2_page (每1分钟) │
|
||
│ 查询: app_task_page_analysis WHERE status='pending' │
|
||
│ 执行: 分析岗位列表选择器 │
|
||
│ 成功: status='success', 创建Step3任务 │
|
||
└────────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌────────────────────────────────────────────────────────────────┐
|
||
│ job_step3_next (每1分钟) │
|
||
│ 查询: app_task_next_page WHERE status='pending' │
|
||
│ 执行: 分析分页选择器 │
|
||
│ 成功: status='success', 创建Step4任务 │
|
||
└────────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌────────────────────────────────────────────────────────────────┐
|
||
│ job_step4_detail (每1分钟) │
|
||
│ 查询: app_task_detail_analysis WHERE status='pending' │
|
||
│ 执行: 分析详情页字段选择器 │
|
||
│ 成功: config_status='success', 创建Step5任务 │
|
||
└────────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌────────────────────────────────────────────────────────────────┐
|
||
│ job_step5_crawl (每1分钟) │
|
||
│ 查询: app_task_crawl WHERE status='pending' │
|
||
│ 执行: 爬取岗位数据,存入app_job_data │
|
||
│ 成功: crawl_status='success', total_crawl_times+1 │
|
||
└────────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────┐
|
||
│ 爬取完成 │
|
||
└────────┬────────┘
|
||
│
|
||
┌──────────────────┴──────────────────┐
|
||
▼ ▼
|
||
┌──────────────────────┐ ┌──────────────────────┐
|
||
│ job_periodic_crawl │ │ job_check_validity │
|
||
│ (每10分钟) │ │ (每1分钟) │
|
||
│ 检查last_crawl_at │ │ 检查expire_at已过期 │
|
||
│ 超过N天则重新爬取 │ │ 按域名并发验证URL │
|
||
└──────────────────────┘ └──────────────────────┘
|
||
```
|