# 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 │ └──────────────────────┘ └──────────────────────┘ ```