招聘网站爬虫设计源码解析:架构、模块与反爬实战

发布时间:2026/8/31 16:58:06
招聘网站爬虫设计源码解析:架构、模块与反爬实战 简介本资源是一套面向Python初学者与数据采集实践者的招聘网站数据爬虫完整源码聚焦人力资源领域职位信息自动化采集场景解决企业HR、求职分析者及数据分析学习者对结构化招聘数据的获取需求。压缩包共33个文件含10个核心Python脚本如pachong.py、14个文本文件含readme.txt使用说明与HTML原始页存档、4个XML配置文件、3个CSV结果文件及IDE与Git配置文件整体2.93MB其中py文件构成爬虫主逻辑txt用于日志与网页快照CSV便于后续导入分析XML支撑结构化规则定义。已有511人学习下载资源采用模块化目录设计如zhilianzhaopin_python1/2/3多版本迭代结构附带HTML页面样本与分步to_csv转换逻辑可直接运行调试、对比不同解析策略效果并为反反爬机制扩展与多平台适配提供清晰代码基线。1. 为什么是招聘网站爬虫练手的最佳试验场先说说我为什么盯上招聘网站。做爬虫这个方向很多人一上来就去爬电商、爬社交平台结果被验证码、登录墙、风控系统折磨得欲哭无泪。招聘网站不一样它有几个天然优势特别适合作为爬虫设计的实战项目。第一招聘网站的信息结构高度规整。岗位名称、薪资范围、学历要求、经验要求、公司名称、工作地点这些字段本身就存在于页面的HTML、JSON或者接口返回数据里结构清晰几乎不需要做复杂的信息抽取。你不需要像爬新闻那样做正文提取也不需要像爬评论那样做翻页拼接数据解析逻辑相对简单能让你把精力集中在爬虫框架本身的搭建上。第二招聘网站的数据更新频率非常高。新岗位每天都在发布旧岗位每天也在下线这意味着你的爬虫系统设计好之后可以通过增量抓取、定时调度、数据对比这些功能快速验证效果。我见过很多人写爬虫抓完一次就扔了但招聘网站这种高频更新场景会逼着你思考增量爬取、去重策略、异常重试这些真正工程化的问题。第三招聘网站的数据价值可以直接兑现。我自己就靠这套思路在换城市找工作的时候把目标城市、目标岗位、薪资区间过滤好的数据直接推到本地Excel简历投递效率翻了不止一倍。而且从合规角度来看招聘网站上的岗位信息属于企业主动公开的招聘信息在遵守robots协议、控制抓取频率、不破坏网站正常服务的前提下用于个人学习研究目的是相对安全的爬虫练习对象。这一点对其他类型网站来说边界要模糊得多。标题里提到的设计源码我理解不仅仅是能跑通的代码更是一套有设计感、有工程思维、能应对真实环境的解决方案。这篇文章我会从整体架构讲起把每一层设计的原因说清楚再给到核心模块的代码实现最后聊聊我在实际抓取过程中踩过的坑——尤其是那些容易被忽略、但真实存在的稳定性问题。2. 整体架构设计别一上来就写代码这是我最想强调的一点。很多人拿到这个项目第一反应是不就是 requests 加上 BeautifulSoup 嘛然后直接开写。结果写完之后发现网站反爬升级了代码失效或者抓了三千条数据里面有五百条重复又或者对方服务器稍微慢一点程序就卡死在那里。这些问题的根源不是代码水平不行而是缺少架构层面的设计。2.1 数据流向采集、解析、存储三种角色的拆分招聘网站爬虫我把它拆成三个独立的逻辑角色采集层负责发起网络请求拿到原始的HTML或JSON数据。这一层只关心能不能拿到数据不关心数据长什么样。解析层负责从原始数据中提取结构化字段得到统一的岗位数据模型。这一层只关心怎么从垃圾里挑出金子不关心数据从哪个URL来的。存储层负责把解析后的数据写入数据库、文件或者内存同时处理去重逻辑。这一层只关心数据怎么存、怎么避免重复。这样的分层设计有一个核心好处每一层都可以独立替换。比如你今天用 requests 抓取明天想换成 httpx 或者 aiohttp 做异步请求只需要改采集层今天数据存 CSV明天想存 MySQL只需要改存储层。各层之间通过标准的数据结构传递信息互不干扰。2.2 技术选型requests 还是 Scrapy技术选型是绕不开的问题。Scrapy 确实是工业级爬虫框架自带并发调度、去重、中间件、Pipeline功能很齐全。但我个人建议在这个项目上第一版用 requests BeautifulSoup/lxml 手写一个轻量框架而不是直接上 Scrapy。原因有两点。第一招聘网站的数据规模和应用场景远没有达到必须用 Scrapy 的地步。一个城市、一个岗位方向撑死了几万条数据requests 串行请求加简单并发就完全够用。第二手写框架能让你真正理解爬虫的每一个环节——请求头怎么构造、Session 怎么维持、Cookie 怎么处理、异常怎么重试、数据怎么去重。这些底层细节在 Scrapy 里都是被封装好的黑盒你用它的时候感觉不到一旦出了问题就无从下手。当然如果后续你把数据规模扩大到了全国所有城市、所有岗位或者要周期性全量抓取那 Scrapy 的分布式扩展能力就有价值了。但那是第二阶段的事先把基础打牢。2.3 目录结构代码组织决定了后期维护成本我第一版写爬虫的时候把所有代码堆在一个 main.py 里五百多行看起来好像很完整但改一个字段解析逻辑要找半天加一个请求头要全局搜索替换。后来我重构成了模块化结构维护效率提升了不止一个量级。推荐一个结构清晰、适合当前项目的目录组织方式job_crawler/ ├── main.py # 程序入口负责整体调度 ├── config.py # 配置文件请求头、URL模板、存储路径 ├── collector/ │ ├── __init__.py │ ├── requester.py # 采集层请求封装、代理、重试机制 │ └── url_manager.py # URL管理列表页URL生成、去重 ├── parser/ │ ├── __init__.py │ ├── list_parser.py # 解析列表页提取岗位详情页URL │ └── detail_parser.py # 解析详情页提取岗位字段数据 ├── storage/ │ ├── __init__.py │ ├── excel_writer.py # 存储到Excel │ └── deduplicator.py # 布隆过滤器去重 └── logs/ └── crawler.log # 日志文件这套结构的设计哲学是职责单一。每个模块只做一件事模块之间通过函数调用或者简单的类实例传递数据。新手可以直接抄这套结构它在绝大多数中大型爬虫项目中都是适用的。3. 核心模块代码剖析源码级拆解这一章是全文最重要的部分。我会逐段给出核心代码并解释每一段代码为什么这么写容易踩的坑在哪里。3.1 采集层Requests会话管理、重试、请求头伪装采集层是整个爬虫的地基地基不稳上面全是白搭。这里我直接给出一个封装好的采集模块代码并逐段注释。import random import time import requests from requests.adapters import HTTPAdapter from requests.packages.urllib3.util.retry import Retry from loguru import logger class Requester: 职责负责所有 HTTP 请求的发送、重试、异常处理。 设计要点 1. 使用 Session 保持连接避免每次请求重新建立 TCP 连接 2. 内置指数退避重试机制应对服务器限流 3. 请求头随机化模拟真实浏览器行为仅用于模拟不同浏览器的常规标识不属于任何绕过机制 # 常见的浏览器User-Agent池实际使用时可从外部配置加载 USER_AGENTS [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36, Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36, ] def __init__(self, timeout10, max_retries3, backoff_factor0.5): self.timeout timeout self.max_retries max_retries self.backoff_factor backoff_factor # 创建HTTPAdapter配置重试策略 retry_strategy Retry( totalmax_retries, status_forcelist[429, 500, 502, 503, 504], allowed_methods[GET, POST], backoff_factorbackoff_factor, # 重试间隔: {backoff_factor} * (2 ** (retry_count - 1)) ) adapter HTTPAdapter(max_retriesretry_strategy, pool_connections10, pool_maxsize10) # Session 全局复用连接池可有效降低握手的开销 self.session requests.Session() self.session.mount(http://, adapter) self.session.mount(https://, adapter) def _get_headers(self): 构造带随机UA的请求头。 return { User-Agent: random.choice(self.USER_AGENTS), Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive, } def get(self, url, paramsNone, encodingutf-8): GET 请求封装。 :param url: 目标URL :param params: 查询参数 :param encoding: 响应编码招聘网站多为utf-8但部分老站可能为gbk :return: 响应text文本失败返回None try: resp self.session.get( url, paramsparams, headersself._get_headers(), timeoutself.timeout, ) resp.raise_for_status() # 根据目标网站的charset设置编码 resp.encoding encoding return resp.text except requests.exceptions.RequestException as e: logger.error(f请求失败: {url}, 错误: {e}) return None def get_json(self, url, paramsNone): 如果目标网站提供 JSON 接口优先走这个函数。 当前很多招聘站点会在页面中内嵌接口返回的数据如 window.__INITIAL_STATE__ 或者直接由列表接口返回 JSON 数据解析效率远高于HTML。 try: resp self.session.get( url, paramsparams, headersself._get_headers(), timeoutself.timeout, ) resp.raise_for_status() return resp.json() except requests.exceptions.RequestException as e: logger.error(fJSON请求失败: {url}, 错误: {e}) return None这里有几个设计细节值得展开说。为什么用 Session 而不是裸 requests.get因为招聘网站的一次完整浏览过程通常涉及列表页、详情页等多个请求。如果每次请求都新建连接TCP 握手和 TLS 握手的开销会拖慢整体速度也更容易触发对方的频率限制。Session 会保存 Cookie 和连接池模拟真实浏览器的行为稳定性好很多。为什么配置 status_forcelistHTTP 429 是限流信号5xx 是服务器内部错误。这些状态码都是重试了大概率能成功的场景。相反404 和 403 不需要重试——404 是地址不存在重试一万次也没用403 是权限拒绝重试只会加重对方服务器的负担还会让 IP 被封得更久。这个区分很重要盲目重试所有状态码是新手最常见的错误。为什么设置 backoff_factor0.5这是 urllib3 重试机制的计算基准。重试间隔 backoff_factor × (2^(重试次数-1))所以第一次重试间隔约 0.5 秒第二次约 1 秒第三次约 2 秒。指数退避的核心理念是给服务器喘息的时间而不是像子弹一样疯狂连射。实际跑下来的体验是这种平滑的请求节奏能大大降低被封的概率。3.2 采集层列表页URL构造与请求频率控制列表页是爬虫的入口URL 构造逻辑直接决定了爬虫的覆盖范围。招聘网站的列表页 URL 通常有固定的规律以某招聘网站为例https://www.example.com/jobs?city北京keywordPythonpageNum1关键在于翻页参数的边界。第一页、第二页、第三页……翻到第几页才停止两个策略主动策略根据总岗位数除以每页数量计算总页数解析列表页最后出现的共N条信息得到 N再算出总页数。被动策略请求第 N 页时如果返回的列表为空说明已经到达末页停止翻页。被动策略更通用因为你不用依赖对方输出精确的总数。我写了一个 URL 管理器来统一处理翻页逻辑class UrlManager: 职责生成列表页 URL并维护已访问 URL 集合避免重复请求。 def __init__(self, base_url, city, keyword, max_page100): self.base_url base_url self.city city self.keyword keyword self.max_page max_page self.visited_urls set() # 用于记录已请求过的URL def generate_list_urls(self): 生成从第1页到max_page的列表页URL。 urls [] for page in range(1, self.max_page 1): url self.base_url.format(cityself.city, keywordself.keyword, pagepage) if url not in self.visited_urls: urls.append(url) return urls def mark_visited(self, url): self.visited_urls.add(url)你可能觉得这个类太简单不值得单独封装。但真实的项目里URL 生成会变得越来越复杂要处理多城市、多关键词、多岗位方向要支持时间范围过滤、薪资过滤、学历过滤等参数。把这些逻辑收敛到一个类里后面改需求的时候你只需要改一个地方而不是满项目搜索 URL 拼接代码。频率控制是另一个不得不说的点。有些招聘网站的列表接口没有严格的风控但有些会有 QPS每秒查询数限制。我常用的策略是固定延迟 随机抖动import time import random class Throttle: 简单频率控制器固定下限 随机上限模拟自然浏览节奏。 def __init__(self, min_interval1.0, max_interval3.0): self.min_interval min_interval self.max_interval max_interval self.last_request_time 0 def wait(self): 在每次请求前调用确保请求间隔不低于设定值。 elapsed time.time() - self.last_request_time interval random.uniform(self.min_interval, self.max_interval) if elapsed interval: time.sleep(interval - elapsed) self.last_request_time time.time()这里的逻辑是每次请求间隔不低于 1 秒且随机加上 0~2 秒的抖动。固定间隔容易被识别为机器行为而抖动让请求节奏更接近真人浏览习惯。实测下来这个策略能非常有效地减少对目标网站的打扰同时保证抓取速度在一个合理范围内。3.3 解析层列表页与详情页的数据结构提取列表页解析的最终目的是拿到详情页 URL 列表。很多招聘网站的列表页结构是 HTML岗位标题、公司名称都以链接形式存在a href...中。用 lxml 的 XPath 提取速度最快比 BeautifulSoup 更适合大批量解析。from lxml import etree class ListParser: 解析列表页提取详情页URL。 def parse(self, html_text, detail_url_pattern): :param html_text: 列表页HTML :param detail_url_pattern: 详情页URL的识别模式如包含 /job/ 的路径 :return: 详情页URL列表 if not html_text: return [] tree etree.HTML(html_text) urls tree.xpath(//a[contains(href, job)]/href) # 根据实际站点结构调整 full_urls [] for url in urls: if url.startswith(http): full_urls.append(url) else: # 相对路径拼接成完整URL full_urls.append(urljoin(https://www.example.com, url)) # 去重后返回 return list(set(full_urls))详情页的解析逻辑类似只是要提取的字段更多。典型的招聘岗位详情页字段包括岗位名称公司名称薪资范围工作地点学历要求经验要求岗位描述发布时间我用字典来定义字段名与 XPath 的映射关系这样增加新字段时只需要加一行配置不用改解析函数本体class DetailParser: 解析详情页提取岗位字段数据。 # 字段名 - XPath 表达式映射示例需按目标网站实际结构调整 FIELD_XPATHS { job_title: //div[contains(class, job-title)]/text(), company: //div[contains(class, company-name)]/a/text(), salary: //div[contains(class, job-salary)]/span/text(), location: //div[contains(class, job-location)]/text(), education: //div[contains(class, job-education)]/text(), experience: //div[contains(class, job-experience)]/text(), publish_time: //div[contains(class, job-publish-time)]/text(), job_desc: //div[contains(class, job-description)]//text(), } def parse(self, html_text, job_id): tree etree.HTML(html_text) result {job_id: job_id} for field, xpath_expr in self.FIELD_XPATHS.items(): nodes tree.xpath(xpath_expr) if nodes: if field job_desc: # 描述是多个文本节点拼接并清理空白字符 result[field] .join(nodes).strip() else: result[field] nodes[0].strip() if hasattr(nodes[0], strip) else str(nodes[0]).strip() else: result[field] None # 字段缺失时保留 None方便后续数据校验 return result写解析逻辑时有个很容易忽略的细节字段缺失时不要抛异常而是保留 None。原因很简单一个招聘网站有几万个岗位总有一部分岗位缺少学历要求、或者没有薪资信息如果在解析阶段就因为字段缺失中断整个爬虫就崩了。把缺失字段置为 None等数据入库后再统一做清洗和过滤这才是容错能力强健的解析架构。比较讲究的做法是把 XPath 配置外置到 JSON 文件中按站点域名维护一套选择器。这样一旦目标网站改版只需要更新配置文件解析代码完全不用动。算是一个很实用的工程化经验。3.4 存储层Excel 落盘与增量去重数据落盘的场景里CSV 和 Excel 是最常见的两种格式。Excel 对中文支持更好也能直接用 Excel 打开做筛选排序。我写了一个简单的 Excel 写入模块基于 openpyxl。from openpyxl import Workbook from openpyxl.styles import Font, PatternFill class ExcelWriter: 将岗位数据写入Excel文件。 def __init__(self, filepath): self.filepath filepath self.wb Workbook() self.ws self.wb.active self.ws.title 岗位数据 self._init_header() def _init_header(self): headers [岗位ID, 岗位名称, 公司名称, 薪资, 地点, 学历, 经验, 发布时间, 岗位描述] self.ws.append(headers) for cell in self.ws[1]: cell.font Font(boldTrue) cell.fill PatternFill(start_colorDCE6F1, end_colorDCE6F1, fill_typesolid) def write_records(self, records): for rec in records: self.ws.append([ rec.get(job_id), rec.get(job_title), rec.get(company), rec.get(salary), rec.get(location), rec.get(education), rec.get(experience), rec.get(publish_time), rec.get(job_desc), ]) def save(self): self.wb.save(self.filepath) print(f数据已保存至 {self.filepath})去重是整个爬虫系统里最容易被低估的环节。网络爬虫在翻页抓取时经常遇到同一岗位在多个列表中出现的情况——例如紧急招聘专区、首页推荐位、搜索结果页它们指向的详情页 URL 完全一样。如果不去重数据量会虚高分析结果也会失真。去重主要有两种方案内存去重用一个set()存放已访问的 URL简单高效但进程重启后就丢失了。适合单次抓取。持久化去重使用 Redis 的 SET 数据结构或 SQLite 表记录已访问 URL重启后依然有效。适合周期性增量抓取。对于招聘网站我推荐在详情页 URL 层面做持久化去重。因为列表页 URL 每次抓取翻页都会变而详情页 URL 是岗位的唯一标识。用 SQLite 做持久化不需要额外搭建 Redis 服务import sqlite3 import hashlib class UrlDeduplicator: 基于SQLite的URL持久化去重。 def __init__(self, db_pathurls.db): self.conn sqlite3.connect(db_path) self._create_table() def _create_table(self): self.conn.execute(CREATE TABLE IF NOT EXISTS visited_urls (url_hash TEXT PRIMARY KEY, url TEXT)) self.conn.commit() staticmethod def _hash_url(url): return hashlib.sha256(url.encode(utf-8)).hexdigest() def is_visited(self, url): url_hash self._hash_url(url) cursor self.conn.execute(SELECT 1 FROM visited_urls WHERE url_hash ?, (url_hash,)) return cursor.fetchone() is not None def mark_visited(self, url): url_hash self._hash_url(url) self.conn.execute(INSERT OR IGNORE INTO visited_urls (url_hash, url) VALUES (?, ?), (url_hash, url)) self.conn.commit()这个模块虽然只有不到三十行代码但它在整个爬虫系统里扮演着守门员的角色。每次抓取详情页之前先查一次这个表已经抓过的直接跳过。我第二次跑全量抓取时耗时直接从 40 分钟降到了 8 分钟——因为 80% 的 URL 都已经被标记为已访问了。3.5 主调度程序串联整条链路采集、解析、存储都就绪后需要一个主函数串联整个流程。这里我把控制逻辑、日志、统计都写在 main.py 中import time import random from loguru import logger from config import LIST_PAGE_URL_TEMPLATE, DETAIL_PAGE_URL_PATTERN, TARGET_CITY, TARGET_KEYWORD from collector.requester import Requester from collector.url_manager import UrlManager from parser.list_parser import ListParser from parser.detail_parser import DetailParser from storage.excel_writer import ExcelWriter from storage.deduplicator import UrlDeduplicator # 配置日志输出 logger.add(logs/crawler.log, rotation10 MB, retention7 days, levelINFO) def main(): requester Requester(timeout10, max_retries3) url_manager UrlManager( base_urlLIST_PAGE_URL_TEMPLATE, cityTARGET_CITY, keywordTARGET_KEYWORD, max_page50, ) list_parser ListParser() detail_parser DetailParser() writer ExcelWriter(jobs.xlsx) deduplicator UrlDeduplicator(visited_urls.db) all_records [] list_urls url_manager.generate_list_urls() for page_url in list_urls: url_manager.mark_visited(page_url) list_html requester.get(page_url) if not list_html: logger.warning(f列表页请求失败: {page_url}) continue detail_urls list_parser.parse(list_html, detail_url_patternDETAIL_PAGE_URL_PATTERN) logger.info(f第{page_url}页解析到 {len(detail_urls)} 个岗位详情页链接) for detail_url in detail_urls: if deduplicator.is_visited(detail_url): continue detail_html requester.get(detail_url) if not detail_html: continue job_id detail_url.split(/)[-1].replace(.html, ) # 从URL中提取岗位ID规则按实际调整 record detail_parser.parse(detail_html, job_id) if record.get(job_title): # 核心字段不能为空 all_records.append(record) deduplicator.mark_visited(detail_url) # 单次详情页抓取间隔 time.sleep(random.uniform(0.5, 1.5)) # 列表页翻页间隔 time.sleep(random.uniform(1, 3)) writer.write_records(all_records) writer.save() logger.info(f抓取完成共 {len(all_records)} 条有效岗位数据) if __name__ __main__: main()这段主逻辑把之前所有模块串了起来同时兼顾了异常容错单个详情页失败不会中断整个流程列表页失败会打印日志并跳到下一轮核心字段为空的数据会被过滤掉。程序的健壮性并不体现在某一行代码上而是体现在遇到异常时整体流程不崩这件事上。4. 高频反爬应对与稳定性保障实战踩坑记录理论讲完了接下来聊聊我在实际爬取招聘网站过程中遇到的最麻烦的问题。这些问题在代码库里不一定能直接解决但知道了它们的存在你才能提前在设计上留出应对空间。4.1 数据接口从 HTML 变成了 JSON解析逻辑大改第一次写爬虫的时候我用列表页的 HTML 做解析XPath 都写好了跑了半天。结果第三天再次运行列表页的 HTML 结构完全变了——对方网站改版了。后来我发现很多招聘网站的列表页并不直接渲染全部岗位信息而是通过前端 JS 内嵌一个初始数据对象比如window.__STATE__ {...}然后由浏览器渲染出 HTML。这种情况下直接用requests.get()拿回来的 HTML 里岗位数据其实已经藏在一个巨大的 JSON 字符串里了。解决办法是优先从页面中提取 JSON 数据而非解析 HTML 节点。用正则表达式把window.__STATE__之后的 JSON 部分截取出来然后 json.loads 解析。这样抗网页改版的能力强得多因为数据源是 JSON网页布局怎么变都不影响。import re import json def extract_state_json(html_text): 从HTML中提取window.__STATE__对象的JSON字符串。 pattern re.compile(rwindow\.__STATE__\s*\s*(\{.*?\});, re.DOTALL) match pattern.search(html_text) if match: return json.loads(match.group(1)) return None这种设计方案的好处是即使页面的 HTML 结构调整了只要数据接口没变爬虫代码不需要任何改动。4.2 请求频率过高触发限制如何设计退避策略招聘网站的限流机制虽然不如电商平台严格但频率太高一样会触发。我遇到过的情况是请求频率超过每秒 5 次持续几分钟对方开始返回带有验证码的页面——典型的反爬手段。处理这个问题的思路分三层控制基础频率每次请求之间至少间隔 1 秒这已经比大部分真实用户浏览速度快了。监控响应特征如果返回的 HTML 或 JSON 里出现验证码标识、滑块验证标识等特征立即停止当前请求循环进入冷却状态。冷却时间可以逐步增加比如先停 60 秒再停 300 秒呈指数上升。分散请求目标如果只抓一个城市一个岗位方向对方的频率限制会很敏感。但如果同时切换不同城市、不同关键词参数请求会落到不同的业务分片上更不容易触发统一限流。这里有一个实用技巧把请求集中在业务低谷期。招聘网站的访问高峰是工作日的上午十点到十一点、下午两点到四点服务器风控系统在这些时段会更敏感。如果你在晚上十点后跑爬虫频率限制明显宽松很多。4.3 断点续抓程序停了不等于数据白抓了面试过爬虫岗位的人应该都知道线上爬虫最怕的事就是程序中途崩溃。你抓了三个小时结果网络波动导致某个请求超时整个进程退出前三个小时的成果全部丢失。断点续抓的核心就是持久化进度。我推荐的做法是把进度和数据分开管理数据已经抓到的部分写入 Excel 或数据库后立即 save进度信息哪些页面已抓完、剩余多少记录在本地状态文件里在 main.py 中我用state.json记录当前抓取到第几个列表页、当前任务批次。程序启动时先读取状态文件从断点处继续import json import os STATE_FILE state.json def load_state(): if os.path.exists(STATE_FILE): with open(STATE_FILE, r, encodingutf-8) as f: return json.load(f) return {current_page: 0} def save_state(page_index): with open(STATE_FILE, w, encodingutf-8) as f: json.dump({current_page: page_index}, f, ensure_asciiFalse)这个技巧虽然简单但对整个爬虫系统的工程化程度提升非常明显。招聘数据动辄几万条一口气抓完本来就容易出问题断点续抓能让你从必须一口气跑完变成分批次慢慢跑完压力小很多。4.4 字符编码和历史遗留字段的坑最后一个坑来自编码。招聘网站数量多有的站点用 UTF-8有的站点用 GBK还有少数老站用的是 GB2312。如果 requests 拿到响应后不设置resp.encodingrequests 会根据响应头猜测编码但经常猜错导致中文乱码。我的做法是在Requester.get()里显式传入encoding参数优先使用响应头Content-Type里的 charset拿不到就用 UTF-8 兜底。如果发现某个站点无论如何都乱码再去检查对方页面的meta charset标签。还有一类老招聘网站历史数据里的岗位薪资字段是面议或者薪资面议学历字段是本科及以上这样的非标准文本。这些都不影响存储但在你做数据分析时需要单独做一轮字段标准化清洗。我在 ExcelWriter 的写入前加了一个简单的转换函数def normalize_salary(salary_text): 将薪资文本标准化为统一的数值区间供分析用。 if not salary_text or 面议 in salary_text: return None # 简化的转换逻辑提取所有数字 import re nums re.findall(r\d, salary_text) if len(nums) 2: return {min: int(nums[0]), max: int(nums[1])} return None注意这个函数不影响原始数据的存储只是在数据分析阶段转换副本。这样既保证了数据的原始可追溯性又支撑了后续的分析需求。5. 招聘数据垃圾清洗与敏感字段过滤爬虫抓下来的数据直接拿去用是不够的。清洗是一个独立的环节也是招聘爬虫项目里区分业余和专业的重要分水岭。5.1 空值、重复值、异常值的处理规则招聘数据清洗主要做三件事空值处理对于学历要求、薪资等关键字段为空的数据根据使用目的决定是丢弃、补默认值还是标记为未知。我的建议是关键字段岗位名称、公司名称为空的数据直接丢弃非关键字段为空则标记为未知方便后续分析时排除。重复值处理同一岗位在不同渠道可能被重复发布可以通过公司名称 岗位名称 工作地点的组合逻辑做去重。因为不同岗位的发布 ID 可能不同但信息组合相同的基本是重复数据。异常值处理薪资超过正常范围比如月薪 100 万、工作地点不合法等数据都需要通过校验规则过滤掉。这里给一个简单的数据质量校验函数例子def validate_record(record): 校验岗位数据的完整性与合理性。 if not record.get(job_title): return False if not record.get(company): return False salary record.get(salary) if salary and 面议 not in salary: # 简单检查薪资文本是否包含数字 import re if not re.search(r\d, salary): return False return True5.2 关于隐私与敏感信息的合规提醒爬虫方向的开发者合规意识必须刻在骨子里。我不止一次在技术群里看到有人对网站的非公开接口逆向破解、批量注册账号、采集用户个人隐私信息——这类事无论技术多精彩都不要碰。招聘网站爬虫的合规边界在于只能采集企业主动公开的招聘信息不涉及任何自然人的个人隐私信息。千万不要为了凑数去爬简历数据、爬注册用户信息那是彻底的红线。同时抓取频率要克制不能对目标网站的正常服务造成影响。这篇博文里的所有设计都是在公开页面信息 合理请求频率的前提下展开的。从技术本身来说爬虫与反爬的对抗本质是自动化请求与风控策略之间的平衡。你能写出高效、稳定、合规的爬虫说明你具备网络协议、数据结构、数据处理、异常排查的综合能力——这套能力本身就是很值钱的。但它的价值应该用在正当的数据采集和分析场景里而不是灰色地带。6. 后续迭代从脚本到数据产品的成长路径这篇文章的标题是招聘网站数据爬虫设计源码但我不希望这篇文章的终点就是代码能跑。6.1 从单脚本到定时增量抓取目前的代码是手动触发、全量抓取。但真实的招聘数据是动态变化的每天都有新的岗位发布、旧的岗位下线。下一步的迭代方向是定时调度 增量抓取。定时调度最轻量的方案是用系统自带的 cronLinux/macOS或任务计划程序Windows每天晚上定时执行一次抓取任务。增量抓取则依赖前面说的 SQLite 去重表——新发布的岗位 URL 不在表里会被抓取已抓过的岗位 URL 直接被跳过速度极快。6.2 数据可视化让爬虫采集的数据产生价值数据抓下来之后真正的价值在分析层面。同一个岗位关键词不同的城市薪资分布如何不同工作经验要求下的薪资中位数是什么一个城市的热门招聘行业分布是怎样的这些分析给求职决策提供的数据支持价值远高于爬虫本身。基于这套爬虫抓到的数据我用简单的 pandas 加 matplotlib 就画出了薪资分布直方图和城市岗位数量对比图。如果你愿意还可以把数据导入 PowerBI 或 Tableau做成可交互的看板。6.3 框架升级什么时候该换 Scrapy如果数据量继续扩大比如要抓全国几百个城市的所有岗位那么手写框架的短板就暴露出来了并发能力有限、调度灵活性不足、分布式支持缺失。这个阶段迁移到 Scrapy 是顺理成章的事。Scrapy 最核心的优势是异步非阻塞 I/O 模型可以同时发起大量请求而不会阻塞爬取速度有质的提升。同时它的中间件机制可以更优雅地处理代理轮换、User-Agent 轮换、日志监控等能力。但需要注意的是搭建一个可用的 Scrapy 集群学习成本比手写框架高得多——所以这个升级应该在确有需要时再做而不是为了技术炫耀而盲目升级。提示从手写框架迁移到 Scrapy 时你已经掌握的采集、解析、存储、去重逻辑全都用得上。Scrapy 的 Spider 对应解析层Pipeline 对应存储层Downloader Middleware 对应采集层的请求管理。概念是一一对应的迁移并不会让之前的经验白费。最后再分享一个实用经验。很多人以为爬虫项目最难的是写代码但以我一个人做过十几个爬虫项目的体验来说最难的是优雅地维护它。招聘网站改版一次你的选择器可能要更新一遍对方服务器升级了 TLS 版本你的 requests 库可能要升级。好的设计不会一劳永逸但能把每次故障的修复成本压缩到最低。这也是我在前面反复强调模块化、配置外置、日志完备的原因——它们可能不能在第一天帮你节省时间但能在第三个月帮你保住头发。本文还有配套的精品资源点击获取

相关新闻