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

43 KiB
Raw Blame History

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

输入/输出:

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找可点击元素 → 点击验证能否进入详情

选择器优先级: 岗位名称链接 > 申请/查看按钮 > 详情图标 > 整个岗位卡片

输入/输出:

# 输入
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找下一页按钮 → 点击验证页面变化

输入/输出:

# 输入
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_titledescription 是核心字段,必须提取
  • description 包含完整岗位信息(职责、要求、公司介绍等都放这里)
  • 其他字段(salary/location/company/experience/education)只有在页面上有独立、明确的元素时才提取,否则为 null

输入/输出:

# 输入
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

# 输出 (失败)
{
    "status": "failed",
    "error": "验证失败,无法提取有效字段"  # 失败原因
}

5. crawler - 数据爬取 (待开发)

目的: 使用分析得到的选择器配置,自动爬取所有岗位数据

预期流程:

  1. 打开列表页
  2. 使用 job_item_selector 获取当前页所有岗位
  3. 逐个点击进入详情页,用 field_selectors 提取数据
  4. 使用 next_page_selector 翻页
  5. 重复直到无下一页

预期输入/输出:

# 输入
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://
  • 无域名或域名无点 → 无效

输入/输出:

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_urlStep1 执行时检测到 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(爬虫任务主表)

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(搜索招聘页面)

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(岗位列表分析)

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(分页分析)

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(详情页分析)

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(数据爬取)

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

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