招聘网站爬虫实战:从requests到Scrapy分布式爬虫
简介一套基于Python开发的招聘网站爬虫源码合集专为数据分析师、爬虫爱好者以及有招聘数据自动抓取需求的个人或团队设计覆盖51job、拉钩网、智联招聘等国内主流平台。压缩包共44个文件包括17个Python源文件、12个优化编译文件以及字体、图片、Excel表格等辅助资源整体仅9.96MB目录模块划分清晰便于定位和复用。代码覆盖爬虫的初始化、请求构造、响应解析、数据提取与存储等完整环节部分模块基于Scrapy框架并配有Markdown使用文档和运行配置文件便于初学者理解机制也方便开发者二次扩展。项目中还包含词云图片与Excel结果表可辅助进行职位关键词分析与数据可视化内置更新机制能根据目标网站结构调整爬虫逻辑提升长期可用性。已有208人学习适合从入门到进阶的爬虫研究者深入学习也可作为快速搭建招聘数据采集系统的参考。1. 招聘网站爬虫到底在爬什么?一个反直觉的结论:招聘网站的访问门槛并不高,真正让源码合集失去价值的是字段混沌、重复投递和隔天失效。拿到一份拉勾、Boss 直聘或前程无忧的职位列表页,requests 加上 parsel 半小时内就能跑通;但同一个职位在早上和下午可能出现两条记录,不同前端渲染方式会切掉一半字段,公司名里混着“有限公司”和“Ltd.”。这段时间涨起来越多,你对爬虫工程师知识里“抽取易、治理难”的印象就越深。本文要讲的是把“基于Python的招聘网站爬虫设计源码合集”落地成可维护形态的方案:先确认静态还是动态页面,再做字段拆解和去重入库,最后聊单机到 Scrapy 分布式爬虫的升级路径。写法偏工程,不堆库,所有示例都围绕职位列表页、职位详情页、搜索翻页这三个真实场景。适合刚把 python 爬虫教程看完、想拿招聘数据做分析,或者需要给团队搭一个可复用采集模板的读者。2. 招聘网站爬虫的技术选型:静态与动态页面识别2.1 requests 与 parsel 抓首版:最小可运行代码招聘网站的列表页分两类。一类是服务端渲染,页面 HTML 里直接带职位列表,这类用 requests 加 parsel 就能把采集跑通;另一类是前端异步渲染,HTML 里只有空的div idapp,真实数据在 XHR 接口里。很多 python 爬虫入门教程会让初学者一开始就上 Selenium,其实是把成本抬高了。正确做法是先看页面响应里有没有职位数据。import requests from parsel import Selector def fetch_job_list(url, headersNone): headers headers or { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 } resp requests.get(url, headersheaders, timeout10) resp.raise_for_status() # 招聘网站页面通常直接用 utf-8,少数老站是 gbk if resp.encoding and resp.encoding.lower() in (gbk, gb2312): resp.encoding gbk return Selector(textresp.text) sel fetch_job_list(https://example.com/jobs?page1) # 打印前 200 个字符,判断服务端是否直接渲染职位名 print(sel.xpath(string(//body)).get()[:200])这段代码的逻辑是先用通用 UA 发一次 GET 请求,然后通过检查页面里是否存在职位文本,判断当前网站的渲染方式。resp.encoding的判断很关键:如果服务端返回的 Content-Type 是text/html; charsetgbk,requests 有时不会自动识别,后续 XPath 会抽出一堆乱码。参数上,timeout10是必写的,招聘网站偶尔会慢,不设超时会让整个爬虫卡在单个请求上。如果打印结果里能直接看到“Java 开发工程师”“产品经理”这类职位词,说明该网站是服务端渲染,可以直接进入字段抽取阶段。如果 body 里只有一段 JS 引用的数据,那要走 2.2 的浏览器兜底方案。2.2 动态渲染页面的 Playwright 兜底招聘网站里越来越多的搜索页走前端渲染,列表数据藏在https://example.com/search/api?querypythonpageNo2这类接口里。直接用 requests 拿 HTML 会扑空,但直接上 Selenium 又太笨重。常见的可靠做法是先开浏览器开发者工具,在 Network 面板里筛选 XHR,找到返回职位数组的接口,用 requests 模拟它。只有当接口参数被签名加密时才用 Playwright。from playwright.sync_api import sync_playwright def fetch_dynamic_page(url, wait_selector): with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() # 拦截图片和字体,减少无关流量 page.route(**/*, lambda route: route.abort() if route.request.resource_type in (image, font) else route.continue_()) page.goto(url, wait_untildomcontentloaded, timeout20000) # 等待职位卡片容器出现,替代固定 sleep page.wait_for_selector(wait_selector, timeout10000) html page.content() browser.close() return html这段代码里真正有价值的不是launch和goto,而是route.abort和wait_for_selector。招聘页面图片很多,不拦图片会让每次抓取多 1 到 3 秒;按选择器等待可以精确知道数据出现了,而不是盲目 sleep 五秒。wait_untildomcontentloaded也比默认的load快,因为很多招聘网站首页会加载一批不相关的统计脚本。需要说明的是,Playwright 是兜底手段,不是首选。如果 XHR 接口没有签名,XHR 接口后面通常会有一个window.__INITIAL_STATE__或 JSON 响应,直接用 requests 加params模拟翻页,速度和成功率都远高于无头浏览器。把 Playwright 写进源码合集的意义是应对少数做了 JS 加密的定向招聘网站。2.3 反爬常规对抗与频率控制参数招聘网站的反爬策略集中在三块:请求频率、UA 检测、登录态。封禁表现不是直接返回 403,而是返回一个“访问过于频繁”的验证页。这里我一般维护一组请求参数,把它们收敛到一个配置字典里。REQUEST_CONFIG { min_interval: 2.0, # 单次请求最小间隔,单位秒 max_interval: 5.5, retry_times: 3, # 超时或 5xx 时重试次数 timeout: 10, proxy_enabled: False, # 是否走代理池 headers: { User-Agent: Mozilla/5.0 ..., Accept-Language: zh-CN,zh;q0.9, Referer: https://example.com/, }, verify_ssl: False, # 个别招聘站点证书链不全 }参数说明:min_interval和max_interval之间取随机值,避免每次请求间隔一模一样被秒级频率检测识别;Referer要在登录态下和实际访问来源一致,很多招聘网站的列表页接口会校验来源页。verify_sslFalse会触发 urllib3 的警告,建议配合urllib3.disable_warnings()使用,但更优雅的办法是把指定的 CA 证书放进 requests 的verify参数。反爬行为常见表现应对参数UA 检测返回空列表而非报错设置移动端或 Chrome UA 轮换频率限制第 N 页开始跳验证码调大min_interval,加指数退避登录态失效详情页跳登录从浏览器复制 Cookie,设置短时失效刷新接口签名XHR 返回 401分析 JS 或改用 Playwright这里不建议在源码里内置代理池。招聘网站数据量没到千万级就用 IP 代理,成本高且容易被封供应商。先用低频策略跑通字段,再按统计到的封禁频率决定要不要上代理。3. 招聘网站爬虫信息抽取与字段规范化3.1 职位字段抽取:从 HTML 里捞出干净字段拿到了页面之后,下一个痛点不是找职位标题,而是把一个职位卡片转成结构化字段。招聘网站的 HTML 结构差异很大,但规律一致:职位卡片通常在一个带classjob-list-item或>def parse_job_card(card): title card.xpath(.//div[contains(class,job-title)]/text()).get() company card.xpath(.//a[contains(class,company-name)]/title).get() salary card.xpath(.//span[contains(class,salary)]/text()).get() # 部分网站把经验/学历塞在同一个 li 里,用 .//text() 拼全 tag_texts card.xpath(.//div[contains(class,tags)]//text()).getall() tags [t.strip() for t in tag_texts if t.strip()] job_url card.xpath(.//a[contains(class,job-link)]/href).get() return { title: title, company: company, salary: salary, experience: tags[0] if tags else , education: tags[1] if len(tags) 1 else , url: job_url, }get和getall的区别是本段逻辑的重点。get()只取第一个匹配,适合标题和公司名;getall()把节点下所有文本拼出来,适合经验、学历、技能标签混合存在的场景。href拿到的是相对路径,后续入库前要拼上域名前缀。这里最容易踩的坑是用.//text()时把父标签里的隐藏字段也带出来,所以 xpath 路径里尽量带contains(class,...),缩小匹配范围。对详情页,我一般单独写一个解析函数,重点抽发布时间和职位描述。描述字段是后续做 NLP 技能匹配的原料,建议保留原始 HTML 并顺手存一份纯文本。3.2 去重与增量更新策略:标题里“设计”的核心一个招聘网站爬虫源码合集里,最容易被忽略的是去重。同一个职位在搜索结果里出现两次、或昨天和今天各出现一次,都会污染统计结果。业界常见做法是用 URL 里的职位 ID 做唯一键,内存里放set,持久化放 Redis 或数据库唯一索引。import hashlib import redis r redis.Redis(hostlocalhost, port6379, db0) def make_job_id(url, title, company): raw f{url.split(?)[0]}|{title}|{company} return hashlib.md5(raw.encode(utf-8)).hexdigest() def is_duplicate(job_id): return r.sismember(job_seen, job_id) def mark_seen(job_id): r.sadd(job_seen, job_id)这里的hashlib.md5不是做加密,而是把 URL 里的查询参数去掉后和标题、公司名一起生成短 key,避免同一个职位因?sourcesearch和?sourcerecommend被当作两条数据。sismember是 O(1) 的集合查询,适合批量抓取时的实时去重。注意:如果职位文案每天更新,以 URL 里的职位 ID 为准更稳妥,不要拿标题做唯一键,因为同一个 company 会重复发相同 title 的岗位。增量更新的做法是每天跑一次,保留当天的job_seen集合,并把新出现的 job id 对应的职位抓详情页。之前抓过的数据在数据库里按last_seen_at字段更新,而不是重插一条。3.3 落库:JSON/CSV/SQLite 的最短写法源码合集里不需要一上来就接 MySQL。第一版用 SQLite 足够,单表支持十万级职位数据,查询和分析都能直接跑。下面是一个建表和插入的最小格式:import sqlite3 def init_db(db_pathjobs.db): conn sqlite3.connect(db_path) conn.execute( CREATE TABLE IF NOT EXISTS job ( job_id TEXT PRIMARY KEY, title TEXT, company TEXT, salary TEXT, experience TEXT, url TEXT, description_text TEXT, fetched_at TEXT ) ) conn.commit() return conn def insert_job(conn, job): conn.execute( INSERT OR REPLACE INTO job (job_id, title, company, salary, experience, url, description_text, fetched_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) , (job[job_id], job[title], job[company], job[salary], job[experience], job[url], job[description_text], job[fetched_at]), ) conn.commit()建表时的PRIMARY KEY直接解决一个基础问题:重复抓同一职位时,靠主键约束天然去重。INSERT OR REPLACE的逻辑是保留同一条 job_id,更新它的描述和抓取时间,而不是新增。这个写法在数据量到几万条时速度没问题;等顶到几十万条,再换成 MySQL 或 PostgreSQL。字段设计上,salary别存成字符串“20K-40K”,最好在解析阶段拆成salary_min和salary_max两个整数,不然做薪资分析时还得写正则。fetched_at用 ISO 格式文本存储即可,SQLite 的日期函数也能直接处理。4. 从单机到分布式:招聘网站爬虫的调度与扩展4.1 Scrapy 改造单脚本写好的 requests 脚本能跑,但只能单进程跑。如果要抓七八个招聘网站,每个网站几十个城市,就需要调度器、下载中间件和去重模块分工。把脚本重构成 Scrapy 项目的常见套路是保留解析逻辑,把网络请求和调度交给框架。import scrapy class JobSpider(scrapy.Spider): name job_spider start_urls [ https://example.com/jobs?citybeijing, https://example.com/jobs?cityshanghai, ] def parse(self, response): for card in response.css(div.job-list-item): yield { title: card.css(.job-title::text).get(), company: card.css(.company-name::attr(title)).get(), url: response.urljoin(card.css(.job-link::attr(href)).get()), } next_page response.css(a.next::attr(href)).get() if next_page: # 用 Scrapy 的调度器入队 yield response.follow(next_page, callbackself.parse)response.follow是 Scrapy 比 requests 顺手的地方:它会自动拼接绝对 URL,继承原请求的 headers、cookies 和 meta。这里的骨架代码只保留了列表页解析,实际落地时把 3.1 里的parse_job_card挪进来,返回的yield就是一条数据结构。这个阶段的优势不在单页解析,而在并发控制。Scrapy 的settings.py里设CONCURRENT_REQUESTS8,它会自动用细粒度调度替代手动写线程池。我见过很多面试者在项目里写 ThreadPoolExecutor 抓招聘网站,功能没问题,但中断续爬和请求失败恢复都要自己实现,Scrapy 自带。4.2 Scrapy-Redis 分布式去重队列当一台机器抓不过来,或者想多台服务器分担不同城市的种子 URL,官方推荐的方案是 Scrapy-Redis。它把调度队列和去重指纹放到 Redis 里,让多个爬虫实例消费同一个任务池。# settings.py 关键配置 SCHEDULER scrapy_redis.scheduler.Scheduler DUPEFILTER_CLASS scrapy_redis.dupefilter.RFPDupeFilter REDIS_START_URLS_KEY %(name)s:start_urls SCHEDULER_PERSIST True解释下四个配置:SCHEDULER把内存队列换成 Redis 里的 FIFO 列表;DUPEFILTER_CLASS让 request 指纹的集合也存储在 Redis,这样两台机器不会打印同一条职位;SCHEDULER_PERSISTTrue意味着跑完后任务不清空,适合每天增量补爬。启动前先往 Redis 里推一批种子 URL:redis-cli lpush job_spider:start_urls https://example.com/jobs?citybeijing scrapy crawl job_spider在这种设计下,限速配置是新的边界。DOWNLOAD_DELAY是每个实例自己的,如果部署两台机器且都设成 0.5 秒,对目标站点的实际请求间隔是 0.25 秒,等于双倍频率。我对这个参数的处理是:按“目标站能接受的 QPS / 实例数”来设置单机延迟,而不是单独调CONCURRENT_REQUESTS。分布式爬虫带来的去重问题,可以由 3.2 节的job_seen集合继续兜底。Scrapy 的 dupefilter 只管请求 URL 层面,职位级别的去重要靠管道的is_duplicate(job_id),两层去重各管一层。4.3 反爬下的限速与重试策略分布式之后,反爬对抗的关键变成了限速的可控性。Scrapy 自带的 AutoThrottle 模块会对每个域名做自适应限速,但它面对招聘网站这种“前几页快、后面突然验证码”的站点表现不稳定,我更习惯手动设置基准值。# settings.py 常用组合 CONCURRENT_REQUESTS 4 CONCURRENT_REQUESTS_PER_DOMAIN 2 DOWNLOAD_DELAY 2.5 RANDOMIZE_DOWNLOAD_DELAY True RETRY_TIMES 3 RETRY_HTTP_CODES [403, 500, 502, 503] DOWNLOAD_TIMEOUT 15CONCURRENT_REQUESTS_PER_DOMAIN比全局并发更重要,它限制了同一招聘域名的并发连接数,避免一个网站的慢响应拖垮整个任务队列。RANDOMIZE_DOWNLOAD_DELAY让 Scrapy 在 Download Delay 基础上乘以一个 0.5 到 1.5 之间的随机因子,天然避开每 2.5 秒一次的精确节奏。RETRY_HTTP_CODES默认不包含 403,因为加了也没用,403 通常是 IP 或 Cookie 被封,重试只会加重封禁。这里我做的处理是捕获到 403 时,把当前 City 的种子 URL 暂存,等一个小时后重新入队。至于验证码,常规做法是接打码平台或手动过,但更干净的思路是绕过列表页,直接请求移动端接口,这是 5.3 节要展开的内容。5. 让招聘网站爬虫源码更耐用的五个技巧5.1 先读 robots.txt 再定采集边界我见过一份源码合集把目标网站的robots.txt完全忽略,结果抓了三天后被对方安全团队发邮件。招聘网站的数据很多是公司主动发布的公开信息,但不代表可以无限抓取。requests.get(https://example.com/robots.txt)只需要一次请求,就能看到Crawl-delay和Disallow段,把不允许访问的路径写进源码的exclude_paths列表。5.2 用移动端接口降低反爬强度招聘网站的移动端 H5 或 App 接口,通常比 PC 端少一层 JS 渲染和验证码,而且字段更规整。常见做法是在 Network 面板里用手机模拟器访问搜索页,找到api/positions之类的 JSON 接口。这个接口大多只校验 UA 和签名参数,有些甚至只需要User-Agent: Mozilla/5.0 (iPhone...)。把 PC 端的列表页解析函数替换为 JSON 响应解析,采集速度能提升一个量级。{jobId: 12345, jobTitle: Python工程师, salaryMin: 20, salaryMax: 40}注意移动端接口的翻页参数通常叫page和pageSize,且单页数量上限被固定在 30 左右,这是为了防爬而不是产品限制。5.3 最后用统计表验证数据完整性落库之后不要直接信条数,写一段 SQL 看核心字段的空值率和重复率。SELECT COUNT(*) FROM job WHERE salary_min IS NULL如果超过 10%,说明解析器漏掉了某个版本的页面结构;SELECT COUNT(*), job_id FROM job GROUP BY job_id HAVING COUNT(*) 1查不到结果,说明去重逻辑正常。这两个查询我每次调整解析器后都会跑一遍,比盯着日志有效。本文还有配套的精品资源点击获取