在实际求职和招聘场景中,很多优秀的职位并不一定发布在大型招聘平台上,而是隐藏在公司官网的“加入我们”或“职业发展”页面。对于求职者而言,每天手动检查上千家公司的官网是不现实的;对于招聘方而言,如何让自己的职位被更多主动的求职者发现也是一个挑战。一个能够自动、持续地从大量公司官网抓取职位信息的系统,就成为了连接这两端需求的有效工具。

本文将围绕如何构建一个每日从数千家公司官网抓取职位列表的系统展开。这不是一个简单的爬虫脚本,而是一个需要考虑稳定性、可扩展性、数据质量和维护性的工程实践。我们将从核心概念入手,逐步讲解系统架构设计、关键模块实现、数据存储与去重、以及生产环境下的运维与排错。无论你是想了解大规模网络爬虫的工程化思路,还是希望为自己的项目集成一个可靠的职位信息源,这篇文章都将提供一条清晰的实现路径。

1. 理解大规模职业页面抓取的核心挑战

在动手写代码之前,必须明确我们要解决的问题域及其特殊性。从公司官网抓取职位信息,不同于抓取新闻或商品数据,它面临着一系列独特的挑战。

1.1 数据源的异构性与反爬策略

每家公司的职业页面结构、技术栈和发布方式都截然不同。有的使用标准的HTML列表,有的依赖JavaScript动态渲染(如React、Vue构建的单页应用),有的则通过API接口返回JSON数据。此外,越来越多的公司会部署基础的反爬虫措施,如请求频率限制、User-Agent验证、甚至验证码。

这意味着我们的抓取系统不能依赖单一的解析策略。它必须是一个混合系统,能够根据目标网站的特征,智能地选择最合适的抓取和解析方法。一个常见的架构是“探测-适配”模式:先对目标URL进行轻量级探测,根据响应内容类型(如是否包含特定JS框架标识)和结构特征,动态分发给不同的抓取处理器。

1.2 数据的时效性与更新频率

职位信息具有强时效性。一个“每日抓取”的系统,其核心价值在于能近乎实时地发现新开放的职位和已关闭的职位。这就要求系统不仅要能增量抓取,还要能准确判断一条职位信息是“新增”、“更新”还是“已失效”。

简单的对比全文哈希(如MD5)在页面结构微调时就会失效,导致大量误报。更稳健的做法是提取职位信息的核心字段(如职位ID、标题、部门、地点)生成一个“特征指纹”,基于这个指纹进行比对。同时,需要设计一个合理的调度策略,对高频更新的公司页面提高抓取频率,对低频更新的则降低频率,以节约资源。

1.3 系统的可扩展性与健壮性

“8k company career pages”意味着至少8000个独立的数据源。系统必须能够水平扩展,以并行处理海量任务。同时,单个网站的抓取失败不应导致整个系统阻塞或崩溃。我们需要引入任务队列、分布式调度、失败重试、熔断降级等机制。

健壮性还体现在链接发现上。公司官网的职位列表页可能分页,也可能有多个入口(如按部门、按地点筛选)。抓取系统需要具备一定的链接发现能力,确保能抓取到完整的职位列表,而不是仅抓取第一页。

2. 系统架构设计与技术选型

一个面向生产环境的大规模抓取系统,通常采用模块化、松耦合的设计。以下是推荐的核心架构组件。

2.1 整体架构图(逻辑层面)

[调度中心 Scheduler]
       |
       v
[任务队列 Task Queue] (e.g., Redis, RabbitMQ)
       |
       |--- [爬虫节点 Worker 1] -> [目标网站A]
       |--- [爬虫节点 Worker 2] -> [目标网站B]
       |--- [爬虫节点 Worker N] -> [目标网站N]
       |
       v
[数据清洗与解析 Parser]
       |
       v
[数据存储与去重 Storage & Dedup] (e.g., PostgreSQL, Elasticsearch)
       |
       v
[监控与报警 Monitoring]

各组件职责

  • 调度中心 :管理所有待抓取的公司URL列表,决定下一次抓取的时间和优先级,生成抓取任务投递到队列。
  • 任务队列 :解耦调度器与爬虫节点,实现异步处理和负载均衡。
  • 爬虫节点 :执行实际的HTTP请求,下载页面或API数据。需要实现请求轮换、错误处理。
  • 解析器 :将原始的HTML/JSON数据转换为结构化的职位信息。这部分逻辑可能因网站而异,是系统中最复杂的部分。
  • 存储 :持久化结构化的职位数据,并实现高效的去重和查询。
  • 监控 :跟踪抓取成功率、延迟、数据质量等指标。

2.2 关键技术选型建议

选择技术栈时,需平衡开发效率、性能和生态。

组件 候选技术 选型理由与注意事项
编程语言 Python, Node.js, Go Python 是首选,因为其爬虫生态丰富(Scrapy, Requests, BeautifulSoup, Playwright),开发速度快。 Go 在需要极高并发和性能的场景下是优秀选择。 Node.js 擅长处理高并发I/O和JS渲染。
爬虫框架 Scrapy, 自建基于Requests/Playwright Scrapy :适合规则相对固定的垂直爬虫,内置了队列、去重、管道等机制,开箱即用。 自建框架 :当目标网站差异极大,需要高度定制化的探测、渲染和解析流程时更灵活。
浏览器渲染 Playwright, Puppeteer, Selenium 用于抓取动态渲染的页面。 Playwright 支持多浏览器(Chromium, Firefox, WebKit),API现代,是当前最推荐的选择。
任务队列 Redis (RQ/Celery), RabbitMQ, Apache Kafka Redis + RQ :轻量级,易于部署,适合大多数场景。 Celery :功能更全,但配置更复杂。 Kafka :适用于数据吞吐量极大、需要流式处理的场景。
数据存储 PostgreSQL, Elasticsearch PostgreSQL :关系型数据存储的主力,利用 UNIQUE 约束和 upsert ON CONFLICT UPDATE )可以方便地实现去重和更新。 Elasticsearch :如果后续需要强大的全文搜索和聚合分析功能,可以作为辅助存储或主要存储。
调度器 APScheduler, Celery Beat, 自建Cron APScheduler :纯Python库,可以集成到任何Python应用中,非常灵活。对于分布式调度,可能需要结合数据库存储任务状态。

注意 :不要将所有解析规则硬编码在代码中。建议将“公司域名”与对应的“解析配置”(如CSS选择器、JSON路径)存储在数据库或配置文件中,实现动态加载。这是系统能否轻松扩展到8000个网站的关键。

3. 核心模块实现详解

我们将以Python技术栈为例,构建一个简化但核心流程完整的抓取系统。

3.1 数据模型设计

首先,在 models.py 中定义核心的数据模型。这是系统处理数据的蓝图。

# models.py
from datetime import datetime
from typing import Optional
from sqlalchemy import Column, String, Text, DateTime, Boolean, Index, UniqueConstraint
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()

class Company(Base):
    """公司信息表"""
    __tablename__ = 'companies'
    id = Column(String(64), primary_key=True)  # 例如公司域名
    name = Column(String(255), nullable=False)
    career_page_url = Column(String(2048), nullable=False)
    parser_config = Column(Text)  # 存储该网站解析规则的JSON字符串
    is_active = Column(Boolean, default=True)
    crawl_frequency_hours = Column(Integer, default=24)
    last_crawl_time = Column(DateTime)
    created_at = Column(DateTime, default=datetime.utcnow)

class JobListing(Base):
    """职位信息表"""
    __tablename__ = 'job_listings'
    id = Column(String(255), primary_key=True)  # 全局唯一ID,可由 source+company_id+job_id 生成
    company_id = Column(String(64), nullable=False, index=True)
    source_job_id = Column(String(255))  # 来源网站上的职位ID
    title = Column(String(512), nullable=False)
    department = Column(String(255))
    location = Column(String(512))
    description = Column(Text)
    apply_url = Column(String(2048))
    posted_date = Column(DateTime)
    first_seen_at = Column(DateTime, default=datetime.utcnow, nullable=False)
    last_updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
    is_active = Column(Boolean, default=True, index=True)  # 职位是否仍开放

    # 创建复合索引,用于快速查询某公司的活跃职位
    __table_args__ = (
        Index('idx_company_active', 'company_id', 'is_active'),
        UniqueConstraint('company_id', 'source_job_id', name='uix_company_job_id'),
    )

关键字段解释

  • JobListing.id :使用组合键(如 f"{company_id}:{source_job_id}" )或UUID,确保全局唯一。
  • source_job_id :尽可能从原始页面提取职位ID,这是去重和跟踪同一职位更新的关键。
  • UniqueConstraint :数据库级别的唯一约束,防止同一公司同一职位重复插入。
  • is_active :这是一个“软状态”。我们通过每日抓取来更新它:如果本次抓取发现该职位已不在列表,则将其置为 False

3.2 调度器与任务生成

调度器负责定时为每个活跃的公司生成抓取任务。以下是基于APScheduler的简单实现。

# scheduler.py
import logging
from datetime import datetime, timedelta
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy.orm import Session
from models import Company, engine
from task_queue import enqueue_crawl_task  # 假设的任务入队函数

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def generate_crawl_tasks():
    """生成抓取任务:找出所有需要抓取的公司"""
    with Session(engine) as session:
        # 找出所有活跃的,且上次抓取时间超过预定频率的公司
        companies_to_crawl = session.query(Company).filter(
            Company.is_active == True,
            (Company.last_crawl_time.is_(None)) |
            (datetime.utcnow() - Company.last_crawl_time > timedelta(hours=Company.crawl_frequency_hours))
        ).all()

        for company in companies_to_crawl:
            task_data = {
                'company_id': company.id,
                'company_name': company.name,
                'url': company.career_page_url,
                'parser_config': company.parser_config
            }
            # 将任务放入队列
            enqueue_crawl_task(task_data)
            logger.info(f"Enqueued crawl task for {company.name}")

            # 可选:立即更新`last_crawl_time`,避免短时间重复调度。
            # 更优做法是在抓取成功后再更新。
            # company.last_crawl_time = datetime.utcnow()
        # session.commit()

def start_scheduler():
    scheduler = BackgroundScheduler()
    # 每5分钟运行一次任务生成器(可根据需要调整)
    scheduler.add_job(
        generate_crawl_tasks,
        trigger=IntervalTrigger(minutes=5),
        id='generate_crawl_tasks',
        max_instances=1
    )
    scheduler.start()
    logger.info("Scheduler started.")
    return scheduler

3.3 爬虫节点与请求处理

爬虫节点从队列中消费任务,执行HTTP请求。这里展示一个支持静态页面和动态渲染的混合抓取器。

# crawler.py
import requests
from playwright.sync_api import sync_playwright
import logging
from urllib.parse import urljoin
from typing import Optional, Dict, Any

logger = logging.getLogger(__name__)

class HybridCrawler:
    def __init__(self, request_timeout=30, enable_js=False):
        self.session = requests.Session()
        self.session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...', # 使用常见UA
        })
        self.request_timeout = request_timeout
        self.enable_js = enable_js  # 是否默认启用JS渲染
        self.playwright = None
        self.browser = None
        if enable_js:
            self._init_browser()

    def _init_browser(self):
        """初始化Playwright浏览器,建议每个节点只初始化一次"""
        self.playwright = sync_playwright().start()
        # 使用无头模式,生产环境可考虑非无头模式进行调试
        self.browser = self.playwright.chromium.launch(headless=True)

    def fetch(self, url: str, force_js: bool = False) -> Optional[str]:
        """
        获取页面内容
        :param url: 目标URL
        :param force_js: 是否强制使用浏览器渲染
        :return: 页面HTML字符串
        """
        if force_js or self.enable_js:
            return self._fetch_with_playwright(url)
        else:
            return self._fetch_with_requests(url)

    def _fetch_with_requests(self, url: str) -> Optional[str]:
        """使用requests获取静态页面"""
        try:
            resp = self.session.get(url, timeout=self.request_timeout)
            resp.raise_for_status()  # 检查HTTP错误
            # 简单检查内容是否看起来是JS渲染的(例如包含常见的JS框架标记)
            content = resp.text
            if '<div id="root"></div>' in content or 'window.__INITIAL_STATE__' in content:
                logger.warning(f"Page {url} may be JS-rendered, consider using Playwright.")
            return content
        except requests.exceptions.RequestException as e:
            logger.error(f"Failed to fetch {url} with requests: {e}")
            return None

    def _fetch_with_playwright(self, url: str) -> Optional[str]:
        """使用Playwright获取动态渲染页面"""
        if not self.browser:
            self._init_browser()
        context = self.browser.new_context()
        page = context.new_page()
        try:
            page.goto(url, wait_until='networkidle')  # 等待网络空闲
            # 可以在这里执行一些滚动或点击操作,以确保所有内容加载
            # page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
            content = page.content()
            return content
        except Exception as e:
            logger.error(f"Failed to fetch {url} with playwright: {e}")
            return None
        finally:
            context.close()

    def close(self):
        if self.browser:
            self.browser.close()
        if self.playwright:
            self.playwright.stop()
        self.session.close()

3.4 解析器与规则适配

解析器是系统的“大脑”,负责从异构的HTML中提取结构化数据。这里展示一个基于配置的解析器设计。

# parser.py
import json
import re
from typing import List, Dict, Any, Optional
from bs4 import BeautifulSoup
import extruct  # 用于提取结构化数据(如JSON-LD)

class ConfigurableParser:
    def __init__(self, parser_config: Dict[str, Any]):
        """
        :param parser_config: 解析配置字典,例如:
            {
                "type": "html",
                "job_list_selector": "div.job-listing",
                "fields": {
                    "title": {"selector": "h2.job-title", "attr": "text"},
                    "job_id": {"selector": "a", "attr": "href", "regex": r"/careers/(\d+)"},
                    "department": {"selector": "span.dept", "attr": "text"},
                    "apply_url": {"selector": "a.apply-btn", "attr": "href", "resolve": true}
                }
            }
        """
        self.config = parser_config

    def parse(self, html_content: str, base_url: str) -> List[Dict[str, Any]]:
        """根据配置解析HTML,返回职位字典列表"""
        if self.config.get('type') == 'json_ld':
            # 尝试提取JSON-LD结构化数据(许多招聘网站使用)
            data = self._extract_json_ld(html_content)
            if data:
                return self._parse_from_json_ld(data)
        # 默认使用BeautifulSoup基于CSS选择器解析
        return self._parse_with_selectors(html_content, base_url)

    def _extract_json_ld(self, html_content: str) -> Optional[List[Dict]]:
        """使用extruct提取JSON-LD数据"""
        try:
            data = extruct.extract(html_content, syntaxes=['json-ld'])
            return data.get('json-ld', [])
        except Exception as e:
            print(f"Error extracting JSON-LD: {e}")
            return None

    def _parse_from_json_ld(self, json_ld_data: List[Dict]) -> List[Dict[str, Any]]:
        """从JSON-LD数据中解析职位信息"""
        jobs = []
        for item in json_ld_data:
            if item.get('@type') == 'JobPosting':
                job = {
                    'title': item.get('title'),
                    'description': item.get('description'),
                    'location': item.get('jobLocation', {}).get('address', {}).get('addressLocality'),
                    'apply_url': item.get('applyUrl') or item.get('url'),
                    'posted_date': item.get('datePosted'),
                    # 可以映射更多字段...
                }
                # 清理空值
                job = {k: v for k, v in job.items() if v is not None}
                jobs.append(job)
        return jobs

    def _parse_with_selectors(self, html_content: str, base_url: str) -> List[Dict[str, Any]]:
        """使用CSS选择器解析"""
        soup = BeautifulSoup(html_content, 'html.parser')
        job_elements = soup.select(self.config['job_list_selector'])
        jobs = []
        for elem in job_elements:
            job_data = {}
            for field_name, field_config in self.config['fields'].items():
                selector = field_config.get('selector')
                attr = field_config.get('attr', 'text')
                regex = field_config.get('regex')
                resolve_url = field_config.get('resolve', False)

                if not selector:
                    continue
                target_elem = elem.select_one(selector)
                if not target_elem:
                    continue

                # 提取属性或文本
                if attr == 'text':
                    value = target_elem.get_text(strip=True)
                else:
                    value = target_elem.get(attr, '')

                # 应用正则表达式提取(如从URL中提取ID)
                if regex and value:
                    match = re.search(regex, value)
                    if match:
                        value = match.group(1) if match.groups() else match.group(0)

                # 解析相对URL为绝对URL
                if resolve_url and value and field_name in ['apply_url', 'detail_url']:
                    value = urljoin(base_url, value)

                job_data[field_name] = value
            if job_data:  # 确保提取到至少一个字段
                jobs.append(job_data)
        return jobs

3.5 数据存储与去重逻辑

这是将抓取到的原始数据转化为系统知识的关键一步。核心是“插入或更新”(upsert)操作。

# storage.py
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from datetime import datetime
from models import JobListing, Company
import hashlib

engine = create_engine('postgresql://user:password@localhost/job_scraper')

def generate_job_fingerprint(job_data: Dict[str, Any]) -> str:
    """生成职位的唯一指纹,用于辅助去重。"""
    # 使用核心字段生成指纹,忽略描述等可能频繁变化的字段
    core_string = f"{job_data.get('company_id')}:{job_data.get('source_job_id')}:{job_data.get('title')}:{job_data.get('location')}"
    return hashlib.md5(core_string.encode()).hexdigest()

def upsert_job_listing(job_data: Dict[str, Any]):
    """
    插入或更新职位信息。
    策略:优先使用 source_job_id,其次使用指纹。
    """
    with Session(engine) as session:
        company_id = job_data['company_id']
        source_job_id = job_data.get('source_job_id')
        fingerprint = generate_job_fingerprint(job_data)

        # 尝试通过唯一约束(company_id, source_job_id)查找现有职位
        existing_job = None
        if source_job_id:
            existing_job = session.query(JobListing).filter_by(
                company_id=company_id,
                source_job_id=source_job_id
            ).first()

        # 如果没找到,尝试通过指纹查找(防止source_job_id缺失或变化)
        if not existing_job and fingerprint:
            # 注意:此查询效率较低,生产环境应考虑为fingerprint建索引或使用其他去重策略
            existing_job = session.query(JobListing).filter_by(
                company_id=company_id,
                # 这里假设我们有一个`fingerprint`字段,实际模型中需添加
                # fingerprint=fingerprint
            ).first()

        now = datetime.utcnow()
        if existing_job:
            # 更新现有职位
            existing_job.title = job_data.get('title', existing_job.title)
            existing_job.location = job_data.get('location', existing_job.location)
            existing_job.description = job_data.get('description', existing_job.description)
            existing_job.apply_url = job_data.get('apply_url', existing_job.apply_url)
            existing_job.last_updated_at = now
            existing_job.is_active = True  # 既然被抓取到,说明职位仍活跃
            logger.info(f"Updated job: {existing_job.id}")
        else:
            # 插入新职位
            new_job = JobListing(
                id=f"{company_id}:{source_job_id or fingerprint}",
                company_id=company_id,
                source_job_id=source_job_id,
                title=job_data['title'],
                location=job_data.get('location'),
                description=job_data.get('description'),
                apply_url=job_data.get('apply_url'),
                posted_date=job_data.get('posted_date'),
                first_seen_at=now,
                last_updated_at=now,
                is_active=True
            )
            session.add(new_job)
            logger.info(f"Inserted new job: {new_job.id}")

        try:
            session.commit()
        except IntegrityError as e:
            session.rollback()
            logger.error(f"IntegrityError on upsert for {job_data}: {e}")
            # 可能是并发写入导致,可以根据业务需求选择重试或忽略

4. 系统运行与验证

将上述模块组合起来,形成一个完整的工作流。我们编写一个主工作节点脚本。

# worker.py
import logging
import sys
from crawler import HybridCrawler
from parser import ConfigurableParser
from storage import upsert_job_listing
from task_queue import get_task_from_queue  # 假设的从队列取任务函数

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

def process_task(task_data):
    """处理单个抓取任务"""
    company_id = task_data['company_id']
    url = task_data['url']
    parser_config = json.loads(task_data['parser_config']) if isinstance(task_data['parser_config'], str) else task_data['parser_config']

    logger.info(f"Processing task for {company_id}: {url}")

    # 1. 抓取
    crawler = HybridCrawler(enable_js=parser_config.get('force_js', False))
    html = crawler.fetch(url, force_js=parser_config.get('force_js', False))
    crawler.close()

    if not html:
        logger.error(f"Failed to fetch content from {url}")
        # 可以在这里更新公司状态,标记为抓取失败,或加入重试队列
        return

    # 2. 解析
    parser = ConfigurableParser(parser_config)
    jobs_raw = parser.parse(html, base_url=url)

    if not jobs_raw:
        logger.warning(f"No jobs parsed from {url}. Check parser configuration.")
        # 可能是页面结构变化,需要触发告警

    # 3. 存储
    success_count = 0
    for job_raw in jobs_raw:
        job_raw['company_id'] = company_id
        try:
            upsert_job_listing(job_raw)
            success_count += 1
        except Exception as e:
            logger.error(f"Failed to upsert job {job_raw.get('title')}: {e}")
    logger.info(f"Successfully processed {success_count}/{len(jobs_raw)} jobs from {company_id}")

def main_loop():
    """工作节点主循环"""
    logger.info("Worker started.")
    crawler = HybridCrawler(enable_js=True)  # 全局初始化一个,避免频繁启停
    try:
        while True:
            task_data = get_task_from_queue()  # 阻塞或非阻塞获取任务
            if task_data:
                process_task(task_data)
            # 可以添加适当的休眠,避免空转
            # time.sleep(1)
    except KeyboardInterrupt:
        logger.info("Worker shutting down.")
    finally:
        crawler.close()

if __name__ == '__main__':
    main_loop()

验证系统是否工作

  1. 启动依赖服务 :确保PostgreSQL和Redis(如果使用)已运行。
  2. 初始化数据库 :运行 alembic upgrade head 或相应的SQL脚本创建表。
  3. 添加测试公司 :向 companies 表插入一条记录,配置一个简单的解析规则(如针对一个静态招聘页面的CSS选择器)。
  4. 启动调度器 :在一个终端运行 python scheduler.py
  5. 启动工作节点 :在另一个终端运行 python worker.py
  6. 观察日志与数据库 :查看工作节点日志是否显示抓取、解析、存储成功。检查 job_listings 表中是否出现了预期的职位数据。

5. 生产环境常见问题与排查

将系统投入生产,面对8000个不同网站时,会遇到各种预料之外的问题。以下是典型问题排查清单。

问题现象 可能原因 检查与排查步骤 解决方案与预防
抓取失败,返回403/429 触发目标网站反爬机制(IP限制、频率过高、UA异常)。 1. 检查日志中的HTTP状态码和响应头(如 Retry-After )。
2. 检查请求头中的 User-Agent 是否合理。
3. 统计同一IP对同一域名的请求频率。
1. 使用代理IP池轮换。
2. 为每个请求添加随机延迟(如 time.sleep(random.uniform(1, 5)) )。
3. 尊重 robots.txt ,设置合理的抓取间隔。
页面抓取成功,但解析不到数据 1. 页面结构已更新。
2. 网站改为JS动态渲染。
3. 解析配置错误。
1. 保存抓取到的HTML样本,手动用浏览器打开对比。
2. 检查HTML中是否包含大量JS框架代码或只有 <div id="app"> 等空容器。
3. 使用浏览器开发者工具重新定位元素选择器。
1. 实现解析规则的版本管理和自动检测失效机制。
2. 启用 Playwright 进行动态渲染抓取。
3. 建立解析规则测试套件,定期对样本进行回归测试。
数据库出现大量重复职位 1. source_job_id 提取不稳定或缺失。
2. 并发写入导致唯一约束冲突处理不当。
3. 指纹生成逻辑有缺陷。
1. 检查原始数据中 source_job_id 的提取逻辑。
2. 查看数据库错误日志中是否有 IntegrityError
3. 对比重复记录的指纹字段。
1. 优化 source_job_id 提取,优先从URL或隐藏字段获取。
2. 在应用层或数据库层使用更健壮的upsert逻辑(如PostgreSQL的 ON CONFLICT DO UPDATE )。
3. 复核指纹生成算法,确保其稳定性。
抓取速度过慢 1. 同步请求导致I/O阻塞。
2. 动态渲染页面耗时过长。
3. 网络延迟或代理速度慢。
1. 使用异步HTTP客户端(如 aiohttp )或增加工作节点数量。
2. 分析 Playwright goto 操作的耗时。
3. 监控网络请求的响应时间。
1. 将架构改为异步(如使用 asyncio + aiohttp )。
2. 对无需JS的网站禁用动态渲染。
3. 使用更优质的代理服务或调整超时时间。
无法发现所有分页或职位 1. 列表页有“加载更多”按钮(JS触发)。
2. 职位信息通过API异步加载。
3. 初始列表页只显示部分职位。
1. 使用浏览器开发者工具的Network面板,查看点击“加载更多”时触发的XHR请求。
2. 直接尝试抓取API接口(通常返回JSON,更易解析)。
3. 检查页面是否有隐藏的筛选器或视图切换。
1. 在 Playwright 中模拟点击“加载更多”按钮。
2. 优先配置并抓取API接口。
3. 完善链接发现模块,从页面中提取所有可能的职位详情页链接。

6. 最佳实践与扩展方向

构建一个稳定运行的系统,除了核心功能,还需要在运维、监控和数据质量上下功夫。

6.1 运维与监控最佳实践

  1. 配置外部化 :将所有变量(如数据库连接字符串、代理列表、请求头、抓取间隔)存储在环境变量或配置文件中,不要硬编码。
  2. 完善的日志 :结构化日志(JSON格式)便于后续收集和分析。记录关键事件:任务开始/结束、抓取状态码、解析数量、存储结果、异常信息。
  3. 指标监控 :暴露关键指标(如抓取成功率、平均响应时间、新增职位数/日),使用Prometheus + Grafana进行可视化监控。
  4. 告警机制 :对连续抓取失败、解析成功率骤降、数据量异常波动等情况设置告警(可通过邮件、Slack、钉钉等)。
  5. 数据备份与版本控制 :定期备份数据库。对解析规则配置文件进行版本控制(如Git),便于回滚和协作。

6.2 数据质量保障

  1. 数据去重与合并 :除了数据库唯一约束,在业务逻辑层实现更智能的合并,例如识别同一职位在不同地点的发布。
  2. 数据清洗 :对抓取的文本进行清洗,如去除多余空白、HTML标签、非法字符。
  3. 字段标准化 :将地点(如“北京”、“北京市”、“Beijing”)、职位类型等字段映射到标准枚举值,便于后续搜索和分析。
  4. 定期全量验证 :每周或每月对历史“活跃”职位进行一次全量验证,访问其申请链接,确认职位是否真实有效,及时关闭失效职位。

6.3 系统扩展方向

  1. 分布式爬虫集群 :使用Redis作为分布式任务队列,轻松横向扩展工作节点。
  2. 智能解析与机器学习 :对于无法配置规则的新网站,可以尝试使用机器学习模型自动识别页面上的职位信息块(如使用视觉特征或DOM结构特征)。
  3. 构建搜索与推荐接口 :将清洗后的数据导入Elasticsearch,为前端提供强大的全文搜索、筛选和排序功能,甚至可以构建简单的职位推荐系统。
  4. 增加数据源类型 :除了公司官网,可以集成LinkedIn、Glassdoor等平台的公开职位信息(需严格遵守其服务条款和robots.txt)。
  5. 实现实时通知 :为订阅了特定关键词(如“Python 远程”)的用户,在发现新职位时推送邮件或应用内通知。

构建这样一个系统是一个持续迭代的过程。从最核心的“抓取-解析-存储”闭环开始,逐步加入容错、调度、监控和数据分析能力。最重要的是保持代码的模块化和可配置性,以应对成千上万网站各不相同的挑战。

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐