news 2026/10/14 7:39:31

Python爬虫实战:破解JS加密采集招聘平台城市薪资数据

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python爬虫实战:破解JS加密采集招聘平台城市薪资数据

最近我一直在琢磨一个事:各城市的薪资水平到底差多少?招聘平台上其实躺着现成的答案,但一个城市一个城市手动搜、手动记,眼睛都能看花。于是我决定写个 Python 爬虫,把招聘平台公开的薪资数据批量拿下来,汇成一张全国城市薪资排行榜单。

这个项目最折腾人的反而不是爬虫本身,而是目标站点在关键接口上做了参数加密,请求里那个动态签名不还原出来,requests 写得再漂亮也拿不到数据。所以这次实战的核心其实是两条线并行:一条是常规的爬虫采集链路,另一条是 JS 逆向的参数还原链路。整篇内容我会用通用方法论来拆解,重点讲清楚“为什么这样做”和“遇到类似站点怎么举一反三”,适合有 Python 基础、想搞懂爬虫逆向完整流程的读者参考。

1. 项目目标与合规边界:先搞清楚要采什么、哪些线不能碰

1.1 需求拆解:城市薪资排行榜需要哪些数据维度

动手写代码之前,先把需求捋清楚。我要的是一张“全国城市薪资排行榜”,听起来是一个表,但仔细往下拆,它的最小数据单元其实是“城市 + 岗位 + 薪资 + 样本量”的组合。

举个例子,光说“北京平均月薪 1.8 万”是没有意义的,因为北京有算法工程师也有前台行政,二者薪资差距可能超过一倍。真正能打的排行榜至少要有这四个维度:

  • 城市:数据按城市聚合,常见字段是 city_name、city_code
  • 岗位分类:至少要分到“技术、产品、设计、运营、市场、销售”这个颗粒度,再细一点可以到具体职位名
  • 薪资区间:招聘平台上通常不是单个数字,而是“15k-25k”这种区间,需要做中点换算
  • 发布时间与样本量:同一城市不同时间段的薪资波动可能很大,样本量太少的数据聚合出来也没有参考价值

这些维度定了,后面写模型、写清洗逻辑才有依据。不然爬到一半才发现少了关键字段,回头补数据的工作量非常大。

1.2 合规边界:技术交流可以,但采集必须守住底线

标题里写了“仅技术交流”,那就更要把合规这件事摆在桌面上说清楚。我的做法是给自己划了几条硬性红线:

  • 只采集招聘平台公开页面或接口中展示的数据,不碰需要登录才能看到的非公开信息,更不碰用户个人隐私
  • 遵守 robots.txt 约定,采集频率控制在远低于正常人工访问的水平,不搞并发轰炸
  • 数据仅用于个人学习和技术验证,不做商业用途,不批量导出后对外发布
  • 不针对任何站点做未授权的接口绕过或越权访问

另外,“逆向”这个词在爬虫语境里指的是分析前端 JS 加密逻辑、还原请求参数,本质上是研究自己浏览器发出的请求,这在技术圈是很常规的学习方向。但如果你拿这套能力去撞接口、绕验证码、薅付费数据,那就越界了。技术本身中性,边界在于你怎么用。

2. 抓包定位核心接口:从几十个请求里找出真正干活的

2.1 开发者工具第一课:过滤出 XHR 请求

打开目标招聘平台的城市薪资页面,按 F12 进入开发者工具,切到 Network 面板。别急着看瀑布流,先做过滤:在筛选栏里勾选 Fetch/XHR,把图片、CSS、字体文件这些噪音全部去掉。

这时候你会看到一列异步接口。招聘类站点通常还有一个共性:首次进入页面时,页面框架和数据是分开加载的,真正的薪资数据藏在一个 JSON 接口里,而不是直接渲染在 HTML 中。这个接口往往带有 salary、job、search、board 之类的关键词,一眼扫过去基本能锁定。

锁定了接口之后,先看它的响应体。如果返回的是标准 JSON,里面有 salary 字段、city 字段、count 字段,那恭喜,这个站点的核心数据是明文返回的。这次项目里最麻烦的地方就在这里——响应体虽然能看懂,但请求参数里多了一个每次都在变的动态签名,不搞定它,请求发出去就是 403。

2.2 判断加密参数的通用方法:对比法

怎么快速判断哪个参数是加密的?我的习惯是同一页面换不同条件分别请求两次,对比两次请求的 Payload 差异。

比如第一次选“北京 + 技术岗”,第二次选“上海 + 产品岗”。两次请求里,city 和 jobType 的变化是正常的,但如果你发现有一个参数两次都不相同,而且看不出规律,那大概率就是动态签名。常见的命名有 sign、token、nonce、_t、secret,也有干脆叫一堆乱码的。

这时候不要急着自己猜算法,先去 Sources 面板里全局搜索这个参数名。按 Ctrl+Shift+F 全局搜索,输入参数名,Node 模块、JS 文件、网页源码里所有出现的位置都会列出来。一般来说,参数名出现的 JS 文件附近,就是加密逻辑的藏身之处。

2.3 响应体的字段结构:先设计存储模型

抓包的过程中我已经把接口响应里感兴趣的字段记下来了,这个动作很多人会偷懒,但非常不建议省。拿到一个字段清单之后,顺手就把数据库模型设计出来,后面不管是跑数据清洗还是做排行都会省事很多。

这次我用 SQLAlchemy 来存爬取结果,模型大概长这样:

from sqlalchemy import create_engine, Column, String, Integer, Float, DateTime from sqlalchemy.ext.declarative import declarative_base Base = declarative_base() class CitySalary(Base): __tablename__ = "city_salary" id = Column(Integer, primary_key=True, autoincrement=True) city = Column(String(50), index=True) job_category = Column(String(50)) salary_min = Column(Integer) salary_max = Column(Integer) sample_count = Column(Integer) fetch_date = Column(DateTime, index=True)

为什么用 SQLAlchemy 而不是直接写 SQL?因为爬虫的字段经常要调,ORM 模型改起来比 SQL 语句安全,而且后面接入 Pandas 做分析也方便,一个 session.query 就能把数据导成 DataFrame。对于这种轻量级爬虫存储需求,SQLite 作为底层就够用了,不需要上 MySQL。

3. JS 逆向的参数还原:断点、调用栈与补环境三板斧

3.1 打断点定位加密函数

参数名的出现位置只能告诉你“加密逻辑大概在这里”,要精确定位到函数级别,还是得靠断点。这一步是整场爬虫实战里最容易卡住的地方,方法本身不复杂,但需要一点耐心。

在 Sources 面板里找到包含参数名的那个 JS 文件,先点左下角的格式化按钮,把压缩成一行的代码展开成可读格式。然后在 Payload 里下断点——Chrome 的 Network 面板支持直接右键请求,选“Break on fetch/XHR”,下次这个接口发起请求时会自动停在调用位置。

断点一停,看右侧的 Call Stack 调用栈。从下往上翻,重点找那些名字里带 encrypt、sign、getToken、genSign 这类关键词的函数。找到了就点进去,在函数内部再打几个断点,重新触发一次请求,单步执行,观察每个变量的值变化。

这套流程是通用的:定位参数名,搜索关键词,打断点,追调用栈,单步看值。不管目标站点用的是 MD5 还是 AES,只要你能让代码停在加密函数执行前,剩下的事情就是读代码。

3.2 从代码里识别加密特征

加密函数往往不会写得那么直白,多数时候是混淆过的,变量名是一堆 a、b、c,函数名也看不出业务含义。这种情况不要想着逐行读完整段逻辑,而是抓特征。

常见的加密体系有几种:

  • 哈希类:看到 CryptoJS.MD5、sha256 之类的调用,多半是时间戳 + 盐值拼接后做哈希
  • 对称加密类:看到 AES.encrypt、DES.encrypt,通常需要逆向出 key 和 iv,这两个参数往往藏在同一个文件的其他位置
  • 自定义拼接:很多站点根本不加密,只是把参数打乱顺序再加点盐,这种在浏览器里执行一次原函数就能看到结果

判断到底属于哪一类,最直接的办法是搜索关键词。在格式化后的 JS 文件里搜索 encrypt、sign、digest、sha、md5、base64 这些特征词,搜索结果会帮你快速缩小包围圈。

3.3 Node.js 补环境:把浏览器函数搬出来跑

找到加密函数之后,我通常不会马上去读它到底怎么实现的,而是先把它原封不动搬进 Node.js 里跑一遍。为什么要这么做?因为浏览器里的 JS 函数依赖了 window、document 这些浏览器环境对象,直接复制到 Node 里会报错报到你怀疑人生。所以我们需要“补环境”,也就是把函数依赖的那几个浏览器对象手动 mock 出来。

实际操作分三步:

  1. 把目标函数及其依赖的工具函数复制到一个 js 文件里
  2. 在文件开头定义缺失的浏览器对象,比如window = globalThis、navigator = {userAgent: 'Mozilla/5.0'}、document = {}
  3. 在文件末尾导出函数,用 node 命令行跑一次,看输出的签名值是否和浏览器里的请求参数一致

只要 Node 里跑出来的值跟浏览器请求里抓到的值一致,这个加密函数就算还原成功了。后面的工作就是用 Python 重写,或者直接用 PyExecJS 在 Python 里调 Node 脚本。

这里给你看一个非常典型的补环境示例框架,逻辑是通用的,具体加密函数名我用占位符代替:

// 模拟浏览器环境 window = globalThis; navigator = { userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" }; document = {}; // 假设 encryptSign 是抠出来的目标函数 function encryptSign(params, salt) { // 这里的实现来自目标的 JS 文件 // 常见逻辑是 params 排序拼接 + salt + 时间戳,然后做哈希 return hex_md5(params + salt + Date.now()); } // 导出供外部调用 module.exports = { encryptSign };

注意,这里的 hex_md5 只是一个代称,真实情况下你要把目标 JS 里的哈希函数一并抠出来。很多时候目标站点的加密逻辑就是“参数按字典序排序 + 固定盐值 + 时间戳”拼接后做 MD5,属于最简单的动态签名,但正因为简单,反爬效果也还行,因为盐值一般藏在 JS 里,你不逆向还真不知道。

3.4 用 Python 调 Node 还是纯 Python 重写?

抠出加密逻辑之后有两个选择:一是纯 Python 重写一遍,二是用 Python 调用 Node 执行 JS 脚本。我的经验是分情况处理:

  • 如果加密逻辑很短,比如就是拼接字符串做 MD5,那直接用 Python 的 hashlib 重写,性能好、部署简单
  • 如果加密逻辑很长且依赖了很多第三方 JS 库,原样重写容易出错,用 PyExecJS 或 subprocess 调 Node 更稳

PyExecJS 用起来很简单:

import execjs with open("sdk_sign.js", "r", encoding="utf-8") as f: js_code = f.read() ctx = execjs.compile(js_code) sign = ctx.call("encryptSign", "city=beijing&job=tech", "fixed_salt") print(sign)

但 PyExecJS 有个通病,就是没有真实浏览器环境,如果你要跑的 JS 依赖 DOM API,它还是会挂。这时候我会选择另一种思路:用 Playwright 直接在这个页面里执行加密函数,拿到签名后再交给 requests 去发请求。相当于把浏览器当签名生成器用,这也是很多爬虫工程师实际采用的方式,稳定且不挑 JS 复杂度。

4. 请求工程化:拿到签名之后,让采集任务跑得又稳又温柔

4.1 请求头与 Cookie:这些细节决定你能否顺利拿到数据

签名还原之后,请求基本能通了,但离“稳定采集”还差几步。请求头必须模拟得像一个正常浏览器,重点关注四个字段:

  • User-Agent:用最新版 Chrome 的真实 UA,别用 Python-requests 的默认 UA
  • Referer:指向当前页面地址,很多站点会校验 Referer
  • Accept:application/json, text/javascript, */*; q=0.01这类标准异步请求头
  • Cookie:如果接口需要登录态,登录后的 Cookie 要放进请求里,同时考虑过期时间

还有一个细节是很多新手会忽略的:首次访问页面时会种下一些 Cookie,这些 Cookie 可能和后续接口的签名校验有关。所以我的习惯是用 requests.Session() 保底,先用 GET 访问页面拿到初始 Cookie,再发带签名的数据请求。

import requests from fake_useragent import UserAgent session = requests.Session() ua = UserAgent() headers = { "User-Agent": ua.chrome, "Referer": "https://example.com/salary-board", "Accept": "application/json, text/javascript, */*; q=0.01", } # 先访问页面拿 Cookie session.get("https://example.com/salary-board", headers=headers)

4.2 频率控制:不是越快越好,是越稳越好

爬虫圈有个常见的误解,觉得并发越高越快。但招聘平台的反爬能力不弱,你新手光环期可能没被封,连续跑几个小时后就会收到验证码或者 403。我在这个项目里的策略是:单线程 + 随机延时 + 失败退避。

延时不能是固定值。固定 2 秒的延时很容易被识别成机器行为,真正的用户操作是忽快忽慢的。所以我一般用random.uniform(1.5, 3.5),再叠加一点业务逻辑的随机性,比如数据多的城市多休息一下。

失败退避也很关键。如果某个城市连续请求 3 次都失败,立刻停止对这个城市的请求,把任务标记为失败,而不是无限重试。无限重试的后果通常是封 IP,封了再换代理又是一轮折腾。

4.3 断点续采与 SQLAlchemy 的工程化应用

全国城市的量级是几百个,岗位分类再拆一拆,总共可能上千个组合。如果跑到一半程序崩了,从零开始重跑是非常低效的。所以我在设计阶段就把断点续采考虑进去了,核心做法是每次爬完一个城市组合就立即入库,下次启动时先查数据库里已有哪些城市组合,跳过已采集的。

这里 SQLAlchemy 的查询能力就派上用场了:

from sqlalchemy.orm import sessionmaker from sqlalchemy import create_engine engine = create_engine("sqlite:///salary.db") Session = sessionmaker(bind=engine) session = Session() # 按天维度查到过的记录,避免重复采集 done_cities = set( session.query(CitySalary.city) .filter(CitySalary.fetch_date == today) .distinct() .all() )

这样就算程序跑了三天三夜中途崩了,重启后只需要补上没跑完的部分,数据不会重复,也不会漏。这是爬虫工程化里很重要的一课:爬虫不只是发请求、收数据,更是一个需要管理状态、容忍失败的工程任务。

4.4 代理池:个人学习真的不一定需要

这个话题有必要单独说,因为很多教程一上来就让你买代理池,我很不认同。个人学习项目根本不需要代理,你一天顶多跑几百个请求,还控制着频率,正常站点不会把你怎么样。

什么时候才需要代理?两种情况:

  • 目标站点对单个 IP 的请求频率有严格限制,且你确实需要短时间采集大量数据
  • 你的 IP 已经被临时封禁,但又不想等解封时间,需要用代理绕过封禁

如果你只是做城市薪资排行榜这种轻量级项目,老老实实限速跑,基本不会遇到第一个问题。万一真的被临时封了,也不要急着上代理,等待一段时间让封禁自动解除,顺便检查一下自己的延时设置是不是太粗暴了。

5. 数据清洗与排行计算:从原始 JSON 到一张能看的榜单

5.1 字段解析与异常值处理

接口返回的 JSON 通常是嵌套结构,城市、岗位、薪资可能散落在不同层级里。第一步是把嵌套 JSON 拍平成偏平结构,这个用 Pandas 的 json_normalize 最顺手,一次性把多层嵌套数据展开成 DataFrame。

展开之后马上会遇到脏数据。我在实采过程中碰到过这些问题:

  • 薪资范围是字符串,需要从“15k-25k”中提取 min 和 max,然后换算成具体数值
  • 部分岗位薪资为 0 或显示“面议”,这类数据不能直接参与排行计算
  • 样本量很少的城市,比如只有 3 条数据,算出来的平均值几乎没意义,要做过滤

这里我给一个非常实用的清洗策略:样本量小于 20 的城市组合直接不进榜单,薪资为 0 的记录剔除,薪资超过 200k 的极端值按异常值剔除。这个策略不是拍脑袋定的,而是我在分析数据分布后看到的自然断裂带,你也可以根据自己抓到的数据范围做调整。

5.2 排行口径:平均薪资之外,还要看中位数和样本量

排行的核心问题是口径。平均薪资容易被极端值拉偏,比如某个城市有 10 个月薪 5 万的算法岗,平均薪资会被拉得很高,但大部分人的真实感受可能没这么乐观。所以我做榜单时会同时计算三个指标:

  • 平均薪资:最直观,也最容易理解
  • 中位数薪资:更接近真实感受,抗极端值干扰
  • 样本量:用来评价可信度,样本量越大,排名越有参考价值

具体的计算逻辑是这样的:先按城市分组,然后对每个城市的薪资列表求平均值和中位数,同时在结果里附上样本量。最后输出的时候,按“平均薪资从高到低”排序,但会把中位数和样本量一起展示出来,让读者自己判断可信度。

这样做出来的榜单比单纯一个平均薪资有说服力得多。我甚至还想清楚了可视化方案:用 Matplotlib 画一张横向柱状图,城市名在 Y 轴,平均薪资在 X 轴,柱子上标注样本量,一眼就能对比出城市间薪资的分布差异。

6. 实采复盘:那些教程里不会告诉你的坑

6.1 三个最典型的实战大坑

第一个坑是签名参数会过期。一开始我拿到签名函数后,以为这个签名是永久的,结果第二天再跑就发现请求全部 403。排查之后才意识到,签名算法里混入了时间戳,而且服务端会校验时间偏差超过一定范围就拒绝请求。解决办法是每次需要签名时都重新调用加密函数生成,而不是复用上一次的值。

第二个坑是接口返回的数据有分页上限。招聘平台的薪资接口通常不是一次返回全部数据,分页参数超过某个页数后会返回空数组。这个限制很隐蔽,表面上看起来像“该城市没有更多数据了”,实际是后端刻意限制深度。解决办法是把多个岗位分类拆开采集,缩小单次请求的数据范围,而不是死磕翻页。

第三个坑是 JS 文件会不定期更新。目标站点改了加密算法,我之前的逆向成果就全部作废。这个没法完全避免,我的应对措施是:把还原出来的 JS 脚本和调用方式封装成独立模块,下次加密算法变了,只需要替换这个模块,不用改主爬虫逻辑。这就是工程上说的“隔离变化”,做好了能帮你省下大量返工时间。

6.2 这套方法论能复用到哪些方向

这次做的是城市薪资排行榜,但整套流程完全能平行迁移到其他领域。比如城市房价中位数排行、岗位需求热度排行、行业薪资成长曲线、甚至某个垂直领域的技术栈分布统计,本质上都是同一套逻辑:抓包定位接口,逆向处理动态参数,工程化采集入库,清洗聚合后做排行展示。

核心能力其实是两个:一个是对目标站点网络请求的分析能力,另一个是把请求工程化、数据管道化的工程素养。前者解决“能不能拿到”,后者解决“拿得稳不稳、数据干不干净、后续能不能被复用”。这两样能力恰恰是爬虫工程师最值钱的地方。

我个人的体会是,爬虫项目最锻炼人的地方不是语法,而是面对不确定性时的处理思路。接口字段变了、频率被限制了、加密逻辑改了,每个意外都在逼你重新观察和思考。做完了这个项目再回头看,最值得骄傲的不是拿到了多少城市的数据,而是那套从抓包到逆向、从采集到清洗、最终沉淀成可复用模块的完整方法论。建议你也找一个类似公开榜单的小项目练练手,别贪多,把一个站点完整跑通,学到的东西比看十篇教程都扎实。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 7:39:27

铣削受力图与受力分析:从原理到代码的切削力计算指南

简介:这份资源围绕铣削加工中工件受力这一核心问题展开,面向金属切削工艺学习者、机械加工技术人员及机械专业学生,帮助理解铣削力、切削力与摩擦力的合成关系,并对比逆铣与顺铣两种方式的受力差异与适用场景。压缩包内共1个docx文…

作者头像 李华
网站建设 2026/10/14 7:39:17

Java后端转AI Agent实战:不必先学Python,用Java落地大模型应用

做Java后端做了13年,Spring Boot、分布式、中间件那些东西基本刻进肌肉记忆里了。去年开始认真转AI Agent方向,前后过程踩了不少坑,最离谱的是发现大多数人和我一开始一样,把力气全花在了学Python上。今天就先把结论摆出来&#x…

作者头像 李华
网站建设 2026/10/14 7:39:15

CS2 卡顿闪退黑屏排查手册:从帧率、驱动到网络延迟的全流程定位思路

CS2 卡顿、掉帧、闪退、黑屏卡死,这四个问题我在过去一年多里反复遇到过,身边朋友也经常拿这些事来找我。有的是一开局就掉到几十帧,有的是玩十几分钟屏幕一黑直接卡死,有的是明明网络很好却感觉人总被拉回去。说实话,…

作者头像 李华
网站建设 2026/10/14 7:38:45

顺序栈实现详解:数组+top指针的底层原理与边界控制

1. 项目概述:为什么一个“顺序栈”值得花一整篇干货来拆解?在某高校数据结构实验课的机房里,我见过太多学生盯着屏幕发呆——不是代码写不出来,而是根本没搞懂“顺序栈”这四个字背后到底在解决什么问题。它不像链表那样有指针跳转…

作者头像 李华
网站建设 2026/10/14 7:38:43

JavaScript原型链完全解析:从查找机制到继承与补环境实战

前阵子在HoRain云上面调试一个服务端渲染服务,日志里蹦出来一行奇怪的报错:TypeError: xxx.querySelector is not a function。本地浏览器里明明跑得好好的,怎么一到Node环境就翻车?排查到最后,发现是某个库在Node环境…

作者头像 李华
网站建设 2026/10/14 7:38:36

AnyPS5跨设备串流实战:从原理到避坑的完整指南

1. 从"AnyPS5"这个名字说起:它到底想解决什么问题第一次看到"AnyPS5"这个标题,我脑子里冒出来的第一个念头是:这大概率是一个围绕"PS5"这个关键词做文章的项目,而且"Any"这个前缀暗示了它…

作者头像 李华