This commit is contained in:
kgod
2026-05-26 21:02:17 +08:00
commit 8697477a53
10000 changed files with 1541403 additions and 0 deletions
+936
View File
@@ -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 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 │
└──────────────────────┘ └──────────────────────┘
```
+356
View File
@@ -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
```
---
## 五、流程 Aredirect
**特点**:点击岗位后整页跳转,返回后状态丢失,需重新打开并翻页恢复
```
初始化:
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
```
---
## 六、流程 Bnew_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
```
---
## 七、流程 Cin_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": "...",
...
},
...
]
```
+307
View File
@@ -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
# 方法1evaluate
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。