ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

招聘数据爬虫与可视化:Scrapy+Redis+Dash端到端工程实践

招聘数据爬虫与可视化:Scrapy+Redis+Dash端到端工程实践 简介这是一套基于Python技术栈开发的招聘网站数据采集与可视化分析系统面向爬虫初学者、Web开发学习者及数据分析爱好者解决招聘信息获取、结构化存储与多维展示的实际问题。资源包共520个文件含284个核心Python源码Scrapy爬虫、Django后端、数据处理逻辑、18个HTML模板与17个CSS/SCSS样式文件构成完整前端界面另有SQLite3数据库、JS交互脚本及可执行exe工具整体压缩包17.55MB结构清晰便于模块化学习与二次开发。已有13541人学习下载体现了较强的实践参考价值。读者可直接运行获得真实招聘数据的分页展示、岗位关键词筛选拓展功能复现词云可视化分析流程并掌握账户管理、用户收藏、信息发布等典型Web应用模块的完整实现方案。1. 用 Python 爬虫抓取招聘网站数据并做可视化不是写个 requests 就完事——它是一套闭环工程你可能已经用requests BeautifulSoup成功爬下一页「Java 开发工程师」的职位列表但当你要对比北上广深杭五城的 Python 岗位薪资中位数、分析学历要求与经验门槛的分布密度、追踪某类技术关键词如「LangChain」「RAG」「Docker」在岗位描述中的出现趋势时单页静态解析立刻失效页面反爬升级、AJAX 异步加载、登录态校验、动态 iframe 嵌套、IP 请求频控……这些不是“加个 headers 就能过”的小问题而是系统性瓶颈。本方案不讲“如何入门爬虫”而是聚焦真实招聘数据场景下的可维护、可扩展、可验证的端到端链路从 Scrapy 框架级结构化采集到 Redis 中间件支撑去重与任务调度再到 Pandas 清洗后的多维聚合最终用 PlotlyDash 构建响应式交互看板——所有环节均基于生产环境常见约束设计如无 headless 浏览器依赖、规避 Selenium 运维开销、适配主流招聘站 DOM 结构稳定性。适合已有 Python 基础、正面临实际招聘数据分析需求的工程师或 HR 数据支持岗。2. 用 Scrapy 搭建高鲁棒性招聘数据采集管道绕过动态渲染、应对字段漂移、内置反爬韧性招聘网站是典型的“半动态”目标首页和列表页多为服务端渲染SSR但详情页常嵌套 iframe 或通过 JS 拼接 JSON 数据同时岗位标题、公司名、薪资范围、工作地点等字段在 HTML 中位置不固定甚至存在「面议」「15K-25K·14薪」等非标格式。直接用Playwright或Selenium全量渲染会显著拖慢吞吐且难以部署到无图形界面的 Linux 服务器。Scrapy 的优势在于其事件驱动架构与中间件生态配合scrapy-splash轻量级 JS 渲染或scrapy-playwright现代替代可精准控制渲染粒度——仅对含 iframe 的详情页启用 JS 执行其余页面走纯 HTTP 解析兼顾速度与兼容性。2.1 初始化 Scrapy 项目并配置核心中间件scrapy startproject job_spider cd job_spider pip install scrapy-playwright beautifulsoup4 pandas plotly dash redis在settings.py中启用 Playwright 并设置基础反爬策略# settings.py BOT_NAME job_spider SPIDER_MODULES [job_spider.spiders] NEWSPIDER_MODULE job_spider.spiders # 启用 Playwright仅对需 JS 渲染的请求生效 DOWNLOAD_HANDLERS { http: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, https: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, } TWISTED_REACTOR twisted.internet.asyncioreactor.AsyncioSelectorReactor PLAYWRIGHT_BROWSER_TYPE chromium PLAYWRIGHT_LAUNCH_OPTIONS { headless: True, timeout: 20 * 1000, } # 基础反爬随机 User-Agent 请求间隔 DOWNLOADER_MIDDLEWARES { scrapy.downloadermiddlewares.useragent.UserAgentMiddleware: None, scrapy_useragents.downloadermiddlewares.useragents.UserAgentsMiddleware: 500, job_spider.middlewares.JobSpiderDownloaderMiddleware: 543, } RANDOMIZE_DOWNLOAD_DELAY True DOWNLOAD_DELAY 1.5 # 随机延迟 0.75~2.25 秒注意scrapy-useragents提供 1000 真实 UA 池比硬编码headers更可持续DOWNLOAD_DELAY设为 1.5 秒是多数招聘站前程无忧、BOSS 直聘、猎聘允许的保守阈值避免触发 IP 封禁。2.2 定义结构化 Item 与自适应解析逻辑招聘数据字段易变硬编码response.css(div.job-title::text).get()在页面改版后立即失效。应采用“字段定位规则回退”双策略优先用 CSS 选择器定位失败则用 XPath 模糊匹配再失败则用正则从文本块中提取。定义JobItem如下# items.py import scrapy class JobItem(scrapy.Item): url scrapy.Field() # 原始详情页 URL用于去重 title scrapy.Field() # 职位名称如高级Python开发工程师 company scrapy.Field() # 公司名称 salary_min scrapy.Field(serializerint) # 薪资下限单位元/月 salary_max scrapy.Field(serializerint) # 薪资上限单位元/月 city scrapy.Field() # 工作城市如北京 experience scrapy.Field() # 经验要求如3-5年 education scrapy.Field() # 学历要求如本科 tags scrapy.Field() # 技术标签列表如[Django, Redis, 微服务] publish_time scrapy.Field() # 发布时间ISO 格式字符串 raw_html scrapy.Field() # 原始 HTML 片段用于后续 NLP 分析在 Spider 中实现弹性解析以 BOSS 直聘为例# spiders/boss_spider.py import re import json from scrapy import Spider, Request from scrapy_playwright.page import PageMethod from ..items import JobItem class BossSpider(Spider): name boss allowed_domains [zhipin.com] start_urls [https://www.zhipin.com/web/geek/job?queryPythoncity101010100] # 北京 Python 岗 def start_requests(self): for url in self.start_urls: yield Request( urlurl, meta{ playwright: True, playwright_page_methods: [ PageMethod(wait_for_selector, ul.job-list-box li), ], }, callbackself.parse_list, ) def parse_list(self, response): # 解析列表页提取详情页 URL for job_card in response.css(ul.job-list-box li): detail_url response.urljoin(job_card.css(a::attr(href)).get()) if detail_url and job_detail in detail_url: yield Request( urldetail_url, meta{playwright: True}, # 详情页需 JS 渲染 callbackself.parse_detail, ) def parse_detail(self, response): item JobItem() item[url] response.url # 标题优先 CSS失败则 XPath 模糊匹配 title_sel response.css(div.job-name h1::text).get() if not title_sel: title_sel response.xpath(//h1[contains(text(), 职位) or contains(text(), 岗位)]/text()).get() item[title] title_sel.strip() if title_sel else # 薪资正则提取数字区间兼容15k-25k、面议、20K·14薪 salary_text response.css(span.salary::text).get() or salary_match re.search(r(\d)[kK]?\s*[-—–]\s*(\d)[kK]?, salary_text) if salary_match: item[salary_min] int(salary_match.group(1)) * 1000 item[salary_max] int(salary_match.group(2)) * 1000 elif 面议 in salary_text: item[salary_min] item[salary_max] 0 else: # 尝试提取单值如20K·14薪 → 取20K single_match re.search(r(\d)[kK], salary_text) if single_match: val int(single_match.group(1)) * 1000 item[salary_min] item[salary_max] val # 公司名多 selector 回退 company_sel ( response.css(div.job-detail-header .company-name::text).get() or response.css(div.job-company h3::text).get() or response.xpath(//div[contains(class, company)]//text()).re_first(r[\u4e00-\u9fa5a-zA-Z0-9]) ) item[company] company_sel.strip() if company_sel else # 技术标签从岗位描述中提取关键词简化版 desc_text response.css(div.job-detail-main div.text::text).getall() full_desc .join(desc_text) tech_keywords [Python, Django, Flask, Redis, MySQL, Docker, K8s, LangChain] item[tags] [kw for kw in tech_keywords if kw.lower() in full_desc.lower()] yield item提示parse_detail中的salary解析逻辑覆盖了招聘站最常见的 3 类格式避免因字段缺失导致 pipeline 中断tags提取未用 NLP 模型而用关键词匹配是权衡准确率与部署复杂度的结果——实际项目中可替换为jieba分词 TF-IDF 加权。3. 用 Redis 实现去重、任务队列与状态监控让爬虫真正“跑得稳、停得住、查得清”Scrapy 默认使用内存去重RFPDupeFilter但分布式部署或意外中断后重启内存状态丢失会导致重复抓取。将去重集合、待抓取队列、已抓取 URL 集合全部迁移到 Redis是招聘爬虫工程化的关键一步。Redis 不仅提供原子操作保障一致性其KEYS命令还能实时查看各阶段数据量便于运维排查。3.1 配置 Scrapy-Redis 中间件与 Pipeline安装并配置scrapy-redispip install scrapy-redis修改settings.py# settings.py # 启用 Scrapy-Redis SCHEDULER scrapy_redis.scheduler.Scheduler DUPEFILTER_CLASS scrapy_redis.dupefilter.RFPDupeFilter SCHEDULER_PERSIST True # 中断后保留队列 REDIS_URL redis://localhost:6379/0 # 本地 Redis生产环境建议密码连接池 # 自定义 Pipeline 写入 Redis替代默认文件输出 ITEM_PIPELINES { job_spider.pipelines.RedisPipeline: 300, }编写RedisPipeline将清洗后的数据存入 Redis Hash 结构按日期分区# pipelines.py import json import redis from datetime import datetime from scrapy.exceptions import DropItem class RedisPipeline: def __init__(self, redis_url): self.redis_client redis.from_url(redis_url) classmethod def from_crawler(cls, crawler): return cls( redis_urlcrawler.settings.get(REDIS_URL, redis://localhost:6379/0) ) def process_item(self, item, spider): # 生成唯一 keyjob:日期:hash(url) date_str datetime.now().strftime(%Y%m%d) key fjob:{date_str}:{hash(item[url])} # 序列化 item 为 JSON 字符串 item_dict {k: v for k, v in item.items() if v is not None} item_dict[crawl_time] datetime.now().isoformat() try: self.redis_client.hset(key, mappingitem_dict) self.redis_client.expire(key, 3600 * 24 * 7) # 7 天过期 except Exception as e: raise DropItem(fRedis write failed for {key}: {e}) return item3.2 用 Redis CLI 实时监控爬虫健康状态启动爬虫后通过 Redis 命令行快速验证各组件是否就绪# 连接 Redis redis-cli -h localhost -p 6379 # 查看当前待抓取 URL 数量即队列长度 llen job_spider:requests # 查看已去重的 URL 数量去重集合大小 scard job_spider:dupefilter # 查看今日已存入的岗位数量Hash 键数量 keys job:20240520:* | wc -l # 查看某条记录内容调试用 hgetall job:20240520:-123456789Redis Key 类型用途典型命令生产建议List (job_spider:requests)待抓取 URL 队列llen,lrange设置LIST_MAX_LENGTH10000防爆内存Set (job_spider:dupefilter)已访问 URL 去重集合scard,smembers使用SPOP替代SREM减少锁竞争Hash (job:20240520:*)岗位数据存储hgetall,hkeys按日期分库db 1存历史db 0存当日注意SCHEDULER_PERSISTTrue是关键开关——它让 Scrapy 在 CtrlC 中断后requests队列和dupefilter集合仍保留在 Redis 中下次scrapy crawl boss会自动续跑无需手动恢复。4. 用 Pandas 清洗与聚合招聘数据处理非标薪资、归一化城市、提取技术栈热度从 Redis 读出的原始数据充满噪声薪资字段有「面议」「15K-25K·14薪」「200-300元/天」城市字段有「北京」「北京市」「北京-朝阳区」「Beijing」技术标签是列表但含大小写混杂与缩写如「k8s」「Kubernetes」。Pandas 是最高效的清洗工具其向量化操作比循环快 10 倍以上且语法直观。4.1 从 Redis 批量读取并构建 DataFrame# data_loader.py import redis import pandas as pd import json from datetime import datetime def load_jobs_from_redis(date_str20240520): r redis.Redis(hostlocalhost, port6379, db0) keys r.keys(fjob:{date_str}:*) jobs [] for key in keys: try: data r.hgetall(key) # Redis 返回 bytes需 decode item {k.decode(): v.decode() for k, v in data.items()} # 转换数值字段 if salary_min in item: item[salary_min] int(item[salary_min]) if item[salary_min].isdigit() else 0 if salary_max in item: item[salary_max] int(item[salary_max]) if item[salary_max].isdigit() else 0 jobs.append(item) except Exception as e: print(fSkip key {key}: {e}) return pd.DataFrame(jobs) # 加载数据 df load_jobs_from_redis(20240520) print(fLoaded {len(df)} jobs) print(df[[title, company, salary_min, salary_max, city]].head())4.2 标准化关键字段三步清洗法# clean_data.py import re import pandas as pd def clean_city(city): 归一化城市名提取中文主城名 if not isinstance(city, str): return 未知 # 移除区/县/市后缀及英文 city re.sub(r[市区县]|[\u0020-\u007E], , city) # 映射简称如沪→上海 city_map {京: 北京, 沪: 上海, 粤: 广东, 深: 深圳} return city_map.get(city, city) or 未知 def clean_salary(row): 统一薪资为月薪元处理面议/日薪/年薪 min_val, max_val row[salary_min], row[salary_max] if min_val 0 and max_val 0: # 面议 return (8000, 25000) # 设默认区间根据行业基准 if 天 in str(row.get(raw_html, )): # 日薪 → 月薪按22天 daily (min_val max_val) // 2 return (daily * 22, daily * 22) if 年 in str(row.get(raw_html, )): # 年薪 → 月薪除以12 return (min_val // 12, max_val // 12) return (min_val, max_val) # 应用清洗函数 df[city_clean] df[city].apply(clean_city) df[salary_min_clean], df[salary_max_clean] zip(*df.apply(clean_salary, axis1)) df[salary_avg] (df[salary_min_clean] df[salary_max_clean]) / 2 # 展开技术标签列表为多行便于统计 df_exploded df.explode(tags).dropna(subset[tags]) df_exploded[tags] df_exploded[tags].str.strip().str.upper()4.3 生成多维分析报表城市薪资对比、技术栈热度、经验要求分布# analysis.py import plotly.express as px import plotly.graph_objects as go # 1. 各城市平均薪资箱线图剔除异常值 fig1 px.box( df, xcity_clean, ysalary_avg, title各城市 Python 岗位月薪中位数对比20240520, labels{city_clean: 城市, salary_avg: 月薪元} ) fig1.update_xaxes(categoryordertotal descending) # 2. 技术栈热度 Top 10基于标签出现频次 tech_freq df_exploded[tags].value_counts().head(10) fig2 px.bar( xtech_freq.index, ytech_freq.values, titlePython 岗位高频技术标签Top 10, labels{x: 技术标签, y: 出现次数} ) # 3. 经验要求分布正则提取数字 df[exp_years] df[experience].str.extract(r(\d)-(\d)).apply( lambda x: int(x[0]) if pd.notna(x[0]) else 0, axis1 ) fig3 px.histogram( df, xexp_years, nbins8, title岗位要求工作经验年限分布, labels{exp_years: 要求年限年, count: 岗位数} ) # 保存为 HTML可直接打开 fig1.write_html(reports/city_salary_box.html) fig2.write_html(reports/tech_hotmap.html) fig3.write_html(reports/exp_distribution.html)提示explode(tags)将每个岗位的标签列表展开为独立行使value_counts()能准确统计「Redis」出现 127 次、「Docker」出现 98 次——这是计算技术栈热度的基石操作不可省略。5. 用 Dash 构建可交互招聘数据看板支持城市筛选、技术词云、薪资热力图静态 HTML 报表无法满足业务方“想看杭州 Java 岗和 Python 岗薪资对比”“查最近一周 Spark 关键词增长趋势”的即时需求。Dash 是 Python 生态最成熟的 Web 可视化框架其声明式语法与回调机制让前端交互逻辑完全用 Python 编写无需 JS。5.1 构建基础 Dash App 并集成 Plotly 图表# dashboard.py import dash from dash import dcc, html, Input, Output, State, callback import plotly.express as px import pandas as pd from data_loader import load_jobs_from_redis # 初始化 Dash App app dash.Dash(__name__, suppress_callback_exceptionsTrue) # 布局顶部筛选栏 图表区域 app.layout html.Div([ html.H1(招聘数据实时看板, style{textAlign: center}), # 筛选控件 html.Div([ html.Label(选择日期), dcc.DatePickerSingle( iddate-picker, datepd.to_datetime(2024-05-20), display_formatYYYY-MM-DD ), html.Label(选择城市, style{marginLeft: 20px}), dcc.Dropdown( idcity-dropdown, options[{label: c, value: c} for c in [北京, 上海, 深圳, 杭州, 广州]], value[北京, 上海], multiTrue ), ], style{display: flex, alignItems: center, margin: 20px}), # 图表容器 html.Div([ dcc.Graph(idsalary-box-plot), dcc.Graph(idtech-wordcloud), # 此处用 Plotly 模拟词云实际可用 wordcloud 库 ]) ]) # 回调根据筛选条件更新图表 callback( [Output(salary-box-plot, figure), Output(tech-wordcloud, figure)], [Input(date-picker, date), Input(city-dropdown, value)] ) def update_charts(selected_date, selected_cities): # 加载数据 date_str selected_date.replace(-, ) df load_jobs_from_redis(date_str) # 过滤城市 if selected_cities: df df[df[city_clean].isin(selected_cities)] # 图表1薪资箱线图 fig1 px.box(df, xcity_clean, ysalary_avg, titlef{selected_date} 各城市 Python 岗位薪资分布) # 图表2技术词云用条形图模拟 if not df.empty: tech_freq df.explode(tags)[tags].value_counts().head(10) fig2 px.bar(xtech_freq.index, ytech_freq.values, title高频技术标签Top 10, labels{x: 技术, y: 出现频次}) else: fig2 px.bar(x[], y[], title暂无数据) return fig1, fig2 if __name__ __main__: app.run_server(debugTrue, host0.0.0.0, port8050)5.2 启动看板并验证交互逻辑运行命令python dashboard.py访问http://localhost:8050即可看到顶部日期选择器默认设为2024-05-20城市多选框预置「北京」「上海」下方两个图表实时响应筛选选中「深圳」「杭州」后箱线图只显示这两城词云数据也仅来自这两城岗位进阶技巧若需真实词云效果可添加wordcloud库并修改回调from wordcloud import WordCloud import base64 from io import BytesIO # 在回调中生成词云图 text .join(df.explode(tags)[tags].dropna()) wc WordCloud(width800, height400, background_colorwhite).generate(text) img_buffer BytesIO() wc.to_image().save(img_buffer, formatPNG) img_base64 base64.b64encode(img_buffer.getvalue()).decode() return fig1, html.Img(srcfdata:image/png;base64,{img_base64})此方式将词云转为 Base64 内嵌图片避免额外静态资源路径管理。至此从 Scrapy 采集、Redis 状态管理、Pandas 清洗到 Dash 可视化整条链路已打通。你不再需要手动导出 CSV 再用 Excel 画图——每次scrapy crawl boss新增数据看板自动刷新。下一步可接入定时任务cron每 6 小时爬一次、增加预警如某城市薪资环比下降超 15% 发邮件、或对接 BI 工具将 Redis 数据同步至 MySQL 供 Tableau 查询。本文还有配套的精品资源点击获取
返回列表