Python爬虫实战:招聘会参会企业数据采集实战 - 分页抓取、去重与增量更新完整方案(附CSV导出 + SQLite持久化存储)!

㊙️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!

㊗️爬虫难度指数:⭐⭐

🚫声明:本数据&代码仅供学习交流,严禁用于商业用途、倒卖数据或违反目标站点的服务条款等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议",技术无罪,责任在人。

全文目录:

🌟 开篇语

哈喽,各位小伙伴们你们好呀~我是【喵手】。

运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO

欢迎大家常来逛逛,一起学习,一起进步~🌟

我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略反爬对抗 ,从数据清洗分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上

📌 专栏食用指南(建议收藏)

  • ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
  • ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
  • ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
  • ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用

📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏👉《Python爬虫实战》👈

💕订阅后更新会优先推送,按目录学习更高效💯~

1️⃣ 摘要(Abstract)

本文将带你从零构建一个招聘会参会企业信息采集系统 ,使用 requests + lxml + SQLite 技术栈,实现对招聘会官网企业列表的自动化抓取,最终产出包含企业名称、所属行业、招聘岗位数的结构化数据库。

读完本文你将获得:

  • 掌握分页列表的自动化遍历与数据提取技巧
  • 学会基于数据库的去重与增量更新策略
  • 理解爬虫项目从环境搭建到生产部署的完整流程
  • 积累一套可复用的爬虫代码框架和异常处理经验

2️⃣ 背景与需求(Why)

为什么要做这个项目?

在求职季或者人力资源分析场景中,我们经常需要:

  • 数据分析需求:统计某地区招聘会的行业分布、热门行业、岗位供给趋势
  • 信息聚合需求:将分散在多个招聘会的企业信息整合到一处,方便检索
  • 自动化监控需求:定期抓取最新招聘会信息,发现新入驻企业或岗位变化

手动复制粘贴效率低且容易出错,而招聘会官网通常会分页展示几十甚至上百家企业,人工处理几乎不可行。因此,我们需要一个自动化的采集工具。

目标站点与字段清单

本文以某地级市人才市场官网的招聘会企业列表为例(该站点结构清晰、无强反爬,适合学习)。

目标字段清单:

字段名 说明 数据类型 示例值
company_name 企业名称 VARCHAR(200) "XX科技有限公司"
industry 所属行业 VARCHAR(100) "互联网/电子商务"
job_count 招聘岗位数 INT 15
job_fair_id 招聘会ID VARCHAR(50) "2025_spring_01"
url 企业详情页URL VARCHAR(500) "https://..."
crawl_time 抓取时间 DATETIME "2025-01-29 14:30:00"

3️⃣ 合规与注意事项(必读)

robots.txt 协议

在开始爬取前,务必检查目标站点的 robots.txt 文件(通常位于 https://example.com/robots.txt),确认是否允许爬虫访问目标页面。大部分人才市场官网的公开招聘会信息页面是允许抓取的,但仍需尊重网站规则。

频率控制与道德约束

  • 请求间隔:每次请求后至少等待 1-3 秒,避免对服务器造成压力
  • 并发限制:本文采用单线程顺序抓取,如需并发请控制在 3-5 个线程以内
  • User-Agent:使用真实浏览器 UA,不伪造身份但也不暴露爬虫特征

数据使用边界

  • 允许:用于个人学习、数据分析、非商业研究
  • 禁止:采集用户隐私信息、绕过付费内容、商业转售数据
  • ⚠️ 谨慎:如需商业用途,请联系网站管理员获取授权

4️⃣ 技术选型与整体流程(What/How)

技术选型分析

目标站点特征判断:

经过初步分析,招聘会企业列表页面属于服务端渲染的静态HTML,数据直接嵌入在页面源码中,无需执行JavaScript即可获取。

三种方案对比:

方案 适用场景 本项目是否适用
requests + lxml 静态HTML,数据在源码中 最优选择
Selenium/Playwright 动态渲染,需要执行JS ❌ 杀鸡用牛刀
API逆向 数据通过接口异步加载 ❌ 本案例无此情况

最终选择:requests + lxml + SQLite

  • requests:轻量级HTTP库,发送请求、处理响应
  • lxml:高性能HTML解析库,XPath选择器精准定位
  • SQLite:轻量级关系数据库,天然支持去重与增量查询

整体流程设计

json 复制代码
┌─────────────┐
│  启动脚本    │
└──────┬──────┘
       │
       ▼
┌─────────────────┐
│ 1. 获取总页数    │ ← 解析首页分页信息
└──────┬──────────┘
       │
       ▼
┌─────────────────┐
│ 2. 遍历每一页    │ ← for page in range(1, total_pages+1)
└──────┬──────────┘
       │
       ▼
┌─────────────────┐
│ 3. 发送HTTP请求  │ ← requests.get() + headers + timeout
└──────┬──────────┘
       │
       ▼
┌─────────────────┐
│ 4. 解析HTML      │ ← lxml.etree + XPath提取字段
└──────┬──────────┘
       │
       ▼
┌─────────────────┐
│ 5. 数据清洗      │ ← 去空格、类型转换、缺失值填充
└──────┬──────────┘
       │
       ▼
┌─────────────────┐
│ 6. 去重判断      │ ← 查询数据库是否已存在
└──────┬──────────┘
       │
       ▼
┌─────────────────┐
│ 7. 写入数据库    │ ← INSERT OR IGNORE / UPDATE
└──────┬──────────┘
       │
       ▼
┌─────────────────┐
│ 8. 导出CSV       │ ← sqlite3 → pandas → to_csv()
└─────────────────┘

5️⃣ 环境准备与依赖安装(可复现)

Python 版本要求

  • 推荐版本:Python 3.8 - 3.11
  • 最低版本:Python 3.7(lxml 部分特性需要)

依赖包安装

创建虚拟环境(可选但推荐):

json 复制代码
python -m venv venv
source venv/bin/activate  # Windows用户: venv\Scripts\activate

安装核心依赖:

json 复制代码
pip install requests==2.31.0
pip install lxml==4.9.3
pip install pandas==2.0.3  # 用于导出CSV,可选

项目目录结构

json 复制代码
job_fair_crawler/
│
├── crawler.py          # 主爬虫逻辑
├── config.py           # 配置文件(URL、headers等)
├── database.py         # 数据库操作封装
├── utils.py            # 工具函数(重试、日志等)
│
├── data/
│   ├── job_fair.db     # SQLite数据库文件
│   └── export.csv      # 导出的CSV文件
│
├── logs/
│   └── crawler.log     # 运行日志
│
└── requirements.txt    # 依赖清单

6️⃣ 核心实现:请求层(Fetcher)

请求头配置

请求头是绕过基础反爬的第一道防线。我们需要模拟真实浏览器行为:

python 复制代码
# config.py
HEADERS = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
                  '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
    'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
    'Accept-Encoding': 'gzip, deflate, br',
    'Connection': 'keep-alive',
    'Referer': 'https://www.example-talent-market.com/',  # 根据实际情况修改
    'Upgrade-Insecure-Requests': '1'
}

BASE_URL = 'https://www.example-talent-market.com/job-fair/companies'
TIMEOUT = 10  # 请求超时时间(秒)
RETRY_TIMES = 3  # 失败重试次数
RETRY_DELAY = 2  # 重试间隔(秒)

请求函数实现

python 复制代码
# utils.py
import requests
import time
import logging
from config import HEADERS, TIMEOUT, RETRY_TIMES, RETRY_DELAY

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(message)s',
    handlers=[
        logging.FileHandler('logs/crawler.log', encoding='utf-8'),
        logging.StreamHandler()
    ]
)

def fetch_page(url, params=None):
    """
    发送HTTP GET请求,支持重试机制
    
    Args:
        url: 目标URL
        params: URL参数字典
    
    Returns:
        response对象 或 None(失败时)
    """
    for attempt in range(1, RETRY_TIMES + 1):
        try:
            response = requests.get(
                url, 
                params=params,
                headers=HEADERS, 
                timeout=TIMEOUT
            )
            
            # 检查HTTP状态码
            if response.status_code == 200:
                response.encoding = response.apparent_encoding  # 自动检测编码
                logging.info(f"✓ 成功获取: {url} (尝试 {attempt}/{RETRY_TIMES})")
                return response
            elif response.status_code == 429:
                logging.warning(f"⚠ 触发频率限制(429),等待{RETRY_DELAY * 2}秒后重试...")
                time.sleep(RETRY_DELAY * 2)
            else:
                logging.error(f"✗ HTTP {response.status_code}: {url}")
                
        except requests.exceptions.Timeout:
            logging.warning(f"⏱ 请求超时,第 {attempt} 次重试...")
        except requests.exceptions.ConnectionError:
            logging.warning(f"🔌 连接错误,第 {attempt} 次重试...")
        except Exception as e:
            logging.error(f"❌ 未知错误: {str(e)}")
        
        if attempt < RETRY_TIMES:
            time.sleep(RETRY_DELAY)
    
    logging.error(f"✗ 达到最大重试次数,放弃: {url}")
    return None

Session 复用(可选优化)

如果需要保持cookie或提升性能,可以使用 requests.Session()

python 复制代码
class SessionFetcher:
    def __init__(self):
        self.session = requests.Session()
        self.session.headers.update(HEADERS)
    
    def get(self, url, params=None):
        # 与上面的fetch_page逻辑相同,但用self.session.get()
        pass

7️⃣ 核心实现:解析层(Parser)

解析策略选择

我们使用 XPath 作为主要解析方式,原因如下:

  • 比BeautifulSoup性能高3-5倍
  • 语法简洁,适合结构化数据提取
  • 对动态变化的HTML结构容错性较好

列表页解析逻辑

假设招聘会企业列表HTML结构如下(简化示例):

html 复制代码
<div class="company-list">
    <div class="company-item">
        <h3 class="company-name">杭州XX科技有限公司</h3>
        <span class="industry">互联网/电子商务</span>
        <span class="job-count">招聘<em>15</em>个岗位</span>
        <a href="/company/12345">详情</a>
    </div>
    <!-- 更多企业... -->
</div>

<div class="pagination">
    <a href="?page=1">1</a>
    <a href="?page=2" class="current">2</a>
    <a href="?page=3">3</a>
    <!-- 共10页 -->
</div>

解析代码实现:

python 复制代码
# crawler.py
from lxml import etree
import re

def parse_company_list(html_content):
    """
    解析企业列表页,提取所有企业信息
    
    Args:
        html_content: HTML源码字符串
    
    Returns:
        企业信息列表 [{company_name, industry, job_count, url}, ...]
    """
    tree = etree.HTML(html_content)
    companies = []
    
    # XPath定位所有企业卡片
    company_items = tree.xpath('//div[@class="company-item"]')
    
    for item in company_items:
        try:
            # 提取企业名称
            name_list = item.xpath('.//h3[@class="company-name"]/text()')
            company_name = name_list[0].strip() if name_list else None
            
            # 提取行业
            industry_list = item.xpath('.//span[@class="industry"]/text()')
            industry = industry_list[0].strip() if industry_list else "未知行业"
            
            # 提取岗位数(从"招聘15个岗位"中提取数字)
            job_text_list = item.xpath('.//span[@class="job-count"]//text()')
            job_text = ''.join(job_text_list)
            job_count_match = re.search(r'\d+', job_text)
            job_count = int(job_count_match.group()) if job_count_match else 0
            
            # 提取详情链接
            url_list = item.xpath('.//a/@href')
            relative_url = url_list[0] if url_list else None
            full_url = f"https://www.example-talent-market.com{relative_url}" if relative_url else None
            
            # 必须有企业名称才算有效数据
            if company_name:
                companies.append({
                    'company_name': company_name,
                    'industry': industry,
                    'job_count': job_count,
                    'url': full_url
                })
            else:
                logging.warning("⚠ 发现无效企业卡片,已跳过")
                
        except Exception as e:
            logging.error(f"❌ 解析单个企业时出错: {str(e)}")
            continue
    
    return companies

def get_total_pages(html_content):
    """
    从首页提取总页数
    
    Args:
        html_content: HTML源码
    
    Returns:
        总页数(整数)
    """
    tree = etree.HTML(html_content)
    
    # 方法1:从分页链接中提取最大页码
    page_links = tree.xpath('//div[@class="pagination"]//a/@href')
    max_page = 1
    
    for link in page_links:
        match = re.search(r'page=(\d+)', link)
        if match:
            page_num = int(match.group(1))
            max_page = max(max_page, page_num)
    
    # 方法2:从"共X页"文本中提取
    total_text = tree.xpath('//div[@class="pagination"]//text()')
    for text in total_text:
        match = re.search(r'共\s*(\d+)\s*页', text)
        if match:
            return int(match.group(1))
    
    return max_page

字段容错处理

在实际爬取中,网站HTML结构可能不稳定,我们需要多层防御:

python 复制代码
def safe_extract(xpath_result, default="", processor=None):
    """
    安全提取XPath结果,避免IndexError
    
    Args:
        xpath_result: XPath查询结果列表
        default: 默认值
        processor: 后处理函数(如strip、int等)
    """
    try:
        value = xpath_result[0] if xpath_result else default
        if processor:
            return processor(value)
        return value
    except Exception:
        return default

# 使用示例
company_name = safe_extract(
    item.xpath('.//h3[@class="company-name"]/text()'),
    default="未知企业",
    processor=lambda x: x.strip()
)

8️⃣ 数据存储与导出(Storage)

数据库设计

使用SQLite创建企业信息表:

python 复制代码
# database.py
import sqlite3
from datetime import datetime
import logging

class JobFairDB:
    def __init__(self, db_path='data/job_fair.db'):
        self.db_path = db_path
        self.conn = None
        self.cursor = None
        self._create_table()
    
    def connect(self):
        """建立数据库连接"""
        self.conn = sqlite3.connect(self.db_path)
        self.cursor = self.conn.cursor()
    
    def close(self):
        """关闭数据库连接"""
        if self.conn:
            self.conn.commit()
            self.conn.close()
    
    def _create_table(self):
        """创建企业信息表"""
        self.connect()
        self.cursor.execute('''
            CREATE TABLE IF NOT EXISTS companies (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                company_name VARCHAR(200) NOT NULL,
                industry VARCHAR(100),
                job_count INTEGER DEFAULT 0,
                job_fair_id VARCHAR(50),
                url VARCHAR(500) UNIQUE,
                crawl_time DATETIME,
                update_time DATETIME,
                UNIQUE(company_name, job_fair_id)
            )
        ''')
        
        # 创建索引加速查询
        self.cursor.execute('''
            CREATE INDEX IF NOT EXISTS idx_company_name 
            ON companies(company_name)
        ''')
        self.cursor.execute('''
            CREATE INDEX IF NOT EXISTS idx_crawl_time 
            ON companies(crawl_time)
        ''')
        
        self.conn.commit()
        self.close()
        logging.info("✓ 数据库表初始化完成")
    
    def insert_or_update(self, company_data, job_fair_id='2025_spring'):
        """
        插入或更新企业信息(去重逻辑)
        
        Args:
            company_data: 企业信息字聘会标识
        
        Returns:
            (is_new, company_id) 元组
        """
        self.connect()
        
        company_name = company_data['company_name']
        industry = company_data.get('industry', '未知行业')
        job_count = company_data.get('job_count', 0)
        url = company_data.get('url')
        now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        
        # 检查是否已存在
        self.cursor.execute('''
            SELECT id, job_count FROM companies 
            WHERE company_name = ? AND job_fair_id = ?
        ''', (company_name, job_fair_id))
        
        existing = self.cursor.fetchone()
        
        if existing:
            company_id, old_job_count = existing
            
            # 如果岗位数有变化,更新记录
            if job_count != old_job_count:
                self.cursor.execute('''
                    UPDATE companies 
                    SET job_count = ?, update_time = ?
                    WHERE id = ?
                ''', (job_count, now, company_id))
                self.conn.commit()
                logging.info(f"🔄 更新企业: {company_name} (岗位数 {old_job_count} → {job_count})")
            
            self.close()
            return (False, company_id)  # 不是新记录
        else:
            # 插入新记录
            self.cursor.execute('''
                INSERT INTO companies 
                (company_name, industry, job_count, job_fair_id, url, crawl_time, update_time)
                VALUES (?, ?, ?, ?, ?, ?, ?)
            ''', (company_name, industry, job_count, job_fair_id, url, now, now))
            
            company_id = self.cursor.lastrowid
            self.conn.commit()
            self.close()
            
            logging.info(f"✨ 新增企业: {company_name} (岗位数: {job_count})")
            return (True, company_id)  # 是新记录
    
    def get_all_companies(self, job_fair_id=None):
        """
        查询所有企业信息
        
        Args:
            job_fair_id: 可选,筛选特定招聘会
        
        Returns:
            企业列表
        """
        self.connect()
        
        if job_fair_id:
            self.cursor.execute('''
                SELECT * FROM companies WHERE job_fair_id = ?
                ORDER BY crawl_time DESC
            ''', (job_fair_id,))
        else:
            self.cursor.execute('SELECT * FROM companies ORDER BY crawl_time DESC')
        
        results = self.cursor.fetchall()
        self.close()
        return results
    
    def export_to_csv(self, output_path='data/export.csv'):
        """导出数据到CSV文件"""
        try:
            import pandas as pd
            
            self.connect()
            df = pd.read_sql_query('SELECT * FROM companies', self.conn)
            self.close()
            
            df.to_csv(output_path, index=False, encoding='utf-8-sig')
            logging.info(f"📊 数据已导出到: {output_path}")
            return True
        except ImportError:
            logging.warning("⚠ pandas未安装,无法导出CSV")
            return False

去重策略说明

本项目采用双重去重机制:

  1. 数据库层面 :通过 UNIQUE(company_name, job_fair_id) 约束确保同一招聘会中企业名称唯一
  2. 应用层面 :在 insert_or_update() 中先查询后决定插入或更新

增量更新逻辑

  • 如果企业已存在但岗位数变化 → 更新 job_countupdate_time
  • 如果企业不存在 → 插入新记录
  • 如果企业和岗位数都未变 → 跳过(避免重复写入)

9️⃣ 运行方式与结果展示(必写)

主程序入口

python 复制代码
# crawler.py (完整版)
import time
from utils import fetch_page
from database import JobFairDB
import logging

def main():
    """主爬虫函数"""
    logging.info("=" * 50)
    logging.info("🚀 招聘会企业爬虫启动")
    logging.info("=" * 50)
    
    # 初始化数据库
    db = JobFairDB()
    
    # 设置招聘会ID(可从命令行参数获取)
    job_fair_id = '2025_spring'
    
    # 1. 获取首页,确定总页数
    base_url = 'https://www.example-talent-market.com/job-fair/companies'
    first_page = fetch_page(base_url, params={'page': 1})
    
    if not first_page:
        logging.error("❌ 无法获取首页,爬虫终止")
        return
    
    total_pages = get_total_pages(first_page.text)
    logging.info(f"📄 共检测到 {total_pages} 页数据")
    
    # 统计变量
    new_count = 0
    update_count = 0
    
    # 2. 遍历所有页面
    for page_num in range(1, total_pages + 1):
        logging.info(f"\n▶ 正在抓取第 {page_num}/{total_pages} 页...")
        
        # 发送请求
        response = fetch_page(base_url, params={'page': page_num})
        if not response:
            logging.warning(f"⚠ 第{page_num}页抓取失败,跳过")
            continue
        
        # 解析企业列表
        companies = parse_company_list(response.text)
        logging.info(f"  解析出 {len(companies)} 家企业")
        
        # 3. 存储到数据库
        for company in companies:
            is_new, company_id = db.insert_or_update(company, job_fair_id)
            if is_new:
                new_count += 1
            else:
                update_count += 1
        
        # 4. 礼貌等待(避免频繁请求)
        if page_num < total_pages:
            time.sleep(2)  # 每页间隔2秒
    
    # 5. 导出结果
    db.export_to_csv()
    
    # 6. 输出统计
    logging.info("\n" + "=" * 50)
    logging.info("✅ 爬取任务完成!")
    logging.info(f"📊 新增企业: {new_count} 家")
    logging.info(f"🔄 更新企业: {update_count} 家")
    logging.info(f"💾 数据库路径: data/job_fair.db")
    logging.info(f"📄 CSV文件: data/export.csv")
    logging.info("=" * 50)

if __name__ == '__main__':
    main()

启动命令

json 复制代码
# 直接运行
python crawler.py

# 或者指定日志级别
python crawler.py --log-level DEBUG

运行结果展示

控制台输出示例:

json 复制代码
==================================================
🚀 招聘会企业爬虫启动
==================================================
2025-01-29 14:30:01 [INFO] ✓ 数据库表初始化完成
2025-01-29 14:30:02 [INFO] ✓ 成功获取: https://...?page=1 (尝试 1/3)
2025-01-29 14:30:02 [INFO] 📄 共检测到 8 页数据

▶ 正在抓取第 1/8 页...
2025-01-29 14:30:02 [INFO] ✓ 成功获取: https://...?page=1 (尝试 1/3)
2025-01-29 14:30:02 [INFO]   解析出 15 家企业
2025-01-29 14:30:03 [INFO] ✨ 新增企业: 杭州XX科技有限公司 (岗位数: 15)
2025-01-29 14:30:03 [INFO] ✨ 新增企业: 宁波YY智能制造 (岗位数: 8)
...

▶ 正在抓取第 2/8 页...
2025-01-29 14:30:05 [INFO] ✓ 成功获取: https://...?page=2 (尝试 1/3)
2025-01-29 14:30:05 [INFO]   解析出 15 家企业
2025-01-29 14:30:06 [INFO] 🔄 更新企业: 温州ZZ电子商(岗位数 5 → 12)
...

==================================================
✅ 爬取任务完成!
📊 新增企业: 103 家
🔄 更新企业: 12 家
💾 数据库路径: data/job_fair.db
📄 CSV文件: data/export.csv
==================================================

CSV文件示例(前5行):

id company_name industry job_count job_fair_id url crawl_time update_time
1 杭州XX科技有限公司 互联网/电子商务 15 2025_spring https://... 2025-01-29 14:30:03 2025-01-29 14:30:03
2 宁波YY智能制造 先进制造业 8 2025_spring https://... 2025-01-29 14:30:03 2025-01-29 14:30:03
3 温州ZZ电子商务 电子商务 12 2025_spring https://... 2025-01-29 14:30:06 2025-01-29 14:35:21
4 绍兴AA纺织科技 传统制造业 6 2025_spring https://... 2025-01-29 14:30:07 2025-01-29 14:30:07
5 嘉兴BB新能源 新能源/环保 20 2025_spring https://... 2025-01-29 14:30:08 2025-01-29 14:30:08

🔟 常见问题与排错(强烈建议收藏)

问题1:抓到403 Forbidden

症状response.status_code == 403

可能原因与解决方案:

原因 解决方案
User-Agent被识别为爬虫 更换为最新版本Chrome的UA
缺少Referer头 在headers中添加正确的Referer
IP被临时封禁 降低抓取频率,增加sleep时间到5-10秒
需要cookie验证 使用Session对象,或手动添加cookie
python 复制代码
# 应急方案:手动添加cookie
HEADERS['Cookie'] = 'session_id=xxx; user_token=yyy'

问题2:抓到429 Too Many Requests

症状:频繁出现429状态码

解决方案

python 复制代码
# 动态调整请求间隔
import random

def smart_sleep():
    """智能等待,避免规律性被识别"""
    delay = random.uniform(2, 5)  # 2-5秒随机间隔
    time.sleep(delay)

# 在每次请求后调用
smart_sleep()

问题3:HTML抓到了但解析为空

症状len(companies) == 0,但查看源码有数据

排查步骤:

  1. 确认是否动态渲染

    python 复制代码
    # 打印HTML源码前500字符
    print(response.text[:500])
    
    # 如果看到<div id="app"></div>这类空壳,说明是JS渲染
  2. 检查XPath选择器

    python 复制代码
    # 在浏览器F12中测试XPath
    $x('//div[@class="company-item"]')  # Chrome控制台
    
    # 注意class可能是动态生成的,如"company-item-a3b2c"
    # 改用包含匹配://div[contains(@class, "company-item")]
  3. 查看是否有iframe嵌套

    python 复制代码
    # 检查是否有iframe标签
    tree.xpath('//iframe/@src')

问题4:编码乱码问题

症状 :中文显示为\xe4\xb8\xad\xe6\x96\x87

解决方案:

python 复制代码
# 方法1:让requests自动检测编码
response.encoding = response.apparent_encoding

# 方法2:手动指定编码
response.encoding = 'utf-8'

# 方法3:使用chardet库检测
import chardet
encoding = chardet.detect(response.content)['encoding']
html = response.content.decode(encoding)

问题5:XPath提取结果为空列表

症状company_name_list = []

调试技巧:

python 复制代码
# 逐层定位问题
tree = etree.HTML(html_content)

# 1. 先检查大容器能否找到
containers = tree.xpath('//div[@class="company-list"]')
print(f"找到{len(containers)}个容器")

# 2. 再检查子元素
if containers:
    items = containers[0].xpath('.//div[@class="company-item"]')
    print(f"找到{len(items)}个企业卡片")

# 3. 最后检查目标字段
if items:
    names = items[0].xpath('.//h3[@class="company-name"]/text()')
    print(f"第一个企业名称: {names}")

问题6:数据库插入失败

症状sqlite3.IntegrityError: UNIQUE constraint failed

原因:违反了唯一性约束

解决方案

python 复制代码
# 使用 INSERT OR IGNORE 忽略重复记录
self.cursor.execute('''
    INSERT OR IGNORE INTO companies (...) VALUES (...)
''')

# 或者使用 INSERT OR REPLACE 覆盖旧记录
self.cursor.execute('''
    INSERT OR REPLACE INTO companies (...) VALUES (...)
''')

1️⃣1️⃣ 进阶优化(锦上添花)

优化1:多线程并发抓取

使用 concurrent.futures 实现线程池:

python 复制代码
from concurrent.futures import ThreadPoolExecutor, as_completed

def crawl_page_wrapper(page_num):
    """线程任务包装函数"""
    try:
        response = fetch_page(base_url, params={'page': page_num})
        if response:
            companies = parse_company_list(response.text)
            return (page_num, companies)
    except Exception as e:
        logging.error(f"第{page_num}页抓取异常: {str(e)}")
    return (page_num, [])

def main_concurrent():
    """并发版本主函数"""
    db = JobFairDB()
    total_pages = 8  # 假设已知总页数
    
    # 使用3个线程并发抓取
    with ThreadPoolExecutor(max_workers=3) as executor:
        futures = {
            executor.submit(crawl_page_wrapper, page): page 
            for page in range(1, total_pages + 1)
        }
        
        for future in as_completed(futures):
            page_num, companies = future.result()
            logging.info(f"第{page_num}页完成,解析{len(companies)}家企业")
            
            for company in companies:
                db.insert_or_update(company, '2025_spring')
    
    db.export_to_csv()

注意事项

  • 线程数不要超过5,避免给服务器造成压力
  • SQLite不支持真正的并发写入,需要加锁或改用MySQL

优化2:断点续跑机制

记录已抓取的页面,意外中断后可继续:

python 复制代码
import json
import os

PROGRESS_FILE = 'data/progress.json'

def save_progress(current_page):
    """保存当前进度"""
    with open(PROGRESS_FILE, 'w') as f:
        json.dump({'last_page': current_page}, f)

def load_progress():
    """加载上次进度"""
    if os.path.exists(PROGRESS_FILE):
        with open(PROGRESS_FILE, 'r') as f:
            data = json.load(f)
            return data.get('last_page', 0)
    return 0

def main_with_resume():
    """支持断点续跑的主函数"""
    start_page = load_progress() + 1
    logging.info(f"▶ 从第{start_page}页继续抓取")
    
    for page_num in range(start_page, total_pages + 1):
        # ... 抓取逻辑 ...
        save_progress(page_num)  # 每页完成后保存进度

优化3:日志与监控

更详细的日志记录和统计:

python 复制代码
import time

class CrawlerStats:
    """爬虫统计类"""
    def __init__(self):
        self.start_time = time.time()
        self.total_requests = 0
        self.success_requests = 0
        self.failed_requests = 0
        self.total_companies = 0
    
    def record_request(self, success=True):
        self.total_requests += 1
        if success:
            self.success_requests += 1
        else:
            self.failed_requests += 1
    
    def add_companies(self, count):
        self.total_companies += count
    
    def report(self):
        elapsed = time.time() - self.start_time
        success_rate = (self.success_requests / self.total_requests * 100 
                       if self.total_requests > 0 else 0)
        
        report = f"""
╔══════════════════════════════════════╗
║        爬虫任务统计报告               ║
╠══════════════════════════════════════╣
║ 总耗时:       {elapsed:.2f} 秒        
║ 总请求数:     {self.total_requests}          
║ 成功请求:     {self.success_requests}          
║ 失败请求:     {self.failed_requests}          
║ 成功率:       {success_rate:.2f}%       
║ 采集企业数:   {self.total_companies}          
║ 平均速度:     {self.total_companies/elapsed:.2f} 企业/秒
╚══════════════════════════════════════╝
        """
        logging.info(report)

优化4:定时任务部署

使用 APScheduler 实现定时抓取:

python 复制代码
from apscheduler.schedulers.blocking import BlockingScheduler

def scheduled_crawl():
    """定时任务包装函数"""
    logging.info("⏰ 定时任务触发,开始抓取...")
    main()

if __name__ == '__main__':
    scheduler = BlockingScheduler()
    
    # 每天早上9点执行
    scheduler.add_job(scheduled_crawl, 'cron', hour=9, minute=0)
    
    # 或者每隔6小时执行一次
    # scheduler.add_job(scheduled_crawl, 'interval', hours=6)
    
    logging.info("⏱ 定时任务已启动,等待触发...")
    scheduler.start()

Linux cron 方式

bash 复制代码
# 编辑crontab
crontab -e

# 每天9:00执行
0 9 * * * cd /path/to/project && /usr/bin/python3 crawler.py >> logs/cron.log 2>&1

1️⃣2️⃣ 总结与延伸阅读

项目复盘

通过本项目,我们完整实现了一个生产级的招聘会企业信息采集系统,包括:

请求层 :支持重试、超时控制、频率限制的HTTP请求封装

解析层 :基于XPath的高效HTML解析,容错性强

存储层 :SQLite数据库去重、增量更新、导出功能

工程化:日志记录、异常处理、进度保存、统计报告

核心收获:

  1. 分页处理:先获取总页数,再循环抓取所有页面
  2. 去重策略:数据库唯一约束 + 应用层判断双重保障
  3. 增量更新:通过时间戳和版本号实现智能更新
  4. 容错设计:每一层都有异常捕获和降级处理

下一步可以做什么?

1. 升级到 Scrapy 框架

Scrapy 是工业级爬虫框架,提供:

  • 自动去重(通过URL fingerprint)
  • 中间件机制(请求/响应拦截)
  • 分布式支持(Scrapy-Redis)
  • 内置的 Item Pipeline

学习路径

2. 处理动态渲染网站

如果目标站点使用 React/Vue 等框架,需要:

  • Playwright:新一代浏览器自动化工具,比 Selenium 更快更稳定
  • Puppeteer:Chrome DevTools Protocol 的 Python 绑定
  • 接口逆向:通过浏览器开发者工具抓包分析 API 接口

推荐阅读

3. 引入代理池与验证码识别

对于反爬更严格的网站:

  • 代理池:轮换IP地址,避免被封

  • 验证码识别

    • 图形验证码:ddddocr 库
    • 滑块验证码:人工打码平台
    • reCAPTCHA:2captcha 等第三方服务
4. 数据清洗与分析

采集到的原始数据需要进一步处理:

python 复制代码
import pandas as pd
import jieba
from collections import Counter

# 行业分布分析
df = pd.read_csv('data/export.csv')
industry_counts = df['industry'].value_counts()

# 岗位数量分析
print(f"平均岗位数: {df['job_count'].mean():.2f}")
print(f"最大岗位数: {df['job_count'].max()}")

# 企业名称词云分析
all_names = ' '.join(df['company_name'])
words = jieba.cut(all_names)
word_freq = Counter(words)
5. 搭建可视化看板

使用 Streamlit 或 Dash 快速搭建数据看板:

python 复制代码
# dashboard.py
import streamlit as st
import pandas as pd
import plotly.express as px

st.title("📊 招聘会企业数据看板")

df = pd.read_csv('data/export.csv')

# 行业分布饼图
fig = px.pie(df, names='industry', title='行业分布')
st.plotly_chart(fig)

# 岗位数量柱状图
fig2 = px.bar(df.nlargest(20, 'job_count'), 
              x='company_name', y='job_count',
              title='Top 20 招聘岗位最多的企业')
st.plotly_chart(fig2)

运行:streamlit run dashboard.py

参考资源

书籍推荐:

  • 《Python网络爬虫从入门到实践》(第2版)
  • 《Web Scraping with Python》 by Ryan Mitchell
  • 《精通Python爬虫框架Scrapy》

开源项目学习:

工具与库:

最后的话

爬虫技术本质上是数据获取的自动化,但请务必记住:

技术无罪,应用有边界。

在享受自动化带来的便利时,请始终遵守法律法规、尊重网站权益、保护用户隐私。合理使用爬虫技术,它可以成为数据分析、学术研究、商业智能的有力工具;滥用则可能触犯法律,得不偿失。

祝你在数据采集的道路上走得更远!如果本文对你有帮助,欢迎点赞收藏,也欢迎在评论区分享你的爬虫实战经验~

🌟 文末

好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

📌 专栏持续更新中|建议收藏 + 订阅

专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?

评论区留言告诉我你的需求,我会优先安排更新 ✅


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)

⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)

⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。技术无罪,责任在人!!!

相关推荐
小鸡吃米…1 小时前
TensorFlow 实现循环神经网络
人工智能·python·tensorflow
阿钱真强道2 小时前
14 ThingsBoard实战:从零搭建设备配置+设备,完成MQTT温湿度上行/目标温度下行测试(对比JetLinks)
java·网络·python·网络协议
ssswywywht2 小时前
python练习
开发语言·python
PD我是你的真爱粉2 小时前
RabbitMQRPC与死信队列
后端·python·中间件
喵手2 小时前
Python爬虫实战:医院科室排班智能采集系统 - 从零构建合规且高效的医疗信息爬虫(附CSV导出 + SQLite持久化存储)!
爬虫·python·爬虫实战·零基础python爬虫教学·医院科室排版智能采集系统·采集医疗信息·采集医疗信息sqlite存储
郝学胜-神的一滴2 小时前
贝叶斯之美:从公式到朴素贝叶斯算法的实践之旅
人工智能·python·算法·机器学习·scikit-learn
好家伙VCC2 小时前
**发散创新:用 Rust构建多智能体系统,让分布式协作更高效**在人工智能快速演进的今天,**多智能体系统(
java·人工智能·分布式·python·rust
梦幻精灵_cq2 小时前
*终端渲染天花板:文心道法解码——闲聊终端渲染状态一统江山
python
yuanmenghao2 小时前
Linux 性能实战 | 第 18 篇:ltrace 与库函数性能分析
linux·python·性能优化