generated from kgod/ai-review-template
提交
This commit is contained in:
+936
@@ -0,0 +1,936 @@
|
||||
# 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 │
|
||||
└──────────────────────┘ └──────────────────────┘
|
||||
```
|
||||
@@ -0,0 +1,356 @@
|
||||
# Crawler 模块设计方案
|
||||
|
||||
## 状态
|
||||
✅ 已完成
|
||||
|
||||
---
|
||||
|
||||
## 一、输入参数
|
||||
|
||||
| 参数 | 来源 | 说明 |
|
||||
|-----|------|-----|
|
||||
| url | 初始输入 | 列表页 URL |
|
||||
| job_item_selector | Step 2 | 岗位项选择器 |
|
||||
| item_change_type | Step 2 | redirect / new_tab / in_page |
|
||||
| next_page_selector | Step 3 | 下一页按钮选择器(None = 无分页) |
|
||||
| page_change_type | Step 3 | url_change / content_change / new_tab |
|
||||
| field_selectors | Step 4 | 字段选择器字典,selector 为数组格式 |
|
||||
| detail_area_selector | Step 4 | 详情区域选择器(仅 in_page 有值) |
|
||||
|
||||
---
|
||||
|
||||
## 二、限制条件
|
||||
|
||||
- 最大页数:5 页
|
||||
- 单个岗位失败:跳过继续
|
||||
- 超时时间:可配置(如 10 秒)
|
||||
|
||||
---
|
||||
|
||||
## 三、数据提取范围
|
||||
|
||||
| item_change_type | 提取范围 | detail_url 记录 |
|
||||
|-----------------|---------|---------------|
|
||||
| redirect | 跳转后的详情页整页 | 详情页 URL |
|
||||
| new_tab | 新标签详情页整页 | 新标签页 URL |
|
||||
| in_page | detail_area_selector 内部 | 列表页 URL |
|
||||
|
||||
---
|
||||
|
||||
## 四、入口逻辑
|
||||
|
||||
```
|
||||
results = []
|
||||
|
||||
IF item_change_type == "redirect":
|
||||
results = 流程A_redirect()
|
||||
ELSE IF item_change_type == "new_tab":
|
||||
results = 流程B_new_tab()
|
||||
ELSE IF item_change_type == "in_page":
|
||||
results = 流程C_in_page()
|
||||
|
||||
RETURN results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、流程 A:redirect
|
||||
|
||||
**特点**:点击岗位后整页跳转,返回后状态丢失,需重新打开并翻页恢复
|
||||
|
||||
```
|
||||
初始化:
|
||||
page_index = 1
|
||||
item_index = 1
|
||||
results = []
|
||||
|
||||
打开 url
|
||||
等待加载完成
|
||||
items_per_page = 用 job_item_selector 计算当前页岗位数
|
||||
|
||||
主循环:
|
||||
WHILE True:
|
||||
|
||||
# ===== 1. 检查是否结束 =====
|
||||
IF items_per_page == 0:
|
||||
BREAK
|
||||
IF page_index > 5:
|
||||
BREAK
|
||||
|
||||
# ===== 2. 处理当前岗位 =====
|
||||
TRY:
|
||||
elements = 获取所有岗位元素(job_item_selector)
|
||||
点击 elements[item_index - 1]
|
||||
等待页面跳转完成(URL 变化 或 networkidle)
|
||||
|
||||
data = 在整页范围用 field_selectors 提取数据
|
||||
data["detail_url"] = 当前页面 URL
|
||||
results.append(data)
|
||||
CATCH:
|
||||
记录失败,跳过
|
||||
|
||||
# ===== 3. 计算下一个位置 =====
|
||||
item_index += 1
|
||||
|
||||
IF item_index > items_per_page:
|
||||
page_index += 1
|
||||
item_index = 1
|
||||
|
||||
IF page_index > 5:
|
||||
BREAK
|
||||
IF next_page_selector == None:
|
||||
BREAK
|
||||
|
||||
# ===== 4. 重新打开并恢复到目标页 =====
|
||||
goto(url)
|
||||
等待加载完成
|
||||
|
||||
FOR i = 1 TO (page_index - 1):
|
||||
success, page = click_next_page(page)
|
||||
IF not success:
|
||||
BREAK 主循环
|
||||
|
||||
items_per_page = 重新计算当前页岗位数
|
||||
|
||||
RETURN results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、流程 B:new_tab
|
||||
|
||||
**特点**:点击后新标签打开详情,关闭新标签后原页面状态保持
|
||||
|
||||
```
|
||||
初始化:
|
||||
page_index = 1
|
||||
results = []
|
||||
|
||||
打开 url
|
||||
等待加载完成
|
||||
|
||||
主循环:
|
||||
WHILE page_index <= 5:
|
||||
|
||||
# ===== 1. 获取当前页岗位 =====
|
||||
items_per_page = 用 job_item_selector 计算当前页岗位数
|
||||
IF items_per_page == 0:
|
||||
BREAK
|
||||
|
||||
# ===== 2. 遍历当前页所有岗位 =====
|
||||
FOR item_index = 1 TO items_per_page:
|
||||
TRY:
|
||||
elements = 获取所有岗位元素(job_item_selector)
|
||||
|
||||
开始监听 context 的 "page" 事件
|
||||
点击 elements[item_index - 1]
|
||||
|
||||
等待新标签页打开(超时则跳过)
|
||||
new_page = 获取新打开的标签页
|
||||
等待 new_page 加载完成
|
||||
|
||||
data = 在 new_page 整页范围用 field_selectors 提取数据
|
||||
data["detail_url"] = new_page.url
|
||||
results.append(data)
|
||||
|
||||
关闭 new_page
|
||||
CATCH:
|
||||
尝试关闭可能存在的新标签
|
||||
记录失败,跳过
|
||||
|
||||
# ===== 3. 翻页 =====
|
||||
IF next_page_selector == None:
|
||||
BREAK
|
||||
|
||||
success, page = click_next_page(page)
|
||||
IF not success:
|
||||
BREAK
|
||||
|
||||
page_index += 1
|
||||
|
||||
RETURN results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、流程 C:in_page
|
||||
|
||||
**特点**:点击后弹窗/详情区展示,通过刷新页面恢复状态,兼容性更强
|
||||
|
||||
```
|
||||
初始化:
|
||||
page_index = 1
|
||||
item_index = 1
|
||||
results = []
|
||||
|
||||
打开 url
|
||||
等待加载完成
|
||||
items_per_page = 用 job_item_selector 计算当前页岗位数
|
||||
|
||||
主循环:
|
||||
WHILE True:
|
||||
|
||||
# ===== 1. 检查是否结束 =====
|
||||
IF items_per_page == 0:
|
||||
BREAK
|
||||
IF page_index > 5:
|
||||
BREAK
|
||||
|
||||
# ===== 2. 处理当前岗位 =====
|
||||
TRY:
|
||||
elements = 获取所有岗位元素(job_item_selector)
|
||||
点击 elements[item_index - 1]
|
||||
等待 detail_area_selector 出现(visible)
|
||||
|
||||
detail_element = 获取 detail_area_selector 元素
|
||||
data = 在 detail_element 内部用 field_selectors 提取数据
|
||||
data["detail_url"] = 当前页面 URL
|
||||
results.append(data)
|
||||
CATCH:
|
||||
记录失败,跳过
|
||||
|
||||
# ===== 3. 计算下一个位置 =====
|
||||
item_index += 1
|
||||
|
||||
IF item_index > items_per_page:
|
||||
page_index += 1
|
||||
item_index = 1
|
||||
|
||||
IF page_index > 5:
|
||||
BREAK
|
||||
IF next_page_selector == None:
|
||||
BREAK
|
||||
|
||||
# ===== 4. 刷新页面恢复状态 =====
|
||||
goto(url)
|
||||
等待加载完成
|
||||
|
||||
FOR i = 1 TO (page_index - 1):
|
||||
success, page = click_next_page(page)
|
||||
IF not success:
|
||||
BREAK 主循环
|
||||
|
||||
items_per_page = 重新计算当前页岗位数
|
||||
|
||||
RETURN results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、统一翻页函数
|
||||
|
||||
**返回值**:(success: bool, page: Page) —— new_tab 模式时 page 引用会变
|
||||
|
||||
```
|
||||
函数 click_next_page(page, next_page_selector, page_change_type):
|
||||
|
||||
# ===== 1. 检查按钮是否可点 =====
|
||||
element = 查找 next_page_selector
|
||||
|
||||
IF element 不存在:
|
||||
RETURN (false, page)
|
||||
|
||||
IF element 有 disabled 属性:
|
||||
RETURN (false, page)
|
||||
|
||||
IF element 的 class 包含 "disabled":
|
||||
RETURN (false, page)
|
||||
|
||||
IF element 不可见 (is_visible == false):
|
||||
RETURN (false, page)
|
||||
|
||||
# ===== 2. 根据 page_change_type 处理 =====
|
||||
|
||||
IF page_change_type == "url_change":
|
||||
before_url = page.url
|
||||
点击 element
|
||||
等待 URL 变化(page.url != before_url)
|
||||
等待加载完成(networkidle)
|
||||
RETURN (true, page)
|
||||
|
||||
ELSE IF page_change_type == "content_change":
|
||||
before_text = 获取第一个岗位的文本内容
|
||||
点击 element
|
||||
等待第一个岗位文本变化(!= before_text)
|
||||
短暂等待确保渲染完成
|
||||
RETURN (true, page)
|
||||
|
||||
ELSE IF page_change_type == "new_tab":
|
||||
开始监听 context 的 "page" 事件
|
||||
点击 element
|
||||
等待新标签页打开
|
||||
new_page = 获取新标签页
|
||||
等待 new_page 加载完成
|
||||
关闭原 page
|
||||
RETURN (true, new_page) # 返回新的 page 引用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、数据提取函数
|
||||
|
||||
```
|
||||
函数 extract_data(scope, field_selectors):
|
||||
# scope: 整个 page 或 detail_element
|
||||
|
||||
data = {}
|
||||
|
||||
FOR field_name, selector_info IN field_selectors:
|
||||
TRY:
|
||||
selectors = selector_info.selector # 数组格式
|
||||
IF selectors 为空数组:
|
||||
data[field_name] = None
|
||||
CONTINUE
|
||||
|
||||
# 遍历所有选择器,提取文本并拼接
|
||||
texts = []
|
||||
FOR selector IN selectors:
|
||||
element = scope.query_selector(selector)
|
||||
IF element:
|
||||
text = element.inner_text().strip()
|
||||
IF text:
|
||||
texts.append(text)
|
||||
|
||||
data[field_name] = "\n".join(texts) IF texts ELSE None
|
||||
CATCH:
|
||||
data[field_name] = None
|
||||
|
||||
RETURN data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、字段选择器格式
|
||||
|
||||
`field_selectors` 统一使用数组格式:
|
||||
|
||||
```
|
||||
{
|
||||
"job_title": {"selector": ["h1.title"], "sample": "Python工程师"},
|
||||
"description": {"selector": [".desc", ".req"], "sample": "负责...\n要求..."},
|
||||
"salary": {"selector": [".salary"], "sample": "25-40K"},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
- 单选择器:`["h1.title"]`
|
||||
- 多选择器:`[".desc", ".req"]`,提取结果用 `\n` 拼接
|
||||
|
||||
---
|
||||
|
||||
## 十一、输出格式
|
||||
|
||||
```
|
||||
[
|
||||
{
|
||||
"job_title": "...",
|
||||
"salary": "...",
|
||||
"location": "...",
|
||||
"description": "...",
|
||||
"requirements": "...",
|
||||
"detail_url": "...",
|
||||
...
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
@@ -0,0 +1,307 @@
|
||||
# Playwright Python 使用指南
|
||||
|
||||
本文档覆盖项目中会用到的 Playwright 核心功能。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
pip install playwright
|
||||
playwright install chromium # 安装浏览器
|
||||
```
|
||||
|
||||
## 基本结构
|
||||
|
||||
```python
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
async def main():
|
||||
async with async_playwright() as p:
|
||||
# 启动浏览器
|
||||
browser = await p.chromium.launch(headless=False) # False 可看到浏览器界面
|
||||
|
||||
# 创建页面
|
||||
page = await browser.new_page()
|
||||
|
||||
# 操作页面...
|
||||
|
||||
# 关闭
|
||||
await browser.close()
|
||||
```
|
||||
|
||||
## 核心操作
|
||||
|
||||
### 1. 导航
|
||||
|
||||
```python
|
||||
# 访问 URL
|
||||
await page.goto("https://example.com")
|
||||
|
||||
# 等待加载完成(可选策略)
|
||||
await page.goto("https://example.com", wait_until="networkidle") # 网络空闲
|
||||
await page.goto("https://example.com", wait_until="domcontentloaded") # DOM 加载完成
|
||||
|
||||
# 获取当前 URL
|
||||
current_url = page.url
|
||||
```
|
||||
|
||||
### 2. 获取内容
|
||||
|
||||
```python
|
||||
# 获取整个页面 HTML
|
||||
html = await page.content()
|
||||
|
||||
# 获取 body 内部 HTML
|
||||
body_html = await page.inner_html("body")
|
||||
|
||||
# 获取元素内部 HTML
|
||||
area_html = await page.inner_html(".job-list")
|
||||
|
||||
# 获取元素外部 HTML(包含自身标签)
|
||||
outer = await page.evaluate("document.querySelector('.job-item').outerHTML")
|
||||
|
||||
# 获取文本内容
|
||||
text = await page.inner_text(".title")
|
||||
|
||||
# 获取页面标题
|
||||
title = await page.title()
|
||||
```
|
||||
|
||||
### 3. 选择器与元素定位
|
||||
|
||||
```python
|
||||
# 单个元素
|
||||
element = await page.query_selector(".job-item") # 返回 ElementHandle 或 None
|
||||
|
||||
# 多个元素
|
||||
elements = await page.query_selector_all(".job-item") # 返回列表
|
||||
count = len(elements)
|
||||
|
||||
# 检查元素是否存在
|
||||
if await page.query_selector(".job-item"):
|
||||
print("存在")
|
||||
|
||||
# Locator API(推荐,更稳定)
|
||||
locator = page.locator(".job-item")
|
||||
count = await locator.count()
|
||||
first = locator.first
|
||||
nth = locator.nth(2) # 第3个元素
|
||||
```
|
||||
|
||||
### 4. 点击与交互
|
||||
|
||||
```python
|
||||
# 点击元素
|
||||
await page.click(".job-item")
|
||||
|
||||
# 点击第一个匹配的元素
|
||||
await page.locator(".job-item").first.click()
|
||||
|
||||
# 点击第 N 个元素
|
||||
await page.locator(".job-item").nth(0).click()
|
||||
|
||||
# 带等待的点击
|
||||
await page.click(".job-item", timeout=5000) # 最多等 5 秒
|
||||
|
||||
# 输入文本
|
||||
await page.fill("input[name='search']", "关键词")
|
||||
|
||||
# 按键
|
||||
await page.keyboard.press("Enter")
|
||||
```
|
||||
|
||||
### 5. 等待
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
# 简单等待(秒)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 等待选择器出现
|
||||
await page.wait_for_selector(".job-list", timeout=10000)
|
||||
|
||||
# 等待选择器消失
|
||||
await page.wait_for_selector(".loading", state="hidden")
|
||||
|
||||
# 等待导航完成
|
||||
async with page.expect_navigation():
|
||||
await page.click(".next-page")
|
||||
|
||||
# 等待网络空闲
|
||||
await page.wait_for_load_state("networkidle")
|
||||
```
|
||||
|
||||
### 6. 执行 JavaScript
|
||||
|
||||
```python
|
||||
# 简单表达式
|
||||
result = await page.evaluate("document.title")
|
||||
|
||||
# 带参数
|
||||
selector = ".job-item"
|
||||
count = await page.evaluate(f"document.querySelectorAll('{selector}').length")
|
||||
|
||||
# 复杂逻辑
|
||||
result = await page.evaluate("""
|
||||
() => {
|
||||
const items = document.querySelectorAll('.job-item');
|
||||
return Array.from(items).map(el => el.outerHTML);
|
||||
}
|
||||
""")
|
||||
|
||||
# 在元素上执行
|
||||
element = await page.query_selector(".job-item")
|
||||
html = await element.evaluate("el => el.outerHTML")
|
||||
```
|
||||
|
||||
### 7. 截图
|
||||
|
||||
```python
|
||||
# 整页截图
|
||||
await page.screenshot(path="screenshot.png")
|
||||
|
||||
# 元素截图
|
||||
element = await page.query_selector(".job-list")
|
||||
await element.screenshot(path="element.png")
|
||||
|
||||
# 全页面(包括滚动区域)
|
||||
await page.screenshot(path="full.png", full_page=True)
|
||||
```
|
||||
|
||||
## 常用模式
|
||||
|
||||
### 模式1:获取多个元素的 HTML
|
||||
|
||||
```python
|
||||
# 方法1:evaluate
|
||||
htmls = await page.evaluate("""
|
||||
selector => {
|
||||
const items = document.querySelectorAll(selector);
|
||||
return Array.from(items).slice(0, 3).map(el => el.outerHTML);
|
||||
}
|
||||
""", ".job-item")
|
||||
|
||||
# 方法2:遍历 ElementHandle
|
||||
elements = await page.query_selector_all(".job-item")
|
||||
htmls = []
|
||||
for el in elements[:3]:
|
||||
html = await el.evaluate("el => el.outerHTML")
|
||||
htmls.append(html)
|
||||
```
|
||||
|
||||
### 模式2:检测页面变化
|
||||
|
||||
```python
|
||||
before_url = page.url
|
||||
|
||||
await page.click(".job-item")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
after_url = page.url
|
||||
|
||||
if after_url != before_url:
|
||||
print("发生了跳转")
|
||||
```
|
||||
|
||||
### 模式3:带重试的选择器验证
|
||||
|
||||
```python
|
||||
async def validate_selector(page, selector: str) -> int:
|
||||
"""验证选择器,返回匹配数量"""
|
||||
try:
|
||||
elements = await page.query_selector_all(selector)
|
||||
return len(elements)
|
||||
except Exception:
|
||||
return 0
|
||||
```
|
||||
|
||||
### 模式4:安全获取元素内容
|
||||
|
||||
```python
|
||||
async def safe_inner_html(page, selector: str) -> str | None:
|
||||
"""安全获取元素 innerHTML"""
|
||||
element = await page.query_selector(selector)
|
||||
if element:
|
||||
return await element.inner_html()
|
||||
return None
|
||||
```
|
||||
|
||||
## 浏览器配置
|
||||
|
||||
```python
|
||||
# 有头模式(可见)
|
||||
browser = await p.chromium.launch(headless=False)
|
||||
|
||||
# 无头模式(后台运行)
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
|
||||
# 慢动作(调试用)
|
||||
browser = await p.chromium.launch(headless=False, slow_mo=500) # 每步慢 500ms
|
||||
|
||||
# 设置窗口大小
|
||||
context = await browser.new_context(viewport={"width": 1280, "height": 720})
|
||||
page = await context.new_page()
|
||||
|
||||
# 设置 User-Agent
|
||||
context = await browser.new_context(
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
)
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
```python
|
||||
from playwright.async_api import TimeoutError as PlaywrightTimeout
|
||||
|
||||
try:
|
||||
await page.click(".not-exist", timeout=3000)
|
||||
except PlaywrightTimeout:
|
||||
print("元素未找到")
|
||||
except Exception as e:
|
||||
print(f"其他错误: {e}")
|
||||
```
|
||||
|
||||
## 项目中的典型用法
|
||||
|
||||
```python
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
async def analyze_page(url: str):
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=False)
|
||||
page = await browser.new_page()
|
||||
|
||||
try:
|
||||
# 1. 访问页面
|
||||
await page.goto(url, wait_until="networkidle")
|
||||
|
||||
# 2. 获取 HTML
|
||||
html = await page.inner_html("body")
|
||||
|
||||
# 3. 验证选择器
|
||||
selector = ".job-item"
|
||||
elements = await page.query_selector_all(selector)
|
||||
print(f"匹配到 {len(elements)} 个元素")
|
||||
|
||||
# 4. 点击验证
|
||||
if elements:
|
||||
before_url = page.url
|
||||
await elements[0].click()
|
||||
await asyncio.sleep(2)
|
||||
after_url = page.url
|
||||
print(f"跳转: {before_url} -> {after_url}")
|
||||
|
||||
finally:
|
||||
await browser.close()
|
||||
```
|
||||
|
||||
## 与 MCP 对比
|
||||
|
||||
| 操作 | MCP 方式 | 直接 Playwright |
|
||||
|------|----------|-----------------|
|
||||
| 导航 | `await call_tool(tools, "browser_navigate", url=url)` | `await page.goto(url)` |
|
||||
| 获取 HTML | `await call_tool(tools, "browser_evaluate", function="document.body.innerHTML")` | `await page.inner_html("body")` |
|
||||
| 点击 | `await call_tool(tools, "browser_evaluate", function="document.querySelector('.x').click()")` | `await page.click(".x")` |
|
||||
| 获取 URL | `await call_tool(tools, "browser_evaluate", function="window.location.href")` | `page.url` |
|
||||
|
||||
直接用 Playwright 代码更简洁、更 Pythonic。
|
||||
Reference in New Issue
Block a user