资讯动态

用函数式Python爬虫批量抓取应届生职位:从接口解析到数据清洗

发布时间:2026/10/16 1:51:04 来源:尧图企业网站定制
每年三四月份应届生找工作的人几乎都在做同一件事打开求职网站一页一页翻职位看到合适的复制岗位名称、再去搜索公司口碑、看一眼薪资范围、然后判断要不要投。一天下来翻了两百个职位真正进脑子里的可能不到二十个。我的做法不太一样。我用函数爬取的方式把某应届生求职网站上的职位信息批量拉下来按照城市、岗位关键词、薪资区间自动过滤生成一张结构化的表格。白天照样去面试晚上打开电脑看当天新增的职位汇总投递效率高了不少。这篇文章就把这套函数爬取的思路完整写一遍。从为什么不用类封装、到怎么拆解网站请求、再到具体的爬取函数怎么写以及运行几天后遇到的真实问题和解决办法。目标是让一个只学过Python基础、没怎么写过爬虫的人也能照着把当前城市的应届生职位批量抓下来。提醒一句爬虫是一门技术但技术有边界。这篇内容只讨论请求公开可见的职位信息、控制访问频率、仅用于个人求职筛选不要把接口抓下来做商用聚合或二次分发。后面有一节专门讲合规边界和红线。1. 为什么用函数式而不是类封装来写爬虫我见过很多爬虫教程一上来就定义一个大大的class PositionSpider里面塞进了请求、解析、清洗、存储、日志五个模块看起来架构很完整。但对于抓取一个固定网站、固定接口的任务来说这是典型的过度设计。函数式写法更直白每个函数只做一件事数据在函数之间以参数和返回值流动。写爬虫的人不需要操心这个状态变量属于哪个实例只需要保证每个函数的输入输出清晰稳定。1.1 函数式的三个实际好处第一调试成本低。爬虫出问题通常就出在两个环节请求被拦了或者页面结构变了。函数式写法里fetch_page和parse_position是互相独立的函数哪个环节报错直接把那个函数单独跑一遍就能定位。第二组合灵活。今天抓的是A网站明天想换成B网站类封装往往要改写类内部的方法。函数式写法则只需要替换对应的fetch_page和parse_position主流程run_pipeline一行都不用动。第三更适合数据流思维。爬虫的本质是一条流水线原始请求 - HTML/JSON文本 - 结构化字段 - 清洗后的干净数据。函数式写法让这条流水线在代码里一眼就能看出来而不是藏在一堆self中间。1.2 主流程怎么编排整个爬虫的核心主函数只有十几行def run_pipeline(city: str, keyword: str, pages: int 10) - None: raw_pages [] for page in range(1, pages 1): html fetch_page(city, keyword, page) raw_pages.append(html) positions [] for raw in raw_pages: positions.extend(parse_position(raw)) clean_data [clean_position(p) for p in positions] save_to_csv(clean_data, output_path(city, keyword))主流程清晰到可以直接念给别人听先把指定城市的搜索结果页全部拉回来然后逐页解析出职位列表逐条清洗成统一格式最后落盘成CSV。我后来在实际使用中还加了一步去重和一步关键词分类同样只是往这流水线里插入两个函数而已。2. 请求链路拆解先搞清楚网站是怎么把职位数据发给你的大部分人学爬虫时直接打开网页、右键查看源代码、在HTML里找职位信息然后就被各种CSS类名绕晕了。其实如果一个求职网站足够现代职位数据往往不是藏在HTML里而是通过接口动态加载的。找到接口远比解析HTML省事。2.1 用浏览器开发者工具定位数据接口我在Chrome里打开某应届生求职网的搜索结果页按F12进入开发者工具切到Network网络面板然后刷新页面。接下来做的事情很简单在筛选项里输入全部类型和全国让页面重新加载一次在请求列表里勾选Fetch/XHR把JS、CSS等资源请求过滤掉找一个返回了职位JSON的请求点进去看Preview预览。这类网站通常有一个固定的接口路径类似/api/position/search请求参数里包含页码、关键词、城市代码等字段响应体则是一段JSON里面直接包含了jobName、companyName、salary、city、publishTime、jobLink等信息。经验之谈如果页面翻页时URL没有变化而职位数据实时更新那基本可以断定走的是XHR异步加载直接找JSON接口是最高效的方式。2.2 拿到接口后先手工验证一次在写代码之前我建议先手工构造一次请求确认接口的响应结构。用浏览器的话可以直接在Console里用fetch发请求fetch(/api/position/search?city101010100keywordpythonpageNum1pageSize20, { headers: { x-requested-with: XMLHttpRequest } }).then(res res.json()).then(data console.log(data))把这份返回的JSON保存下来仔细观察字段结构。这一步决定了后面的parse_position函数怎么写。我第一次写这类爬虫时跳过了手工验证直接按浏览器里一个比较老的搜索结果页的HTML结构去解析结果200个职位只能抓到50个最后打开接口一看才发现近半数据根本不在HTML里。2.3 关键参数的含义要摸透以常见的求职网站接口为例一般需要关心这几个参数城市代码通常是一串数字每个城市对应一个编码网上能找到完整的对照表关键词URL编码后的中文关键词比如python或%E5%89%8D%E7%AB%AF分页参数有的用pageNumpageSize有的用startlimit过滤条件薪资范围、学历要求、经验要求这些往往也能通过参数传。我在实测中发现这个网站的职位列表接口返回的JSON里不但有当前页的数据还有一个total字段表示该条件下总职位数。这个字段非常有用除了用来计算总页数还可以配合一个时间戳做增量更新的参考。3. 四个核心函数把稳得一比落到实处标题里说稳得一比这种稳不是你运气好遇到的网站不反爬而是你的代码结构天然就能扛住大部分常见的反爬策略。我用最朴素的四个函数跑了一周多没有出过故障。3.1 fetch_page请求函数要考虑的不仅仅是请求fetch_page是整个爬虫里第一个核心函数也是被反爬拦截概率最高的函数。我在实际请求时做了三件事每一件都有对应的理由。import requests import random import time from fake_useragent import UserAgent def fetch_page(city_code: str, keyword: str, page: int, retries: int 3) - str: url config[base_url] params { city: city_code, keyword: keyword, pageNum: page, pageSize: config[page_size], } headers { User-Agent: UserAgent().random, Referer: config[referer], Accept: application/json, x-requested-with: XMLHttpRequest, } for attempt in range(retries): try: resp requests.get(url, paramsparams, headersheaders, timeout10) if resp.status_code 200: return resp.text elif resp.status_code in (403, 429): wait_time 5 * (attempt 1) random.random() time.sleep(wait_time) else: resp.raise_for_status() except requests.RequestException as e: if attempt retries - 1: raise time.sleep(2) return 第一件事是随机User-Agent。不同浏览器的UA不同同一个UA频繁访问很容易被识别为脚本行为。fake-useragent这个库可以随机生成常见的浏览器UA我实测过用了随机UA之后请求被拒的概率明显降低。第二件事是设置Referer。很多网站的反爬机制里有一道很基础的检查请求必须来源于站内页面。带上Referer相当于告诉服务器我是从你们网站的搜索结果页里点进来的这道检查才算过关。第三件事是指数退避重试。当接口返回403或429意味着访问过于频繁被暂时限制。这时候盲目继续请求只会让封禁更久代码里用sleep等待每次等待时间翻倍并加上随机抖动给服务器和你的IP都留出喘息空间。3.2 parse_position解析函数最怕脆parse_position的输入是第1步拿到的响应文本输出是结构统一的职位字典列表。我最开始写解析函数时犯过一个典型错误用非常精确的路径去定位字段比如data[list][0][jobName]一旦网站某个字段偶尔缺失整个解析就直接报错。后来我改成更稳的写法字典的get方法 默认值兜底。def parse_position(raw_text: str) - list: import json data json.loads(raw_text) results [] for item in data.get(data, {}).get(list, []): results.append({ job_name: item.get(jobName, ), company: item.get(companyName, ), salary_min: parse_salary(item.get(salary, ))[0], salary_max: parse_salary(item.get(salary, ))[1], city: item.get(city, ), education: item.get(education, ), publish_time: item.get(publishTime, ), link: item.get(jobLink, ), }) return results这段代码看起来简单其实有两个隐藏的价值点。第一**get不会因为字段临时缺失而抛异常**最多是填空字符串。第二**salary被独立成一个函数解析**因为薪资在网站里是8千-1.2万这种文本格式不能直接做成数字需要单独清洗。3.3 parse_salary文本型薪资转成数字范围薪资是职位信息里最有价值也最难处理的字段。它不像城市、学历那样是固定的枚举值而是格式不一的文本8千-1.2万需要拆成 8000 和 12000面议用 None 表示3万以上最低取30000最高取一个很大的数字或None。我用一个独立的函数来处理这些格式def parse_salary(salary_text: str) - tuple: if not salary_text or 面议 in salary_text: return (None, None) salary_text salary_text.replace(千, 千).replace(万, 万) if - in salary_text: low, high salary_text.split(-) return (parse_salary_unit(low), parse_salary_unit(high)) elif 以上 in salary_text: low salary_text.replace(以上, ) return (parse_salary_unit(low), None) else: one parse_salary_unit(salary_text) return (one, one) def parse_salary_unit(text: str) - float: if 万 in text: return float(text.replace(万, )) * 10000 if 千 in text: return float(text.replace(千, )) * 1000 return float(text)这套逻辑跑下来原本杂乱无章的薪资文本就统一成了可用于筛选的数值范围。3.4 save_to_csv落盘时顺手解决编码问题爬虫写到最后真正让人头疼的往往不是解析而是数据的呈现方式。我选择把结果保存成CSV用Excel打开时可以继续做筛选和透视。这里有个细节如果不用UTF-8 with BOMExcel打开CSV时中文会乱码。def save_to_csv(positions: list, path: str) - None: import csv if not positions: return fieldnames [job_name, company, salary_min, salary_max, city, education, publish_time, link] with open(path, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesfieldnames) writer.writeheader() writer.writerows(positions) print(f已保存 {len(positions)} 条职位信息到 {path})4. 反爬拦截的常见花招与对应防御姿势说实话求职网站并不算反爬力度最强的网站——它没有登陆才能看列表的限制、没有复杂验证码、也没有接口签名加密。但它的反爬策略仍然存在而且如果你不了解很容易在爬到一半时被封。我把自己实际踩过的几个坑展开说说。4.1 访问频率过高被临时限制这是最经典的封法。你用一个循环连续请求200页每页间隔0.1秒服务器日志里会看到一个IP在短时间内产生大量请求于是临时对该IP做限制。典型表现是前面几十页都正常突然连续几页返回403或提示访问过于频繁。解决办法不是上什么黑科技就是管住自己的手让每个请求之间留间隔import time import random for page in range(1, 11): html fetch_page(city_code, keyword, page) parse_position(html) # 每个请求之间随机等待 2~4 秒 time.sleep(random.uniform(2, 4))随机等待的意义在于固定等待时间本身也容易成为特征。假设每次请求都精确间隔2秒反爬程序可以据此精确识别。随机2到4秒之间每个请求间隔都略有不同接近人工浏览的节奏。4.2 字段缺失导致解析中断这不是反爬是数据质量问题但处理不当同样会让整个流程中断。比如某条职位只填了公司名岗位名称暂时空缺又比如某条职位的薪资是实习3千/月而不是标准的3千-5千我的parse_salary函数直接会被 invalid literal for float() 报错打断。后来我给解析函数加了一个更保守的兜底机制解析函数绝不主动抛异常任何无法解析的字段都返回None或空字符串。只有遇到网络错误这种不可恢复的异常才让它中断。def safe_parse_salary(text: str): try: return parse_salary(text) except (ValueError, AttributeError): return (None, None)4.3 HTML和JSON混用导致选择困难有些求职网站特别有意思列表页第一页是JS直接渲染的HTML第二页开始却走JSON接口。如果你只盯着HTML解析第一页能抓到后续页就抓不全反之如果你只盯着JSON第一页可能啥也拿不到。我的应对方法是**fetch_page返回原始文本parse_position内部做一次嗅探**——先尝试按JSON解析失败了再按HTML解析。这样不管网站前端改成什么样都能自适应。def smart_parse(raw_text: str) - list: try: return parse_json_position(raw_text) except json.JSONDecodeError: return parse_html_position(raw_text)4.4 登录态失效导致返回空列表还有一种隐蔽的情况接口返回200JSON解析也正常但list是空的。这种往往是因为请求需要携带登录Cookie而你的Cookie过期了。求职场网站通常会要求登录之后才能看完整职位列表。应对方式有两种。一种是登录后复制Cookie写进配置里headers[Cookie] config[my_cookie]另一种是更规范的方案在请求之前用账号密码先调一次登录接口拿到Cookie后写入一个session对象后面的请求全部复用这个session。第二种方案更自动化但需要维护身份信息对个人求职场景来说第一种方案够用且更稳妥。5. 数据清洗与增量存储别让数据库被重复职位撑爆第一版爬虫跑通之后你会面临一个很现实的问题每天跑一次同一个职位会反复被抓到。如果不做去重一周之后你的CSV里会有大量重复数据筛选时看到同样的职位出现五六次非常影响体验。5.1 用职位ID做唯一标识绝大部分求职网站的职位都有一个唯一ID在JSON里通常叫jobId或者包含在jobLink的URL参数里。我用它作为去重的键。def extract_job_id(link: str) - str: import re match re.search(rjob/(\d), link) return match.group(1) if match else link5.2 增量爬取的核心思路去重逻辑不复杂每次爬取之前把本地已有的职位ID集合加载进内存每解析出一条新职位先判断ID是否在集合里如果已经在直接跳过如果不在加入集合并追加到CSV。def load_existing_ids(path: str) - set: import csv ids set() try: with open(path, r, encodingutf-8-sig) as f: for row in csv.DictReader(f): ids.add(row[job_id]) except FileNotFoundError: pass return ids def append_new_positions(positions: list, existing_ids: set, path: str): new_positions [] for p in positions: if p[job_id] not in existing_ids: existing_ids.add(p[job_id]) new_positions.append(p) with open(path, a, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesconfig[fieldnames]) writer.writerows(new_positions)这样每天的增量更新只需要十几秒先请求前几页看数据只把真正的新职位写进文件里。我的日常流程是每天早上9点跑一次把前24小时新增的职位单独生成一份今日新增.csv投简历效率提高不少。5.3 关键词自动分类小技巧只抓下来还不足以高效投递我额外写了一个tag_position函数根据岗位名称给每条数据打上标签岗位名包含爬虫数据分析数据挖掘的标签为数据方向岗位名包含前端WebJavaScript的标签为前端方向岗位名包含后端JavaGoPython的标签为后端方向其余的归入其他。这个分类不需要机器学习纯规则就行但效果很显著。我每天打开CSV之后可以直接按标签筛选不用再从头到尾把两百条职位都看一遍。6. 合规红线与爬虫的边界感写爬虫的人尤其是新手特别容易陷入一种思维只要能抓到数据就说明这是允许的。实际上技术上可行和法律上合规永远是两回事。我在求职爬虫这个场景里给自己立了几条规矩也建议所有看到这篇内容的人参考。6.1 只爬公开可见的数据我这里只请求了无需登录、或仅需登录后即可正常浏览的职位列表接口这些数据在网站上对任何注册用户公开可见。我绝不尝试通过遍历ID抓取非公开数据也不触碰任何需要额外权限才能查看的信息。6.2 控制频率不要影响网站正常服务爬虫的技术再强也不能把对方的服务器拖垮。我所有请求之间的间隔都保持在2秒以上单次只抓前10页一天最多运行两次。这个频率对一个中小型网站来说几乎可以忽略不计。这里有一条朴素的判断标准如果你的爬虫让网站变慢了那就说明爬得太狠了。6.3 数据只用于个人求职不做二次分发我抓下来的职位数据只存在我的个人电脑里用来筛选和投递不公开共享也不打包转卖。职位信息虽然不涉及隐私但它依然是网站运营方投入成本整理发布的内容尊重数据来源是基本原则。6.4 学会识别网站的 robots 协议在写爬虫之前看一下目标网站的根路径下是否有/robots.txt。它是一份君子协定告诉访问者哪些路径允许爬取、哪些路径禁止。作为个人开发者我没有能力也无意去踩这条线——如果目标站点明确禁止了列表接口的爬取我会放弃这个技术方案换用人工查看或寻找其他公开数据源。合规的核心不是没有被告就等于没有问题。而是你自己在动手之前就清楚地知道边界在哪里并且有意识地站在这条边界之内。7. 实测心得与后续优化思路这套爬虫我连续跑了一周多每天一次增量更新累计抓到了600多条职位信息。从数据质量看真正对口的岗位大约占三分之一从稳定性看没有出现过一次因为反爬而中断的情况。实际用下来有几个感受想分享给你。7.1 职位信息爬取只是起点筛选和投递才是重心数据拿回来之后真正花时间的是筛选。我后来在CSV里加了两列一列是是否已投递手动标记一列是公司规模从职位详情页里额外解析出来的。这样每次打开表格一眼就能看到剩下的机会还有哪些。7.2 程序设计上时刻为网站改版做准备求职网站的页面结构每隔几个月就可能改一次。我这次没有把解析规则写死在parse_position里而是单独抽了一个配置区所有CSS类名、JSON字段名、URL参数都集中在同一个地方。网站改版之后只需要更新这个地方不用重写整个爬虫。这个习惯强烈建议养成。7.3 爬虫失败时的报警很重要因为我每天只跑一次跑完就关电脑如果哪次因为网络问题失败了可能当天新增的职位就漏掉了。我给最后的主函数外面套了一层通知逻辑运行结束后如果保存的文件里新增了职位就发一条提醒到自己的手机如果运行报错也会收到一条包含错误信息的提醒。这样不需要每天都盯着终端输出。def notify(title: str, body: str) - None: # 使用个人常用的消息推送渠道自行选择即可 pass if __name__ __main__: try: run_pipeline(city_code, keyword, pages5) notify(职位抓取完成, 今日新增职位已更新) except Exception as e: notify(职位抓取失败, str(e))7.4 下一步可以做的扩展方向这套框架的扩展空间其实很大。比如把职位详情页里的岗位职责、任职要求也抓下来用关键词做一次匹配度打分把所有职位按匹配分数从高到低排序再比如对薪资、学历、经验要求做一次简单的统计分析能大概看出当前的求职市场在给应届生什么样的定价区间。我个人接下来的计划是把匹配度打分做出来。毕竟职位信息爬下来只解决了一半问题真正的目标永远是用最少的时间找到最适合自己的那一个机会。最后再分享一个小技巧不要只抓一个网站。同一个职位信息往往在多个渠道都有发布把两三个渠道的数据抓下来合并去重之后你看到的招聘市场全貌会更接近真实。但前提依然是每个来源都遵守对应的访问频率和robots约定。爬虫能帮你做的事很多但前提是你始终清楚自己在为什么而爬。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑