前言

就业市场的信息获取效率直接影响求职者的决策与企业的招聘效果,58 同城作为综合性生活服务平台,汇聚了海量的全职、兼职招聘信息,涵盖行业、薪资、工作地点、岗位职责等核心维度。通过 Python 爬虫技术系统化抓取这些招聘数据,既能为求职者提供多维度的岗位对比依据,也可用于就业市场的行业薪资分析、岗位需求趋势研究等专业场景。本文将从技术原理、实战开发、数据处理等维度,完整讲解如何合规、高效地爬取 58 同城招聘信息,兼顾代码的稳定性与数据的结构化呈现。

摘要

本文聚焦 58 同城招聘信息的爬虫实战开发,详细阐述了基于requests库的 HTTP 请求构建、BeautifulSoup库的 HTML 解析、数据清洗与 CSV 结构化存储的全流程。通过实战案例,以58 同城北京招聘页面为爬取目标,实现岗位名称、薪资范围、招聘企业、工作地点、岗位类型、发布时间、岗位职责、岗位链接等核心字段的抓取,并通过表格可视化展示爬取结果,同时讲解 58 同城反爬策略规避、数据合法性等关键问题。最终输出标准化的招聘数据集,为后续的就业市场分析奠定基础。

一、技术原理与环境准备

1.1 核心技术栈说明

本次爬虫开发依赖的 Python 库及核心作用如下表所示,确保版本适配以避免兼容性问题:

库名称 版本要求 核心作用
requests ≥2.31.0 发送 HTTP/HTTPS 请求,获取网页源代码
BeautifulSoup4 ≥4.12.0 解析 HTML 文档,定位并提取目标数据字段
csv 内置库 将爬取的非结构化数据存储为标准化 CSV 文件
time 内置库 设置请求间隔,降低反爬触发概率
fake-useragent ≥1.4.0 生成随机 User-Agent,模拟真实浏览器请求
lxml ≥4.9.3 作为 BeautifulSoup 解析器,提升 HTML 解析效率

1.2 环境搭建

执行以下命令安装第三方依赖库,建议在虚拟环境中操作以避免环境冲突:

bash

运行

pip install requests beautifulsoup4 fake-useragent lxml

1.3 反爬机制与规避思路

58 同城针对爬虫设置了多维度的反爬策略,核心及对应规避方案如下:

反爬策略 具体表现 规避思路
User-Agent/Referer 检测 非浏览器请求头返回空数据或 403 错误 使用 fake-useragent 生成随机合法 User-Agent,Referer 设置为 58 同城首页
高频请求限制 短时间多次请求触发 IP 封禁 / 滑块验证码 设置 3-8 秒随机请求间隔,单 IP 请求频率≤3 次 / 分钟
Cookie 验证 部分页面需有效 Cookie 才能加载完整招聘信息 采用 Session 维持 Cookie 状态,或从浏览器手动提取有效 Cookie 补充
页面结构动态混淆 核心数据类名 / 标签不定期随机变化 增加多维度定位逻辑(如标签 + 文本特征),降低页面更新影响
虚假数据干扰 页面混入无效占位数据,干扰爬虫提取 增加数据有效性校验(如薪资格式、企业名称非空)

二、实战开发:爬取 58 同城招聘信息

2.1 目标分析

本次爬取目标为58 同城北京招聘列表页面,需提取的核心字段如下:

  • 岗位名称
  • 薪资范围(如 8k-15k / 月)
  • 招聘企业名称
  • 工作地点(含行政区 + 具体地址)
  • 岗位类型(全职 / 兼职 / 实习)
  • 发布时间(如 1 小时前 / 昨天)
  • 岗位职责(核心任职要求)
  • 岗位详情链接

2.2 完整代码实现

python

运行

import requests
from bs4 import BeautifulSoup
import csv
import time
import random
from fake_useragent import UserAgent

# 初始化UserAgent生成器
ua = UserAgent()
# 创建Session维持Cookie状态
session = requests.Session()

# 目标URL(北京招聘列表页)
base_url = "https://bj.58.com/job/"
# 存储爬取数据的列表
job_data = []

# 请求头配置(可补充浏览器真实Cookie提升稳定性)
headers = {
    "User-Agent": ua.random,
    "Referer": "https://www.58.com/",
    "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
    "Accept-Encoding": "gzip, deflate, br",
    "Connection": "keep-alive",
    # 可选:补充浏览器Cookie,格式示例
    # "Cookie": "58home=bj; id58=xxx; city=bj;"
}

def clean_text(text):
    """
    清洗文本数据,去除冗余空格、换行符等
    :param text: 原始文本
    :return: 清洗后的文本
    """
    if not text:
        return "未知"
    # 去除多余空格、换行、制表符
    return " ".join(text.strip().split())

def get_job_detail(detail_url):
    """
    爬取岗位详情页的岗位职责信息
    :param detail_url: 岗位详情链接
    :return: 岗位职责文本
    """
    try:
        # 发送详情页请求,设置更长超时时间
        response = session.get(detail_url, headers=headers, timeout=20)
        response.raise_for_status()
        response.encoding = "utf-8"
        soup = BeautifulSoup(response.text, "lxml")
        
        # 定位岗位职责(多维度匹配,适配页面结构变化)
        duty_tags = [
            soup.find("div", class_="job_intro"),
            soup.find("div", attrs={"data-name": "jobDesc"}),
            soup.find("div", class_="desc_con")
        ]
        duty_text = "未知"
        for tag in duty_tags:
            if tag:
                duty_text = clean_text(tag.get_text())
                break
        
        # 详情页爬取后休眠,降低频率
        time.sleep(random.uniform(1, 3))
        return duty_text
    except Exception as e:
        print(f"详情页爬取异常 {detail_url}:{e}")
        return "未知"

def get_job_data(page_num):
    """
    爬取指定页码的招聘数据
    :param page_num: 页码数
    """
    # 构造分页URL(58同城招聘分页参数为pn)
    page_url = f"{base_url}pn{page_num}/" if page_num > 1 else base_url
    try:
        # 发送GET请求,设置超时时间15秒
        response = session.get(page_url, headers=headers, timeout=15)
        # 验证请求状态码
        response.raise_for_status()
        # 统一编码格式
        response.encoding = "utf-8"
        # 解析HTML文档
        soup = BeautifulSoup(response.text, "lxml")
        
        # 定位招聘列表容器(多类名匹配,适配页面更新)
        job_list = soup.find_all("div", class_=["job_item", "job_con_item"])
        if not job_list:
            print(f"第{page_num}页未找到招聘数据,可能触发反爬或页面结构更新")
            return
        
        # 遍历提取每条招聘数据
        for item in job_list:
            # 岗位名称
            job_title = clean_text(item.find("a", class_=["job_name", "title"]).get_text()) if item.find("a", class_=["job_name", "title"]) else "未知"
            # 薪资范围
            salary = clean_text(item.find("span", class_=["salary", "job_salary"]).get_text()) if item.find("span", class_=["salary", "job_salary"]) else "未知"
            # 招聘企业
            company = clean_text(item.find("a", class_=["comp_name", "company_name"]).get_text()) if item.find("a", class_=["comp_name", "company_name"]) else "未知"
            # 工作地点
            location = clean_text(item.find("span", class_=["work_addr", "job_address"]).get_text()) if item.find("span", class_=["work_addr", "job_address"]) else "未知"
            # 岗位类型
            job_type = clean_text(item.find("span", class_=["job_type", "type"]).get_text()) if item.find("span", class_=["job_type", "type"]) else "全职"
            # 发布时间
            publish_time = clean_text(item.find("span", class_=["publish_time", "time"]).get_text()) if item.find("span", class_=["publish_time", "time"]) else "未知"
            # 岗位链接
            link_tag = item.find("a", class_=["job_name", "title"])
            job_link = link_tag["href"] if (link_tag and "href" in link_tag.attrs) else "未知"
            # 补全链接(相对路径转绝对路径)
            if job_link.startswith("/"):
                job_link = f"https://bj.58.com{job_link}"
            
            # 爬取岗位职责(详情页)
            duty = get_job_detail(job_link) if job_link != "未知" else "未知"
            
            # 封装数据为字典
            job_item = {
                "岗位名称": job_title,
                "薪资范围": salary,
                "招聘企业": company,
                "工作地点": location,
                "岗位类型": job_type,
                "发布时间": publish_time,
                "岗位职责": duty,
                "岗位链接": job_link
            }
            job_data.append(job_item)
            # 打印单条数据,便于实时监控
            print(f"第{page_num}页已爬取:{job_title} - {salary} - {company}")
        
        # 随机休眠3-8秒,规避反爬
        time.sleep(random.uniform(3, 8))
        
    except requests.exceptions.RequestException as e:
        print(f"第{page_num}页请求异常:{e}")
    except Exception as e:
        print(f"第{page_num}页数据解析异常:{e}")

def save_to_csv(data, filename="58同城北京招聘信息.csv"):
    """
    将爬取的招聘数据保存为CSV文件
    :param data: 爬取的岗位数据列表
    :param filename: 保存的文件名
    """
    if not data:
        print("无有效数据可保存")
        return
    
    # 定义CSV表头(与字典键对应)
    csv_headers = ["岗位名称", "薪资范围", "招聘企业", "工作地点", "岗位类型", "发布时间", "岗位职责", "岗位链接"]
    
    # 写入CSV文件,utf-8-sig确保中文正常显示
    with open(filename, "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=csv_headers)
        writer.writeheader()
        writer.writerows(data)
    
    print(f"\n数据保存完成!共爬取{len(data)}条招聘数据,文件路径:{filename}")

if __name__ == "__main__":
    print("开始爬取58同城招聘信息...")
    # 爬取1-2页数据(58反爬严格,建议少量测试)
    for page in range(1, 3):
        print(f"\n========= 开始爬取第{page}页数据 =========")
        get_job_data(page)
    # 保存数据到CSV
    save_to_csv(job_data)
    print("\n爬取任务全部完成!")

2.3 代码核心原理解析

(1)Session 与请求头强化

使用requests.Session()维持 Cookie 状态,解决 58 同城的 Cookie 验证机制;请求头中除随机 User-Agent 外,补充Referer为 58 首页,模拟真实用户访问路径,同时设置Accept-EncodingAccept-Language,进一步降低反爬识别概率。

(2)多维度数据提取逻辑
  • 类名容错:58 同城页面类名易更新,采用多类名匹配(如["job_name", "title"]),避免单一类名失效导致数据提取失败;
  • 文本清洗:自定义clean_text函数,去除文本中的冗余空格、换行符、制表符,保证数据整洁;
  • 详情页爬取:通过get_job_detail函数爬取岗位职责,采用多标签匹配定位核心内容,适配不同的详情页结构;
  • 链接补全:将相对路径的岗位链接补全为绝对路径,确保可直接访问。
(3)分页爬取与频率控制

58 同城招聘分页参数为pn(第 2 页为pn2),通过条件判断构造分页 URL;每页爬取后设置 3-8 秒随机休眠,详情页额外设置 1-3 秒休眠,严格控制请求频率,避免触发 IP 封禁或验证码。

(4)异常处理机制
  • 网络异常:捕获requests.exceptions.RequestException处理超时、连接失败、403/404 等请求异常;
  • 解析异常:捕获通用Exception处理数据解析过程中的格式错误、索引越界等问题;
  • 详情页异常:单独捕获详情页爬取异常,避免单条详情页失败影响整页数据提取;
  • 数据空值:增加 “未找到招聘数据” 判断,及时反馈反爬触发或页面结构更新。
(5)数据存储设计

将数据封装为字典列表,通过csv.DictWriter写入 CSV 文件,utf-8-sig编码确保中文在 Excel/WPS 中正常显示;表头覆盖核心招聘维度,便于后续的薪资分析、岗位需求统计等场景。

三、输出结果展示

3.1 控制台输出示例

plaintext

开始爬取58同城招聘信息...

========= 开始爬取第1页数据 =========
第1页已爬取:Python开发工程师 - 10k-20k/月 - 北京XX科技有限公司
第1页已爬取:新媒体运营 - 6k-12k/月 - 北京XX文化传媒有限公司
第1页已爬取:销售代表 - 8k-15k/月 - 北京XX商贸有限公司

========= 开始爬取第2页数据 =========
第2页已爬取:UI设计师 - 9k-18k/月 - 北京XX设计有限公司
第2页已爬取:行政助理 - 5k-8k/月 - 北京XX企业管理有限公司

数据保存完成!共爬取5条招聘数据,文件路径:58同城北京招聘信息.csv

爬取任务全部完成!

3.2 CSV 文件输出示例

岗位名称 薪资范围 招聘企业 工作地点 岗位类型 发布时间 岗位职责 岗位链接
Python 开发工程师 10k-20k / 月 北京 XX 科技有限公司 朝阳区望京 SOHO 全职 1 小时前 1. 负责后端接口开发;2. 参与数据库设计;3. 配合前端联调。要求:熟练掌握 Django/Flask 框架,有 2 年以上开发经验。 https://bj.58.com/job/123456.html
新媒体运营 6k-12k / 月 北京 XX 文化传媒有限公司 海淀区中关村 全职 昨天 1. 负责公众号、小红书内容编辑;2. 策划线上营销活动;3. 统计运营数据。要求:有 1 年以上新媒体运营经验,会 PS 优先。 https://bj.58.com/job/789012.html
销售代表 8k-15k / 月 北京 XX 商贸有限公司 丰台区马家堡 全职 3 天前 1. 开发新客户,维护老客户;2. 完成月度销售目标;3. 跟进客户订单。要求:沟通能力强,能接受出差。 https://bj.58.com/job/345678.html
UI 设计师 9k-18k / 月 北京 XX 设计有限公司 东城区东直门 全职 1 天前 1. 负责产品界面设计;2. 制作设计原型;3. 配合开发还原设计。要求:熟练使用 Figma/PS,有电商设计经验优先。 https://bj.58.com/job/901234.html

四、进阶优化方向

4.1 动态页面适配

58 同城部分招聘数据通过 AJAX 动态加载,单纯requests无法获取完整数据,可通过以下方案优化:

  1. 接口抓包:F12 开发者工具→Network→XHR,定位招聘数据接口(JSON 格式),直接请求接口获取数据,示例:

python

运行

# 示例:58同城招聘AJAX接口请求
ajax_url = "https://bj.58.com/job/ajax/get_job_list/?pn=1&city=bj"
response = session.get(ajax_url, headers=headers)
data = response.json()
# 解析JSON数据...
  1. 浏览器模拟:使用Playwright模拟浏览器渲染,自动处理动态加载、Cookie 验证等问题,示例:

python

运行

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=False)
    page = browser.new_page()
    page.goto(base_url)
    # 等待数据加载
    page.wait_for_selector(".job_item")
    # 提取页面源码
    html = page.content()
    soup = BeautifulSoup(html, "lxml")
    # 解析数据...
    browser.close()

4.2 数据清洗与标准化

  1. 薪资标准化:从薪资范围中提取最低 / 最高薪资(如 “10k-20k / 月”→最低 10000,最高 20000),便于数值分析;
  2. 地点标准化:拆分工作地点为 “行政区” 和 “具体地址”,便于区域统计;
  3. 去重处理:基于岗位名称 + 企业名称 + 工作地点组合去重,避免重复爬取同一岗位;
  4. 无效数据过滤:过滤薪资为 “面议”、企业名称为空的无效数据。

4.3 多线程 / 协程优化

针对少量数据爬取场景,引入threading多线程提升效率(58 反爬严格,不建议大规模并发):

python

运行

import threading
from queue import Queue

# 定义任务队列
q = Queue()
# 填充页码队列
for page in range(1, 3):
    q.put(page)

def crawl_worker():
    while not q.empty():
        page = q.get()
        get_job_data(page)
        q.task_done()

# 启动2个线程
for _ in range(2):
    t = threading.Thread(target=crawl_worker)
    t.start()
q.join()

4.4 验证码自动处理

若触发滑块验证码,可引入ddddocr库识别验证码,结合requests模拟滑动:

python

运行

import ddddocr
import json

ocr = ddddocr.DdddOcr()
# 抓取验证码图片
captcha_url = "https://cdn.58.com/captcha/xxx.jpg"
captcha_img = session.get(captcha_url).content
# 识别验证码缺口位置
res = ocr.slide_match(captcha_img, target_img, simple_target=True)
# 模拟滑动请求
slide_data = {"distance": res["target"][0], "track": []}
session.post("https://bj.58.com/captcha/slide", data=json.dumps(slide_data))

4.5 可视化分析

基于爬取的数据,使用pandas+pyecharts实现薪资分布可视化:

python

运行

import pandas as pd
from pyecharts import options as opts
from pyecharts.charts import Histogram

# 读取数据
df = pd.read_csv("58同城北京招聘信息.csv")
# 提取薪资下限
df["薪资下限"] = df["薪资范围"].str.extract(r"(\d+)k").astype(float) * 1000
# 绘制薪资分布直方图
hist = (
    Histogram()
    .add_xaxis(["0-5k", "5k-10k", "10k-15k", "15k-20k", "20k+"])
    .add_yaxis("岗位数量", [
        len(df[df["薪资下限"] < 5000]),
        len(df[(df["薪资下限"] >= 5000) & (df["薪资下限"] < 10000)]),
        len(df[(df["薪资下限"] >= 10000) & (df["薪资下限"] < 15000)]),
        len(df[(df["薪资下限"] >= 15000) & (df["薪资下限"] < 20000)]),
        len(df[df["薪资下限"] >= 20000])
    ])
    .set_global_opts(title_opts=opts.TitleOpts(title="北京招聘岗位薪资分布"))
)
hist.render("薪资分布.html")

五、合规性说明

  1. 本次爬取仅针对 58 同城公开的招聘信息,未涉及企业商业机密、求职者隐私等敏感数据;
  2. 爬取频率严格控制在合理范围(单页休眠 3-8 秒),未对 58 同城服务器造成流量压力,符合 “善意爬取” 原则;
  3. 爬取的数据仅用于学习、研究目的,禁止用于商业用途;
  4. 遵守 58 同城用户协议及 robots.txt 规则(58 同城 robots.txt:https://www.58.com/robots.txt),如需批量爬取请提前联系平台获取授权;
  5. 若爬取过程中触发验证码、IP 封禁等反爬机制,应立即停止爬取,待解除限制后再操作,避免违规;
  6. 不得将爬取的招聘信息用于虚假招聘、恶意竞争等违法违规行为。

六、总结

本文通过完整的实战案例,讲解了 58 同城招聘信息的爬虫开发全流程,涵盖环境搭建、请求构建、多维度数据提取、详情页爬取、分页控制、结构化存储等核心环节。代码具备良好的容错性与适配性,可通过修改目标 URL 中的城市缩写(如上海为sh、广州为gz)适配不同城市的招聘数据爬取。同时,强调了爬虫开发的合规性与道德准则,避免触碰法律红线。后续可基于爬取的数据开展行业薪资分析、岗位需求趋势研究、区域就业市场对比等深度应用,进一步挖掘数据价值。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐