Scrapy爬虫实战:从拉勾网招聘数据清洗到技能标签可视化分析

Scrapy爬虫实战:从拉勾网招聘数据清洗到技能标签可视化分析 1. 项目概述与核心价值最近在整理过往的数据分析项目时翻到了一个用Scrapy爬取拉勾网招聘数据的“老古董”。这个项目虽然有些年头但其中关于反爬对抗、数据清洗以及围绕岗位技能进行深度分析的思路至今依然非常经典和实用。很多朋友在入门爬虫和数据分析时都会选择招聘网站作为练手目标但往往卡在反爬策略和如何从海量数据中提炼出有价值的信息这两个关键点上。今天我就把这个项目的核心部分特别是其中最具挑战性的第4步数据清洗与结构化和第5步技能标签分析与可视化拿出来结合现在的技术环境重新梳理一遍希望能给正在学习Python数据抓取与分析的你提供一个可以直接复现的、有深度的实战案例。这个项目能帮你解决几个实际问题第一如何构建一个健壮的Scrapy爬虫来应对常见的反爬机制如请求头校验、频率限制第二拿到原始的、杂乱的JSON或HTML数据后如何进行系统性的清洗和规整为分析做准备第三也是最关键的一步如何从职位描述JD这片“文本矿山”中挖掘出企业最看重的技能标签并通过可视化清晰地展现行业需求趋势。无论你是想了解某个特定技术栈比如Python、Java的市场需求还是想分析不同城市、不同薪资区间的技能要求差异这套方法都能给你提供清晰的路径。2. 项目整体设计与爬虫架构解析2.1 目标定义与数据字段设计在动手写一行代码之前明确目标至关重要。我们本次分析的核心是“技能”因此所有数据字段的设计都应围绕这个目标展开。拉勾网的职位列表页和详情页包含了丰富的信息我们需要从中提取出与分析强相关的字段。我设计的核心数据表结构如下这决定了我们爬虫需要抓取哪些内容字段名数据类型说明分析用途position_nameString职位名称如“Python开发工程师”分类、筛选目标职位company_nameString公司名称辅助分析公司背景cityString工作城市地域分布分析salaryString薪资范围如“15-30K”薪资水平与技能关联分析experienceString经验要求如“1-3年”经验与技能关联分析educationString学历要求辅助分析job_detailText职位描述JD全文技能标签提取的核心来源job_advantageText职位诱惑如“技术大牛多”辅助分析公司文化publish_timeString发布时间分析招聘趋势注意salary字段在后续清洗中需要拆分为salary_low和salary_high两个数值型字段以便进行统计计算。job_detail是本次分析的“富矿”所有关于Python、Django、Spark、Hadoop等技能关键词都将从这里提取。2.2 Scrapy爬虫框架选型与核心组件为什么选择Scrapy而不是RequestsBeautifulSoup对于拉勾网这种具有清晰列表-详情结构、且需要处理分页和反爬的网站Scrapy框架的优势是压倒性的。它的异步处理能力能极大提升抓取效率内置的中间件Middleware和管道Pipeline机制让反爬逻辑和数据清洗逻辑能够模块化、可复用。项目的核心文件结构如下lagou_analysis/ ├── scrapy.cfg └── lagou/ ├── __init__.py ├── items.py # 定义数据结构 ├── middlewares.py # 反爬中间件重点 ├── pipelines.py # 数据清洗与存储管道第4关核心 ├── settings.py # 爬虫配置 └── spiders/ ├── __init__.py └── lagou_spider.py # 爬虫主逻辑在settings.py中有几个关键配置决定了爬虫的“礼貌”程度和健壮性# 遵守robots协议虽然有时需要灵活处理但建议开启 ROBOTSTXT_OBEY False # 拉勾网有robots限制根据实际情况调整 # 配置并发和延迟避免对服务器造成压力 CONCURRENT_REQUESTS 16 DOWNLOAD_DELAY 1.5 # 基础下载延迟配合自动限速中间件更佳 AUTOTHROTTLE_ENABLED True # 强烈建议开启自动限速 # 启用并配置我们自定义的中间件和管道 DOWNLOADER_MIDDLEWARES { lagou.middlewares.LagouDownloaderMiddleware: 543, } ITEM_PIPELINES { lagou.pipelines.SalaryFieldPipeline: 300, # 清洗薪资 lagou.pipelines.SkillExtractPipeline: 400, # 提取技能标签第5关核心 lagou.pipelines.JsonWriterPipeline: 800, # 写入JSON文件 } # 设置请求头模拟浏览器 DEFAULT_REQUEST_HEADERS { Accept: application/json, text/javascript, */*; q0.01, Accept-Language: zh-CN,zh;q0.9, Referer: https://www.lagou.com/, User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..., # 使用一个常见UA }2.3 反爬策略实战请求模拟与参数解析拉勾网的反爬机制经历了多次升级目前其数据接口主要通过带参数的POST请求返回JSON数据。直接请求网页HTML的方式已经很难奏效。我们的爬虫需要精确模拟这个请求过程。首先通过浏览器开发者工具F12的“网络Network”选项卡筛选XHR请求可以找到获取职位列表的真实API接口。通常形如https://www.lagou.com/jobs/positionAjax.json。观察其请求载荷Payload会发现关键的参数first: 是否第一页true/falsepn: 页码kd: 搜索关键词如“Python”在lagou_spider.py中我们需要在start_requests方法里构建这个初始请求import scrapy from urllib.parse import quote class LagouSpider(scrapy.Spider): name lagou allowed_domains [lagou.com] def start_requests(self): # 搜索关键词可以扩展多个 keyword Python base_url https://www.lagou.com/jobs/positionAjax.json?needAddtionalResultfalse # 构造表单数据 form_data { first: true, pn: 1, # 第一页 kd: keyword, } # 关键必须携带Referer和特定的Content-Type headers { Referer: fhttps://www.lagou.com/jobs/list_{quote(keyword)}?labelWordsfromSearchtruesuginput, Content-Type: application/x-www-form-urlencoded; charsetUTF-8, X-Requested-With: XMLHttpRequest } yield scrapy.FormRequest( urlbase_url, formdataform_data, headersheaders, callbackself.parse_job_list, meta{keyword: keyword, pn: 1} # 传递参数给回调函数 )实操心得这里的Referer必须正确构造模拟从搜索页跳转过来的行为否则接口很可能返回错误或空数据。X-Requested-With头也是识别Ajax请求的关键。此外拉勾网对频繁请求非常敏感除了设置DOWNLOAD_DELAY最好在中间件中加入IP代理池和用户代理User-Agent轮换的逻辑这在middlewares.py中实现。3. 核心细节解析列表抓取与详情页深入3.1 解析列表页与构造详情页请求在parse_job_list回调函数中我们处理API返回的JSON数据。拉勾网的返回结构通常为content - positionResult - result其中result是一个包含职位概要信息的列表。import json def parse_job_list(self, response): data json.loads(response.text) jobs data.get(content, {}).get(positionResult, {}).get(result, []) for job in jobs: item {} item[position_name] job.get(positionName) item[company_name] job.get(companyFullName) item[city] job.get(city) item[salary] job.get(salary) item[experience] job.get(workYear) item[education] job.get(education) item[publish_time] job.get(createTime) # 关键获取职位详情页的ID用于构造详情页请求 position_id job.get(positionId) if position_id: detail_url fhttps://www.lagou.com/jobs/{position_id}.html # 将初步抓取的item通过meta传递并请求详情页 yield scrapy.Request( urldetail_url, callbackself.parse_job_detail, meta{item: item}, headers{Referer: response.url} # 详情页请求也需要Referer ) # 分页逻辑判断是否有下一页并构造下一页请求 # 注意需要从当前响应或初始meta中获取keyword和pn keyword response.meta[keyword] current_page response.meta[pn] if jobs: # 如果当前页有数据则尝试抓取下一页 next_page current_page 1 # 防止无限爬取可以设置最大页数例如 max_page10 if next_page self.settings.getint(MAX_PAGE, 10): next_form_data { first: false, # 非第一页 pn: str(next_page), kd: keyword, } # 重新构造FormRequest注意更新pn和first参数 yield scrapy.FormRequest( urlresponse.url, # 使用相同的API URL formdatanext_form_data, callbackself.parse_job_list, meta{keyword: keyword, pn: next_page}, headers{Referer: response.headers.get(Referer)} )3.2 详情页数据提取与HTML解析详情页包含了我们最需要的job_detail职位描述和job_advantage。拉勾网的详情页是标准的HTML我们可以使用Scrapy内置的Selector基于XPath或CSS来提取信息。def parse_job_detail(self, response): # 获取从列表页传递过来的item item response.meta[item] # 使用XPath提取职位描述。拉勾网的描述通常在某个特定class的div下 # 注意网站结构可能变化需要定期检查并更新XPath job_detail response.xpath(//div[classjob-detail]//text()).getall() if job_detail: # 将提取的文本列表合并成一个字符串并清洗空白字符 item[job_detail] .join([text.strip() for text in job_detail if text.strip()]) else: # 备用选择器或者记录为抓取失败 item[job_detail] # 提取职位诱惑 job_advantage response.xpath(//dd[classjob-advantage]//text()).getall() item[job_advantage] .join([text.strip() for text in job_advantage if text.strip()]) # 至此一个完整的数据项收集完毕交给Item Pipeline处理 yield item注意事项详情页的HTML结构是爬虫不稳定的主要来源。拉勾网前端可能改版导致XPath失效。因此这里的XPath表达式需要具备一定的容错性。我通常会准备2-3套备用的XPath或者在管道Pipeline中增加校验逻辑如果job_detail字段为空或过短则记录日志并标记该条数据为可疑。此外详情页也可能触发反爬如验证码需要在下载中间件中做好重试和异常处理。4. 第四关数据清洗与结构化管道实现爬虫抓取到的原始数据是“脏”的无法直接用于分析。数据清洗是承上启下的关键一步主要在pipelines.py中实现。我们将创建多个Pipeline类Scrapy会按ITEM_PIPELINES中定义的优先级顺序依次执行它们。4.1 薪资字段的清洗与标准化薪资字段salary通常是“15k-30k”或“20k以上”这样的字符串。我们需要将其转换为数值型的下限和上限便于计算平均薪资、中位数等。import re class SalaryFieldPipeline: 清洗薪资字段拆分为最低和最高薪资单位千元/月 def process_item(self, item, spider): salary_str item.get(salary, ) if not salary_str: item[salary_low] None item[salary_high] None return item # 使用正则表达式匹配数字 # 匹配模式如8-12k, 8k-12k, 8k以上 0.8-1.2万 pattern r(\d(?:\.\d)?) numbers re.findall(pattern, salary_str) numbers [float(num) for num in numbers] # 判断薪资单位是k还是万并统一转换为‘千元/月’ if 万 in salary_str: # 如果是“万/月”则数字乘以10 numbers [num * 10 for num in numbers] # 默认单位是‘k’即千无需转换 # 根据匹配到的数字数量处理 if len(numbers) 2: item[salary_low], item[salary_high] numbers elif len(numbers) 1: # 如果是“xxk以上”则下限为xx上限设为None或一个较大值如下限的2倍 item[salary_low] numbers[0] item[salary_high] None # 或 numbers[0] * 2根据分析需求定 else: item[salary_low] item[salary_high] None # 可以删除原始的字符串薪资字段或保留以供核对 # del item[salary] return item实操心得薪资清洗的难点在于格式的多样性。除了正则匹配还需要处理“面议”、“薪资范围较大”等特殊情况。在实际项目中我会将无法解析的薪资单独记录下来人工抽样检查并不断完善正则表达式。此外将薪资统一为“千元/月”这个单位是后续所有统计分析的基础务必保证准确。4.2 经验与学历字段的规整experience和education字段也可能存在不统一的情况如“经验不限”、“应届毕业生”、“本科及以上”。我们可以建立一个映射字典将其归类到几个标准类别中。class StandardizeFieldPipeline: 标准化经验和学历字段 # 经验要求映射 exp_map { 不限: 经验不限, 应届毕业生: 应届生, 1年以下: 1年以内, 1-3年: 1-3年, 3-5年: 3-5年, 5-10年: 5-10年, 10年以上: 10年以上, } # 学历要求映射 edu_map { 大专: 大专, 本科: 本科, 硕士: 硕士, 博士: 博士, 不限: 学历不限, 大专及以上: 大专, 本科及以上: 本科, 硕士及以上: 硕士, } def process_item(self, item, spider): exp item.get(experience, ) edu item.get(education, ) # 标准化经验 for key, value in self.exp_map.items(): if key in exp: item[experience] value break else: # 如果没匹配到任何已知键可以统一为“其他”或保留原值 item[experience] 其他经验要求 # 标准化学历 for key, value in self.edu_map.items(): if key in edu: item[education] value break else: item[education] 其他学历要求 return item4.3 数据去重与存储清洗完成后数据需要持久化。我们可以选择存储到JSON文件便于后续Python直接读取分析或数据库如MongoDB、MySQL。import json import pymongo from itemadapter import ItemAdapter class JsonWriterPipeline: 将清洗后的数据写入JSON文件 def open_spider(self, spider): # 爬虫启动时打开文件 self.file open(lagou_jobs_cleaned.json, w, encodingutf-8) self.items [] def close_spider(self, spider): # 爬虫关闭时将列表写入文件 json.dump(self.items, self.file, ensure_asciiFalse, indent2) self.file.close() def process_item(self, item, spider): # 将Item对象转换为字典并加入列表 self.items.append(ItemAdapter(item).asdict()) return item class MongoPipeline: 将数据存储到MongoDB def __init__(self, mongo_uri, mongo_db): self.mongo_uri mongo_uri self.mongo_db mongo_db classmethod def from_crawler(cls, crawler): # 从settings.py读取配置 return cls( mongo_uricrawler.settings.get(MONGO_URI), mongo_dbcrawler.settings.get(MONGO_DATABASE, lagou) ) def open_spider(self, spider): self.client pymongo.MongoClient(self.mongo_uri) self.db self.client[self.mongo_db] def close_spider(self, spider): self.client.close() def process_item(self, item, spider): # 使用职位ID或公司职位名作为去重依据 collection_name spider.name _jobs # 构造一个唯一键例如公司名职位名城市 unique_key f{item[company_name]}_{item[position_name]}_{item[city]} # 使用update_one实现去重插入 self.db[collection_name].update_one( {_id: unique_key}, {$set: ItemAdapter(item).asdict()}, upsertTrue ) return item提示在settings.py中配置MONGO_URI如mongodb://localhost:27017即可启用MongoDB存储。数据库存储的优势是便于增量更新和复杂查询。JSON文件则更轻量适合数据量不大或快速分享的场景。清洗后的数据质量直接决定了第五关分析的准确性和深度因此这一关的每个管道都需要仔细测试。5. 第五关技能标签提取与多维数据分析这是整个项目的精华所在。我们将从清洗后的job_detail文本中提取出技术技能关键词并进行多维度交叉分析。5.1 构建技能词典与文本匹配首先我们需要定义一个目标技能词典。这个词典可以根据你的分析目标定制。例如针对Python数据分析方向# skill_keywords.py SKILL_DICT { 编程语言: [python, java, c, javascript, go, r, scala], 数据分析: [sql, hive, spark, hadoop, tableau, power bi, excel, pandas, numpy], 机器学习: [tensorflow, pytorch, sklearn, 机器学习, 深度学习, 自然语言处理, nlp, 计算机视觉, cv], web框架: [django, flask, fastapi, spring, vue, react], 数据库: [mysql, postgresql, mongodb, redis, oracle, elasticsearch], 云计算: [aws, azure, docker, kubernetes, linux], 软技能: [沟通, 团队合作, 解决问题, 学习能力] }然后在管道SkillExtractPipeline中我们遍历每个职位的描述统计技能关键词出现的频率。import jieba from collections import Counter class SkillExtractPipeline: 从职位描述中提取技能标签 def __init__(self): # 加载自定义技能词典确保分词准确 self.skill_keywords set() for category, keywords in SKILL_DICT.items(): self.skill_keywords.update(keywords) # 可以添加结巴分词的自定义词典提高分词准确性 for word in self.skill_keywords: jieba.add_word(word) def process_item(self, item, spider): job_detail item.get(job_detail, ).lower() # 转为小写方便匹配 if not job_detail: item[skill_tags] {} return item # 使用结巴分词进行分词 words jieba.lcut(job_detail) word_counter Counter(words) # 统计技能关键词出现次数 skill_counter {} for skill in self.skill_keywords: # 简单匹配技能词在分词结果中 # 更精确的做法可以处理中英文混合、同义词等 count word_counter.get(skill, 0) # 或者使用 skill in job_detail 进行子串匹配可能高估 if count 0: skill_counter[skill] count # 将技能统计结果存入item item[skill_tags] skill_counter # 也可以按类别统计 skill_by_category {} for category, keywords in SKILL_DICT.items(): total sum(skill_counter.get(k, 0) for k in keywords) if total 0: skill_by_category[category] total item[skill_categories] skill_by_category return item5.2 数据分析与可视化数据清洗和技能提取完成后我们得到一个包含skill_tags和skill_categories的JSON数据集。接下来使用pandas,matplotlib,seaborn进行数据分析。第一步数据加载与概览import pandas as pd import matplotlib.pyplot as plt import seaborn as sns plt.style.use(seaborn-v0_8-darkgrid) # 设置绘图风格 sns.set(fontSimHei) # 解决中文显示问题 # 加载清洗后的数据 df pd.read_json(lagou_jobs_cleaned.json) print(f共爬取职位数: {len(df)}) print(df[[position_name, city, salary_low, salary_high]].head()) # 薪资分布分析 df[salary_avg] (df[salary_low] df[salary_high]) / 2 print(f平均薪资千元/月: {df[salary_avg].mean():.1f}) print(f薪资中位数: {df[salary_avg].median():.1f})第二步技能热度全局分析我们需要将每个职位的skill_tags字典展开、合并进行全局统计。from collections import Counter import ast # 如果skill_tags存储为字符串需要用ast.literal_eval转换 # 假设skill_tags在JSON中是字典字符串需要转换 # df[skill_tags] df[skill_tags].apply(ast.literal_eval) all_skills Counter() for tags in df[skill_tags]: if isinstance(tags, dict): all_skills.update(tags) # 获取出现频率最高的20个技能 top_skills pd.DataFrame(all_skills.most_common(20), columns[技能, 出现频次]) print(top_skills) # 绘制技能词云或条形图 plt.figure(figsize(12, 8)) sns.barplot(datatop_skills, y技能, x出现频次, paletteviridis) plt.title(Python相关职位Top 20技能需求) plt.tight_layout() plt.savefig(top_20_skills.png, dpi300) plt.show()第三步技能与薪资的关联分析这是一个非常有意思的分析点哪些技能更“值钱”# 创建一个新的DataFrame每一行是一个职位技能对并带有该职位的薪资 skill_salary_data [] for idx, row in df.iterrows(): avg_salary row[salary_avg] if pd.isna(avg_salary): continue tags row[skill_tags] if isinstance(tags, dict): for skill, count in tags.items(): # 如果技能出现多次可以按次数加权这里简单处理为出现一次就记录一次 for _ in range(min(count, 3)): # 避免单个职位对某个技能影响过大可以设上限 skill_salary_data.append({skill: skill, salary: avg_salary}) skill_salary_df pd.DataFrame(skill_salary_data) # 计算每个技能的平均薪资 skill_avg_salary skill_salary_df.groupby(skill)[salary].agg([mean, count]).round(1) skill_avg_salary skill_avg_salary[skill_avg_salary[count] 5] # 只考虑出现一定次数的技能 skill_avg_salary skill_avg_salary.sort_values(mean, ascendingFalse).head(15) plt.figure(figsize(12, 8)) sns.barplot(dataskill_avg_salary.reset_index(), yskill, xmean, paletterocket) plt.xlabel(平均薪资千元/月) plt.title(高薪关联技能Top 15出现频次5) plt.tight_layout() plt.savefig(high_salary_skills.png, dpi300) plt.show()第四步城市维度的技能需求差异# 选取几个主要城市进行分析 major_cities [北京, 上海, 深圳, 广州, 杭州] city_skill_demand {} for city in major_cities: city_df df[df[city] city] city_skills Counter() for tags in city_df[skill_tags]: if isinstance(tags, dict): city_skills.update(tags) # 取该城市前10的技能 city_skill_demand[city] dict(city_skills.most_common(10)) # 将数据转换为便于绘图的形式 plot_data [] for city, skills in city_skill_demand.items(): for skill, count in skills.items(): plot_data.append({city: city, skill: skill, count: count}) plot_df pd.DataFrame(plot_data) # 使用分面网格FacetGrid绘制每个城市的技能条形图 g sns.FacetGrid(plot_df, colcity, col_wrap3, height4, shareyFalse) g.map(sns.barplot, count, skill, orderNone, paletteBlues_d) g.set_titles({col_name}) g.set_axis_labels(出现频次, 技能) plt.tight_layout() plt.savefig(skills_by_city.png, dpi300) plt.show()5.3 生成分析报告与结论分析完成后可以将关键图表和结论整合成一份简单的报告。例如核心发现在Python相关职位中出现频率最高的技能是Python、SQL、Linux、MySQL和Redis。这表明后端开发和数据处理的技能组合是市场主流需求。高薪技能与Spark、Hadoop、Docker、KubernetesK8s和深度学习相关的技能其平均薪资显著高于其他技能。这凸显了大数据和云原生、AI方向的技术溢价。地域差异北京和上海对机器学习、深度学习等AI技能的需求明显高于其他城市。深圳的Java、Spring需求相对突出可能与互联网和硬件结合的产业生态有关。杭州则显示出对电商相关技能可在描述中进一步挖掘的侧重。经验要求通过交叉分析experience和skill_tags可以发现要求“3-5年”经验的职位对Docker/K8s、系统设计等技能的提及率远高于“1-3年”的职位这为求职者的技能进阶路径提供了参考。个人经验与避坑指南技能词典需要迭代初始的技能词典一定是不完备的。跑完第一轮分析后一定要去查看那些高频但未被收录的词比如“Kafka”、“Flink”、“ClickHouse”等将它们补充进词典再重新运行提取和分析流程。文本匹配的精度简单的关键词匹配会有噪声。比如“熟悉Linux”和“有Linux服务器运维经验”都能匹配到“Linux”但后者显然技能要求更深。更高级的做法是使用TF-IDF或简单的文本相似度计算或者引入NLP模型进行命名实体识别NER来提取技术栈。数据量是关键爬取的数据量越大例如上千条分析结果越有统计意义。如果只爬了几十页结论可能受个别公司招聘需求的影响而产生偏差。动态反爬拉勾网的反爬策略会变。如果某天发现爬不到数据了不要慌。首先检查API接口地址和参数是否变化其次检查请求头特别是Cookie和User-Agent是否被识别。维护一个有效的用户代理池和会话管理机制是长期运行爬虫的必备条件。这个从爬虫构建、反爬对抗、数据清洗到技能分析的完整链路不仅适用于拉勾网其方法论可以迁移到任何招聘网站或垂直信息平台的数据抓取与分析上。掌握了这套流程你就拥有了从互联网上获取并提炼结构化洞察的能力这才是数据工作中最具价值的部分。