Files
post_crawler/docs/PROJECT.md
T
2026-05-26 21:02:17 +08:00

937 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 1app_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 2app_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 3app_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 4app_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 5app_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_companyAI生成公司名)
**目的**: 使用 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 │
└──────────────────────┘ └──────────────────────┘
```