资讯动态

Scrapy招聘数据采集实战:AI岗位爬取与pandas多维度统计

发布时间:2026/10/18 16:20:58 来源:尧图企业网站定制
简介这份实战包面向数据分析学习者、求职调研者与课程设计需求者聚焦Boss直聘上大数据、数据分析、数据挖掘、机器学习、人工智能等热门岗位的采集与分析。资源以Scrapy框架为核心通过纯HTTP请求高效抓取全国热门城市岗位信息输出结构化CSV文件字段涵盖公司名称、职位名称、薪资范围、学历要求、工作经验、工作地点、行业类别与技能关键词并已完成基础去重与格式归一化可直接导入Excel或pandas、matplotlib、seaborn做区域分布热力图、薪资中位数对比、学历需求饼图与高频技能词云等可视化分析。压缩包共34个文件约235KB以13个py脚本承载爬虫工程与清洗逻辑12个js及html、css支撑辅助页面另含csv数据、png图表、cfg配置与txt说明spiders解析、pipelines清洗、middlewares反爬与settings参数齐备runspider.py可一键启动。已有73人学习适合快速复现完整采集到可视化的分析链路。1. 招聘数据里的信号为什么值得自己爬一遍 AI 岗位招聘平台上的岗位数据是少数能同时反映技术趋势和用人市场冷热的公开信号源。你如果只翻几页搜索结果看到的永远是平台想让你看到的排序但如果你把「AI 类岗位」这个关键词下的岗位名称、薪资区间、城市分布、学历要求、技能标签全部结构化落成 CSV再跑一遍多维度统计很多反直觉的结论会自己浮出来——比如某些城市岗位量不大但薪资中位数明显偏高比如「大模型」相关岗位对学历的要求反而比传统算法岗更宽松。这套实战包要解决的就是这件事用 Scrapy 把招聘列表页和详情页的字段稳定抓下来落成 CSV再用 pandas 做多维度统计。它适合有 Python 基础、想练一个完整爬虫项目的数据方向从业者也适合想转 AI 岗、想用数据看清市场真实需求的人。整条链路不依赖任何平台内部接口全部走公开页面解析可控、可复现、可迁移到其他招聘站点。2. 用 Scrapy 搭出可复现的采集骨架2.1 为什么选 Scrapy 而不是 requests BeautifulSoup单页抓取用 requests 加解析库确实更快上手但招聘数据采集有三个特点决定了它更适合 Scrapy第一列表页要翻页详情页要逐条跟进这是典型的「两级请求」结构Scrapy 的Request回调链天然适配第二字段多且分散Item Pipeline 能把「抓取」和「清洗落盘」解耦改字段不用动爬虫逻辑第三反爬是常态Scrapy 的中间件机制让你能统一挂代理、改请求头、控速不用在每个函数里重复写。我一般会先建一个最小可跑的 Spider把列表页翻页和详情页跟进跑通再往里加字段和清洗规则避免一上来就写一大坨然后调不动。2.2 建项目与定义 Item 字段先建项目骨架再定义你要抓的字段。字段设计的原则是列表页能拿到的岗位名、公司、城市、薪资文本和详情页才能拿到的学历、经验、技能标签、岗位描述分开标注方便后面排查是哪一层解析出了问题。# 建项目名字用 job_ai 即可 scrapy startproject job_ai cd job_ai scrapy genspider ai_jobs example.com# job_ai/items.py import scrapy class JobAiItem(scrapy.Item): # 列表页字段 job_title scrapy.Field() # 岗位名称 company scrapy.Field() # 公司名 city scrapy.Field() # 城市 salary_text scrapy.Field() # 薪资原始文本如 20-40K·14薪 # 详情页字段 education scrapy.Field() # 学历要求 experience scrapy.Field() # 经验要求 skills scrapy.Field() # 技能标签列表 job_desc scrapy.Field() # 岗位描述纯文本 job_url scrapy.Field() # 详情页链接用于去重Item 里salary_text故意保留原始文本而不是直接转数字因为薪资格式太杂——「20-40K」「15-25K·13薪」「面议」都有转换逻辑放在 Pipeline 里统一处理Spider 只负责原样抓下来。job_url是去重的关键后面 Pipeline 里会用它做唯一键。2.3 列表页翻页与详情页跟进的回调写法列表页解析出每条岗位的详情链接用yield scrapy.Request跟进并把列表页已经拿到的字段通过meta传过去避免详情页重复解析。翻页用下一页按钮的链接不要自己拼页码因为很多站点的页码参数是加密的。# job_ai/spiders/ai_jobs.py import scrapy from job_ai.items import JobAiItem class AiJobsSpider(scrapy.Spider): name ai_jobs # 起始 URL 换成你要抓的 AI 岗位搜索页 start_urls [https://example.com/ai-jobs?page1] def parse(self, response): # 列表页每条岗位卡片 for card in response.css(div.job-card): item JobAiItem() item[job_title] card.css(a.job-name::text).get(default).strip() item[company] card.css(div.company-name::text).get(default).strip() item[city] card.css(span.city::text).get(default).strip() item[salary_text] card.css(span.salary::text).get(default).strip() detail_url card.css(a.job-name::attr(href)).get() if detail_url: # 把列表页字段带进详情页回调 yield response.follow( detail_url, callbackself.parse_detail, meta{item: item} ) # 翻页找下一页链接 next_page response.css(a.next-page::attr(href)).get() if next_page: yield response.follow(next_page, callbackself.parse) def parse_detail(self, response): item response.meta[item] item[education] response.css(span.edu::text).get(default).strip() item[experience] response.css(span.exp::text).get(default).strip() item[skills] response.css(div.skill-tags span::text).getall() # 岗位描述取纯文本去掉多余空白 item[job_desc] .join(response.css(div.job-detail::text).getall()).strip() item[job_url] response.url yield item这里的关键点是meta{item: item}把列表页已经填好的字段透传到详情页详情页只补它独有的字段。翻页用response.follow而不是手动拼 URL是因为它会自动处理相对路径。如果你发现翻到第二页就断了先检查a.next-page这个选择器在第二页的 DOM 里是否还存在——很多站点最后一页会把下一页按钮换成 disabled 状态选择器要相应调整。2.4 控速、重试与请求头中间件配置招聘站点对高频请求很敏感默认配置跑不了几百条就会被限。在settings.py里把并发降下来、开重试、加随机延迟这是保命配置。# job_ai/settings.py 关键项 ROBOTSTXT_OBEY True # 遵守 robots别硬刚 CONCURRENT_REQUESTS 4 # 并发降到 4别贪快 DOWNLOAD_DELAY 1.5 # 基础延迟 1.5 秒 RANDOMIZE_DOWNLOAD_DELAY True RETRY_ENABLED True RETRY_TIMES 3 # 失败重试 3 次 RETRY_HTTP_CODES [403, 429, 500, 502, 503] DOWNLOAD_TIMEOUT 15 # 默认请求头补一个正常的 UA DEFAULT_REQUEST_HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept-Language: zh-CN,zh;q0.9, }DOWNLOAD_DELAY配合RANDOMIZE_DOWNLOAD_DELAY会让实际延迟在 0.5 到 1.5 倍之间随机浮动比固定延迟更难被识别。RETRY_HTTP_CODES里把 403 和 429 加进去是因为限流通常先返回这两个状态码重试时如果还是失败就该考虑进一步降速或换时间窗口而不是继续硬刷。3. 字段清洗与 CSV 落盘把脏文本变成可统计的表3.1 薪资文本的三种格式与统一转换薪资是清洗里最容易翻车的地方。常见格式有三类「20-40K」这种区间、「15-25K·13薪」带薪数、「面议」或「200-300元/天」这种非月薪。统一策略是只处理能明确转成月薪区间的其余标记为缺失不要硬猜。转换时把「K」当 1000「薪」当月份数乘进去算年薪再除 12 得到月均。# job_ai/pipelines.py import re import csv from itemadapter import ItemAdapter class CleanPipeline: def process_item(self, item, spider): adapter ItemAdapter(item) salary adapter.get(salary_text, ) low, high self.parse_salary(salary) adapter[salary_low] low # 月薪下限单位元 adapter[salary_high] high # 月薪上限单位元 # 技能标签去重去空 skills adapter.get(skills) or [] adapter[skills] list({s.strip() for s in skills if s.strip()}) return item def parse_salary(self, text): if not text or 面议 in text: return None, None # 匹配 20-40K 或 20-40K·14薪 m re.search(r(\d)-(\d)K(?:·(\d)薪)?, text) if not m: return None, None low int(m.group(1)) * 1000 high int(m.group(2)) * 1000 months int(m.group(3)) if m.group(3) else 12 # 按薪数折算成 12 个月口径的月均 factor months / 12 return round(low * factor), round(high * factor)parse_salary返回两个值缺失时返回(None, None)后面统计时用dropna过滤掉而不是填 0——填 0 会把薪资中位数拉低这是很多人第一次做薪资统计时踩的坑。·14薪的折算逻辑是按总年薪除以 12 得到月均这样不同薪数的岗位才能放在同一口径下比较。3.2 技能标签的归一化与去重技能标签的脏在于同义词和大小写「Python」「python」「PYTHON」是同一个「机器学习」「ML」大概率也是同一个。归一化用一张映射表把常见变体收敛到标准写法映射表之外的保留原样不要强行合并。# 技能归一化映射按需扩充 SKILL_MAP { python: Python, pytorch: PyTorch, tensorflow: TensorFlow, ml: 机器学习, machine learning: 机器学习, nlp: 自然语言处理, cv: 计算机视觉, llm: 大语言模型, 大模型: 大语言模型, } def normalize_skills(skills): result set() for s in skills: key s.strip().lower() result.add(SKILL_MAP.get(key, s.strip())) return sorted(result)归一化放在 Pipeline 里、落盘之前做这样 CSV 里存的就是干净标签后面做词频统计不用再洗一遍。映射表不要一次写太大先跑一批数据看看实际出现了哪些变体再针对性补否则容易把不该合并的合并掉。3.3 用 Pipeline 直接写 CSV 并做 URL 去重落盘用 Python 内置 csv 模块写不依赖 pandas这样爬虫跑完就能直接拿到文件。去重用job_url做集合判断重复的直接丢弃。class CsvPipeline: def open_spider(self, spider): self.seen set() self.file open(ai_jobs.csv, w, newline, encodingutf-8-sig) self.writer csv.writer(self.file) self.header_written False def process_item(self, item, spider): adapter ItemAdapter(item) url adapter.get(job_url) if url in self.seen: return item # 重复的直接跳过 self.seen.add(url) if not self.header_written: self.writer.writerow(adapter.field_names()) self.header_written True self.writer.writerow([adapter.get(f) for f in adapter.field_names()]) return item def close_spider(self, spider): self.file.close()编码用utf-8-sig这样 Excel 直接打开不会乱码这是血泪经验——用普通 utf-8 写的中文 CSV 在 Excel 里打开经常是一堆问号。field_names()拿的是 Item 定义的全部字段所以 Item 里加了新字段CSV 表头会自动跟着变不用手动维护列名。4. 多维度统计从 CSV 到能看懂的结论4.1 城市、学历、经验三个维度的分布统计拿到 CSV 后用 pandas 做分组统计。城市维度看岗位量和薪资中位数学历和经验维度看要求分布。注意薪资用中位数而不是均值因为少数高薪岗位会把均值拉飞。import pandas as pd df pd.read_csv(ai_jobs.csv) # 过滤掉薪资缺失的行再做薪资统计 df_salary df.dropna(subset[salary_low, salary_high]) df_salary[salary_mid] (df_salary[salary_low] df_salary[salary_high]) / 2 # 城市维度岗位量 薪资中位数 city_stat df_salary.groupby(city).agg( job_count(job_title, count), salary_median(salary_mid, median) ).sort_values(job_count, ascendingFalse) # 学历维度分布 edu_stat df[education].value_counts() # 经验维度分布 exp_stat df[experience].value_counts()salary_mid用区间中值代表该岗位薪资比直接用下限或上限都更稳。groupby之后用agg同时算岗位量和中位数一次拿到两个指标。如果某个城市岗位量只有个位数它的薪资中位数参考价值很低统计时建议加一个job_count 5的过滤避免被小样本误导。4.2 技能词频统计与共现关系技能标签是列表要先展开再统计词频。共现关系看的是哪些技能经常一起出现比如「Python PyTorch」这种组合。from collections import Counter from itertools import combinations # 展开所有技能标签 all_skills [s for skills in df[skills].dropna() for s in eval(skills)] skill_freq Counter(all_skills).most_common(20) # 共现每行岗位的技能两两组合 pair_counter Counter() for skills in df[skills].dropna(): skill_list eval(skills) for pair in combinations(sorted(set(skill_list)), 2): pair_counter[pair] 1 top_pairs pair_counter.most_common(15)eval这里是因为 CSV 里列表被存成了字符串形式读回来要还原。如果担心安全问题可以换成ast.literal_eval。共现统计前先set去重避免同一岗位里重复标签把组合数算多。词频看的是「哪些技能被要求得最多」共现看的是「哪些技能是打包出现的」后者对判断技术栈组合更有用。4.3 薪资区间的分箱与可视化前的数据准备薪资分箱是为了画分布图把连续薪资切成几个区间统计每个区间的岗位数。分箱边界按实际数据的分位数来定不要拍脑袋。# 按分位数切 5 档 df_salary[salary_bin] pd.qcut( df_salary[salary_mid], q5, labels[低, 中低, 中, 中高, 高] ) bin_stat df_salary[salary_bin].value_counts().sort_index() # 导出统计结果供可视化使用 city_stat.to_csv(city_stat.csv, encodingutf-8-sig) pd.DataFrame(skill_freq, columns[skill, count]).to_csv(skill_freq.csv, encodingutf-8-sig)qcut按分位数切保证每档岗位数大致均衡比等宽分箱更合理——等宽分箱在薪资右偏分布下会把大部分岗位挤进最低档。统计结果单独导出成 CSV可视化工具直接读这些文件就行不用再碰原始数据。5. 避坑与排查采集和统计里最容易翻车的五件事5.1 列表页翻到第二页就空现象第一页正常抓到岗位第二页返回空列表或直接跳到登录页。原因通常是翻页链接需要携带 cookie 或 token而 Scrapy 默认不维持会话。解决在settings.py里开COOKIES_ENABLED True并在起始请求里带上必要的 cookie如果站点用 token 拼在 URL 里就从第一页的下一页链接里提取 token 参数后续翻页复用。5.2 详情页字段大面积抓空现象列表页字段都有值详情页的学历、经验全是空字符串。原因多半是选择器写错了层级或者详情页的 DOM 结构和列表页不一样。解决先用scrapy shell打开一个详情页 URL逐条验证选择器确认response.css(span.edu::text).get()能拿到值再写进代码。别在编辑器里盲写选择器这是最常见的翻车点。5.3 薪资统计结果明显偏低现象算出来的薪资中位数比预期低一大截。原因通常是「面议」岗位被填了 0 而不是丢弃或者「·14薪」没折算直接当月薪。解决检查parse_salary的返回值缺失一律返回(None, None)统计前用dropna过滤薪数折算逻辑单独写单元测试验证几个典型输入。5.4 CSV 中文乱码现象Excel 打开 CSV 全是乱码。原因是用 utf-8 编码写入Excel 默认按 GBK 解析。解决写文件时用encodingutf-8-sig这个编码会在文件头加 BOMExcel 能正确识别。如果已经写完了用记事本另存为 UTF-8 with BOM 也能救回来。5.5 技能词频被同义词拆散现象词频统计里「Python」和「python」各占一条实际数量被拆成两半。原因是没有做归一化就统计。解决在 Pipeline 里落盘前就做归一化或者统计前先对all_skills跑一遍映射。归一化映射表要持续维护每跑一批新数据就看看有没有新的变体需要合并。6. 把统计结果变成可复用的分析习惯这套链路跑通之后真正有价值的不是某一次抓下来的 CSV而是你形成了一套「采集 → 清洗 → 统计 → 验证」的固定动作。我自己的习惯是每次跑完爬虫先看 CSV 行数和去重后的 URL 数是否一致不一致说明去重逻辑有问题再看薪资字段的缺失率如果超过 30%说明薪资解析规则覆盖不够要回去补格式最后看技能词频的前 20 名里有没有明显的同义词没合并。这三步检查花不了五分钟但能挡掉大部分「数据看起来对、结论其实是错的」的情况。进阶用法上你可以把统计脚本参数化让城市、学历、经验这些维度通过命令行参数传入这样同一份 CSV 能快速切出不同视角。比如加一个--group-by city参数统计脚本就按城市分组输出不用每次改代码。另一个方向是把技能共现结果做成一个简单的关联表看哪些技能组合的岗位薪资中位数更高——这比单看词频更能指导学习方向。我一般会把每次跑出来的统计结果按日期存一份过几个月回头看能看出某些技能要求的涨落趋势这比任何单次快照都有意思。最后说一个我踩过的坑早期我为了抓全把并发开到 16结果跑了不到两千条就被限换 IP 重跑又因为去重集合没持久化重复抓了一堆。后来改成并发 4、延迟 1.5 秒、去重集合落盘虽然慢但一次跑完不用返工反而更省时间。爬虫这件事稳比快重要。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑