项目开工前必读:为什么这个招聘数据采集方案值得你花时间看完
做招聘数据采集这个想法,大概是每个数据从业者、爬虫学习者和产品运营都动过的念头。职位信息、薪资区间、技能要求、公司团队规模,这些字段散落在不同平台的职位卡片里,单独看不觉得珍贵,一旦聚合沉淀成自己的结构化数据集,就能用来做行业薪资分析、技能趋势统计,甚至帮团队搭建一套内部的人才情报看板。
我这次的项目,目标是从某主流招聘平台的职位搜索页里,自动抓取职位列表信息,提取职位名、薪资范围、公司规模、技能标签等核心字段,完成数据清洗后统一落地到CSV文件和SQLite数据库两份持久化存储里。整套方案基于常见的动态页面渲染场景做了封装,核心用到的是Playwright自动化浏览器技术,配合结构化解析策略,解决传统requests方案在这类页面上“拿到源码却拿不到数据”的痛点。
这个方案适合谁?一类是有Python基础但对反爬和动态加载头疼的爬虫学习者,另一类是HR行业或咨询行业里需要持续积累职位样本做分析的朋友,再就是准备做数据服务产品、需要先跑通数据采集链路的初创团队。无论哪类读者,这套方案把“采集—解析—清洗—存储”整条链路一次打通,你跟着做完,得到的不仅是一堆表格文件,更是一套可以复用到其他信息采集项目的通用方法论。
后面所有内容我都按自己实际跑项目的过程来写,包括踩过的坑、改过的参数、为什么某一步非要那样处理。你完全可以拿这份文档当一份操作手册,边读边动手。
1. 项目整体设计与思路拆解
1.1 先说清楚:这个项目解决的是什么问题
很多招聘平台的职位列表,并不是服务端直出的完整HTML。打开页面源码,你会发现职位卡片区域空空如也,真实数据是页面加载完成后,前端再通过异步请求拉取、渲染出来的。这类页面结构在业内有个说法叫“动态渲染页面”,单个页面的数据内容取决于浏览器执行JavaScript之后的结果。
传统的爬虫思路是用requests库直接请求网址,然后拿正则或XPath去匹配HTML。这套思路处理纯静态页面非常顺手,但遇到动态渲染页面就会失灵——requests拿到的是没经过JS执行的原始HTML骨架,里面根本没有职位卡片的内容。也有人说那我直接分析页面的异步接口,绕过页面直接请求接口拿JSON不就行了?理论可行,实践坑很多:接口需要携带加密参数、签名随token动态变化、字段做了一堆混淆,逆向成本并不低,而且接口一旦调整参数规则,你的代码就整个失效。
Playwright的方案绕开了这个困局。它本质是一个浏览器自动化工具,会驱动一个真实浏览器内核去访问页面、执行JavaScript、等待异步渲染完成。页面渲染完是什么样,它拿到的就是什么样,和真人用浏览器看到的内容完全一致。对数据采集来说,这意味着“只要人眼能在页面上看到的数据,代码就能稳定拿到”,思路一下子从“逆向接口猜参数”变成了“操作浏览器读结果”,好理解、好维护、也好调试。
1.2 为什么选Playwright而不是Selenium
聊到浏览器自动化,很多人第一个想到的还是Selenium。在Playwright成熟之前,我也用Selenium写过不少采集脚本,它确实能干活,但痛点也很明显:需要额外管理浏览器驱动,驱动版本和浏览器版本对不上就直接报错;对等待条件的封装比较原始,经常要靠time.sleep硬等,页面稍慢就超时,页面快了又白白浪费时间;上手多窗口、多标签的切换也比较繁琐。
Playwright在这几个维度上的体验是碾压级的。它把浏览器驱动内置到安装包里,装库的时候驱动一并装好,不用再手动去下载和浏览器版本精确匹配的driver。等待机制上,它会自动感知页面状态,内置了智能等待逻辑,元素出现就继续,未出现就继续等,不需要人为预估时间。API设计上也更接近上层的业务语义,wait_for_selector就是等一个元素出现,click就是点击,清晰直接,脚本写出来基本是“所见即所得”。
还有一个细节是自动等待机制对目标页面更友好。频繁用固定time.sleep去等页面加载,一旦网络波动就把等待时间拉长,整体效率变低;时间设短了又容易在元素未出现时强行操作导致报错。Playwright的智能等待是把判断逻辑和动作绑定在一起,只有元素满足条件才继续往下走,既不浪费也不焦虑。
1.3 结构化解析:正则虽好,但这里不适合
拿到页面内容之后,下一步是解析。很多人条件反射想到正则表达式,我也用过,在一些简单页面里确实快,但招聘平台的职位卡片信息量不小——职位名、薪资、经验要求、公司名、公司规模、融资情况、技能标签,一个卡片塞了七八个字段。用正则去逐条匹配这些字段,正则表达式会写得非常长且脆弱,任何细微的HTML结构调整都可能让整个模式断掉,调试速度远赶不上页面更新的速度。
更好的选择是走结构化解析路线。用CSS选择器或XPath把页面中按规则、按层级组织的元素准确定位出来。职位列表的结构通常是一组重复的卡片,每张卡片里各字段的class命名是有规律的,定位好父容器之后,逐项提取子元素就好了。这种解析方式是“面向结构”,而不是“面向文本”,页面小改动时,只要class名没变,代码几乎不用改。
还要重视数据清洗这一层。页面上抓下来的薪资是一个字符串,比如“15-30K·14薪”,直接入库没法做数值比较。要做分析,就得把它拆成下限薪资、上限薪资、年薪每月数这几个独立字段。这种清洗逻辑看起来不起眼,但直接影响数据能不能用,我见过不少项目采集完数据却因为清洗没做好,后续做分析时要花两倍时间在Excel里手工处理,整体效率非常糟糕。
1.4 双存储设计:CSV和SQLite各自的角色
最后就是存储环节,这个项目里我选择了CSV和SQLite两份存储并存。
CSV的价值在于通用和轻量。采集下来的数据可以直接拖进Excel、打开即看,也可以用类似pandas的库做快速探索式分析,还可以直接作为可视化报表的数据源。对于小批量数据的日常查看来说,CSV是最省事的格式,不需要任何额外环境就能打开。
SQLite的价值在于结构化查询和累积。如果每周都跑一次采集,数据会越来越多,几千条、上万条之后,用CSV管理明显吃力,按日期筛数据、去重、增量更新都要写一堆繁琐逻辑。SQLite是单文件数据库,不需要安装独立服务,一个文件就能承载几十万条数据的查询,SQL标准语法大多支持,用一条select语句就能完成各种筛选聚合。而且作为持久化存储,它的容错性比CSV强得多,数据写到一半进程崩溃,SQLite有事务机制兜底,CSV则可能出现半截记录。
我自己的习惯是:CSV服务“快速查看”,SQLite服务“长期积累”。采集程序每次运行,两份一起写,这样短期分析有现成文件,长期项目有数据库支撑,两头都不耽误。
2. 环境准备与基础配置
2.1 安装步骤和版本说明
这个项目用到的核心依赖其实只有两个大块:Python环境和Playwright库。我的本地环境是Python 3.10,理论上3.8以上都能跑,不过建议用3.9以上的版本,类型注解和语法支持更舒服。
安装过程非常简单。第一步是安装Playwright库,直接在终端执行:
pip install playwright第二步很关键——安装浏览器内核。Playwright虽然会下载驱动,但浏览器本身还是要装的。执行下面的命令即可:
playwright install chromium这里我推荐只装chromium就够了。Playwright支持chromium、firefox和webkit三种内核,但对采集任务来说,chromium在兼容性和执行速度之间最平衡,没必要三个都装,白白占磁盘空间。装的过程中如果速度慢,大概率是网络问题,可以换国内镜像源再做安装。
项目里还涉及SQLite的存储,这一点完全不用额外安装,Python标准库自带sqlite3模块,引入即可使用,这也是我选SQLite而不是MySQL的一个现实原因——零部署成本,开箱即用。
CSV导出也不用装额外库,Python内置了csv模块,写起来非常简洁。也就是说,整个项目的第三方依赖只有playwright一个库,这在工程上是很大的优势,部署到新机器时几乎不用为依赖环境头疼。
2.2 项目目录结构设计
动手写代码之前,建议先把目录规划好。这个项目的目录设计看起来琐碎,但清爽的工程结构能够让你两个月后再来看代码时,依旧能一眼找到对应模块文件。
recruitment_crawler/ ├── main.py # 主程序入口,编排调度 ├── config.py # 配置信息:目标URL、选择器、字段名 ├── collector.py # Playwright抓取与页面交互 ├── parser.py # 结构化解析与字段清洗 ├── storage.py # 双存储:CSV + SQLite └── requirements.txt # 依赖清单每个模块各司其职:config放配置和选择器,collector只负责“拿页面”,parser只负责“提数据”,storage只负责“存数据”。这个分层的好处是修改任何一层都不会影响另外两层。比如平台改版后卡片结构变了,只需要去config和parser里调整选择器,抓取逻辑和存储逻辑完全不用动。模块化思维对一个有可能长期维护的采集项目来说,不是锦上添花,而是基本配置。
2.3 反爬合规与技术边界
这里我必须专门提一句合规和边界的问题。任何网站的数据采集都要尊重目标网站的服务条款,不可用于商业窃取、恶意竞争或任何违法违规用途。下面的方案,我建议仅用于技术学习、个人研究和公开数据的基础分析,同时对目标平台保持克制,降低请求频率,避免造成访问压力。
技术实现上和很多人的直觉不同,做得越“像真人”反而越容易触发风控。我在项目里刻意避开了极端并发和激进的自动操作,把每次访问的节奏控制在合理范围内。采集目的的正当性、对平台资源的敬畏,才是这个方案能长期稳定运行的前提。
另外要说明,本文中不涉及任何针对具体网站反爬机制的绕过或破解手段,整个思路建立在页面公开可见内容的正常获取基础上。跑项目前,也建议你先仔细阅读目标平台的相关条款,确认自己的使用场景没有越界。
3. 核心代码实现与关键环节解析
3.1 先搭一个可复用的Playwright抓取基底
抓取模块是整个项目的地基。这里我多次强调“基底”两个字,是因为后续所有解析、存储操作都建立在稳定拿到页面内容的前提上。如果抓取层不稳定,后面的工作全是空中楼阁。
采集器模块的代码核心是进入列表页、等待职位卡片渲染、滚动页面触发懒加载,最后把整个页面内容返回给解析层。动态渲染页面有个常见机制叫懒加载——用户滚动到页面底部附近时,页面才会继续请求下一页的数据。如果我们不模拟滚动,列表就永远只有第一屏的少量职位。
# collector.py import time from playwright.sync_api import sync_playwright class Collector: def __init__(self, headless: bool = True): self.headless = headless def fetch_page_content(self, url: str, max_scroll_times: int = 5) -> str: with sync_playwright() as p: browser = p.chromium.launch(headless=self.headless) context = browser.new_context( viewport={"width": 1366, "height": 900}, user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0 Safari/537.36" ) page = context.new_page() # 导航并设置超时 page.goto(url, timeout=30000, wait_until="domcontentloaded") # 等待职位卡片容器出现 page.wait_for_selector(".job-card-container", timeout=15000) for _ in range(max_scroll_times): page.mouse.wheel(0, 1200) time.sleep(1.5) html = page.content() browser.close() return html代码里的两个细节值得说深一点。
第一个是user_agent的设置。浏览器自动化工具的默认UA和正常浏览器的UA特征有明显差异,很多平台的风控系统会先看UA头做第一层过滤。通过context.new_context传入一个常见浏览器的UA,能让请求从外观上更自然。这里不是用来破坏什么安全机制,而是避免把正常的访问误判为机器行为产生误伤。
第二个是滚动加载。max_scroll_times控制滚动次数,每次滚动1200像素,然后等待1.5秒。这个等待不是盲目的,而是给页面留出触发异步请求和渲染新卡片的时间。如果你网速较慢,可以把sleep时间调长一点;如果页面滚动一次能加载的内容很多,也可以一次加大滚动像素值。这个值,我建议结合你自己的网络环境去微调,不要照抄一个数。
另外在launch方法里,headless参数值得斟酌。headless=True是无头模式,不弹出浏览器窗口,适合服务器上跑批处理;headless=False是带界面模式,调试脚本时建议先开着界面,你能肉眼实时看到浏览器在干什么,卡在哪一步,排查问题效率翻倍。我自己的习惯是本地调试用headless=False,部署到生产环境再换成True。
3.2 配置模块:把选择器和字段集中管理
很多人写爬虫不建配置文件,选择器散落在各个代码文件里。说实话,小项目这么写勉强能忍,但这个项目的选择器数量不少,还是规整一点更省心。我把目标URL、Cookie类配置、CSS选择器、解析字段名都集中在config.py里。这样Pages变化时,我只需要打开一个文件改几个字符串,不用满项目翻代码。
# config.py TARGET_URL = "https://example.com/jobs?keyword=Python&city=101010100" SELECTORS = { "card_container": ".job-card-container", "job_title": ".job-title", "salary": ".salary", "company_name": ".company-name", "company_scale": ".company-scale", "skill_tags": ".skill-tag", "job_detail_link": ".job-title a", } OUTPUT_FIELDS = [ "job_title", "salary", "salary_lower", "salary_upper", "salary_months", "company_name", "company_scale", "skill_tags", ]我要单独提醒一个点:实际开发中,不要直接照抄任何平台页面上的class名到正式代码里,因为class名经常带有平台自身的版本前缀,一旦平台改版就会失效。你真正要做的是,打开目标页面后用浏览器开发者工具手动检查职位卡片的DOM结构,找到稳定的父节点和子节点层级,再把这套自己确认过的选择器写进配置。项目里我用了“job-card-container”这种描述性命名,就是提醒你:这是占位命名,实际页面请用你自己观察到的结构。
字段名列表的设计也有讲究。解析层最终会产出一条条记录,记录里的字段数量不要临时变来变去。提前定义好OUTPUT_FIELDS,各模块都以这套字段名作为约定,CSV的表头、SQLite的列名、解析器的输出字典都对齐这一份清单,就不会出现CSV里有的字段数据库里没有这种不一致问题。
3.3 结构化解析与数据清洗:从HTML片段到干净记录
拿到HTML源码之后,解析模块登场。我用lxml库配合XPath来做结构化提取,当然你也可以用BeautifulSoup配合CSS选择器,这个看个人习惯。lxml的解析速度更快,XPath在复杂层级结构里的表达能力也更强,所以我自己倾向用它。
# parser.py from lxml import html import re class Parser: def __init__(self, selectors: dict): self.selectors = selectors def parse_job_cards(self, page_html: str) -> list[dict]: tree = html.fromstring(page_html) cards = tree.cssselect(self.selectors["card_container"]) records = [] for card in cards: title_node = card.cssselect(self.selectors["job_title"]) if not title_node: continue title_text = title_node[0].text_content().strip() salary_text_raw = card.cssselect(self.selectors["salary"])[0].text_content().strip() company_name = card.cssselect(self.selectors["company_name"])[0].text_content().strip() scale_node = card.cssselect(self.selectors["company_scale"]) scale_text = scale_node[0].text_content().strip() if scale_node else "未知" tags = [tag.text_content().strip() for tag in card.cssselect(self.selectors["skill_tags"])] record = { "job_title": title_text, "salary": salary_text_raw, "company_name": company_name, "company_scale": scale_text, "skill_tags": "|".join(tags), } record.update(self._parse_salary(salary_text_raw)) records.append(record) return records def _parse_salary(self, raw_salary: str) -> dict: pattern = r"(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)K" match = re.search(pattern, raw_salary) if not match: return {"salary_lower": None, "salary_upper": None, "salary_months": None} lower = float(match.group(1)) upper = float(match.group(2)) months_match = re.search(r"(\d+)薪", raw_salary) months = int(months_match.group(1)) if months_match else None return {"salary_lower": lower, "salary_upper": upper, "salary_months": months}几个容易踩坑的地方我展开讲讲。
坑一:XPath/CSS定位后元素不存在。不是每张职位卡片都包含全部字段,比如有些标注了“融资未披露”,有些可能就没有公司规模字段。代码里用了if not title_node: continue的兜底逻辑,保证至少核心字段缺失时跳过该卡。其他非核心字段则用条件表达式给默认值,这样解析过程不会因为一张卡片的字段缺失而整体崩溃。
坑二:薪资字段的多种文本形态。“15-30K”“20-40K·15薪”“10-20K·13薪”这几种写法在页面上都很常见。处理这类字段的关键原则是“先抽取稳定片段,再做二次解析”。这里的_parse_salary函数先把薪资范围用正则拆成下限和上限两个数值,再用薪字模式匹配月份数。即使“·14薪”“·15薪”的写法不同,正则核心逻辑并没有变。
坑三:技能标签是列表字段。职位卡片上的“Java”“Spring”“MySQL”这类标签通常对应多个DOM节点。把它们用竖线连接成一个字符串,是为了落库时保持单列存储的简洁性。真要做标签维度的分析,后续可以在SQLite里另建一张职位技能关联表。采集阶段先把冗余一点的存储方案定下来,解析阶段就不要纠结规范化,能存下来是最重要的。
3.4 双存储模块:一份数据,两个去向
存储模块是这个项目里比较能体现工程意识的部分。CSV和SQLite两份存储,我把它封装成了同一个存储类,三个方法分别负责建表、写CSV、写SQLite。这样主程序里调用起来非常清爽。
# storage.py import csv import sqlite3 class Storage: def __init__(self, output_fields: list[str], csv_path: str, db_path: str): self.output_fields = output_fields self.csv_path = csv_path self.db_path = db_path def save_csv(self, records: list[dict]) -> None: with open(self.csv_path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=self.output_fields) writer.writeheader() writer.writerows(records) print(f"[CSV] 已导出 {len(records)} 条记录至 {self.csv_path}") def save_sqlite(self, records: list[dict]) -> None: conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS job_cards ( id INTEGER PRIMARY KEY AUTOINCREMENT, job_title TEXT, salary TEXT, salary_lower REAL, salary_upper REAL, salary_months INTEGER, company_name TEXT, company_scale TEXT, skill_tags TEXT ) """) insert_sql = """ INSERT INTO job_cards (job_title, salary, salary_lower, salary_upper, salary_months, company_name, company_scale, skill_tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """ cursor.executemany(insert_sql, [tuple(r[f] for f in self.output_fields) for r in records]) conn.commit() conn.close() print(f"[SQLite] 已写入 {len(records)} 条记录至 {self.db_path}")CSV这里我特别强调encoding参数用了utf-8-sig。如果你用过utf-8直接写CSV,再用Excel打开,会发现中文全部乱码。原因在于Excel默认按ANSI编码去解析CSV文件,而utf-8-sig会在文件开头写入一个BOM头,Excel认到这个标记就会切到UTF-8编码解析,问题直接消解。这是做数据导出最常见的坑之一,项目里第一次可以忽略,第二次一定会深有体会。
SQLite存储方面有几个设计细节。id主键用自增整数,保证每条记录有唯一标识。字段类型上,薪资下限上限用REAL类型,因为抽取出来的是浮点数值,方便后续做平均值、分位数计算。公司规模这类文本字段用TEXT,技能标签用竖线分隔的TEXT,保持简单。
数据库表在每次运行时执行CREATE TABLE IF NOT EXISTS,这个设计是幂等的。重复运行不会报错,也不会重复建表。INSERT语句用executemany批量插入,几千条数据一次性写入时,比逐条INSERT快一个数量级。最后一定要commit,python的sqlite3默认开启事务,不commit再连接一关,数据就白白丢了。
3.5 主程序编排:把整条流水线串起来
所有模块就绪后,主程序的工作就显得很优雅了。它不关心抓取细节、不关心解析规则、不关心存储格式,只负责按顺序调度模块,然后输出一个简单的日志结果。
# main.py from config import TARGET_URL, SELECTORS, OUTPUT_FIELDS from collector import Collector from parser import Parser from storage import Storage def main(): collector = Collector(headless=True) html_content = collector.fetch_page_content(TARGET_URL) parser = Parser(SELECTORS) records = parser.parse_job_cards(html_content) print(f"[INFO] 解析完成,共获取 {len(records)} 条职位记录") if not records: print("[WARN] 本次未获取到任何记录,请检查选择器或页面结构") return storage = Storage(OUTPUT_FIELDS, "jobs_output.csv", "jobs.db") storage.save_csv(records) storage.save_sqlite(records) if __name__ == "__main__": main()整个流程执行下来大概是这样的节奏:启动浏览器,打开职位列表页,滚动几次触发懒加载,拿到完整HTML;解析层把每条职位卡片拆成结构化记录,同时完成薪资清洗;存储层把记录写入CSV和SQLite,程序退出。
第一次跑的时候,我强烈建议你把headless设为False,亲眼看一遍浏览器执行过程。你会看到页面自动打开、自动滚动、自动关闭,对代码每一行在做什么会有非常直观的体感。等确认逻辑无误后再改回无头模式做批处理。
4. 常见问题与排查技巧实录
4.1 元素定位超时:wait_for_selector一直报错
项目跑起来后,最初次遇到的往往就是wait_for_selector超时。报错信息通常是“Timeout 30000ms exceeded”,意思是等了30秒还没等来职位卡片元素。这个问题大概率不是代码写错了,而是定位条件没对上。
我的排查路径是这样的:先确认页面是否真的加载成功,如果目标URL变了、需要登录、或做了跳转,页面根本不是职位列表页,自然等不到卡片。这种情况先打印page.title()或page.url看看当前落在哪个页面。其次确认选择器是否还有效,平台改版后class名可能变了,打开浏览器开发者工具重新检查DOM结构。还有一种可能是网络慢,页面加载本身就花了很久,把超时时间从15000ms调到30000ms试试。
排查这类问题,建议直接在脚本里加一点调试输出,把当前URL、当前页面标题打出来,能快速定位问题出在访问层还是解析层。
4.2 拿到内容却是空列表:解析结果为何为零条
如果你确认页面加载正常、HTML里也有职位卡片结构,但parser解析后records列表为空,那问题往往出在选择器和卡片的对应关系上。
最常见的误判是把外层容器当成了卡片本身。职位列表的DOM结构通常是“列表容器”嵌套“多个卡片节点”,如果你用列表容器的class去循环遍历,得到的可能是一个整体的父节点,而不是每张卡片的子节点集合。这时你循环里再去取职位标题,当然怎么都取不到。解决方法是调整选择器粒度,逐级定位:先找列表容器,再找容器内的卡片子节点。
还有一种隐蔽情况是页面里实际有两种型态的职位卡片,比如“普通职位”和“急聘职位”结构不一样,公用同一个class名但内部字段位置有差异。这种情况我建议解析时对两种卡片分别做一套提取逻辑,先按卡片类型做个分支判断,再提取各自字段。
4.3 反爬拦截:内容可以被访问,但运行时频繁校验
运行一段时间后,平台可能对频繁访问做出响应,表现是页面出现验证码或者跳转到安全验证页。老实说,这个问题的正确解法只有一个:先停下来反思自己的访问频率。
很多采集项目遇到反爬想到的第一件事是换IP、换指纹、绕验证,这个方向我认为是错的。即使技术上能做到,也越过了正常采集的边界,风险非常大。更合理的处理方式是降低请求频率,每次抓完一页后适当sleep个几秒到十几秒,模拟人浏览的节奏;同时把采集任务分散到每天的固定时间段运行,而不是短时间集中大量抓取。
我自己的实测经验是,单次任务只抓有限页数、每天控制总请求次数,脚本稳定运行的周期会明显变长。数据量不够就拉长采集周期,今天跑一点、明天跑一点,积累下来的数据仍然足够分析用。细水长流远好过一击必中后被封。
4.4 数据去重与增量策略
采集任务跑久了必然出现重复数据。同一职位今天挂出来,明天再去抓,还在列表里是很正常的。如果每次都无脑入库,SQLite里的重复记录会越来越多,分析时结果失真。
我在这个项目里给出的基础方案是每次运行清空CSV重新写入,SQLite则通过job_title + company_name字段做查询去重后再插入。更细的方案可以加一个“职位详情链接”的唯一字段,用它来判断同一条职位是否已存在。招聘平台每个职位详情页的URL都是唯一的,这个字段是天然的批量去重键。
如果希望做得更完善一点,可以在job_cards表上给这列加一个UNIQUE索引,插入时使用INSERT OR IGNORE,让数据库层面的唯一约束帮你自动去重。这样的增量策略让每次运行都只追加新增的职位,历史数据完整保留,数据集随天数增长变得更有分析价值。
4.5 项目维护与长期稳定性
采集项目最怕“一次性跑通,第二周就挂”。增长规律是:网站改版越勤快,选择器失效越快。针对这个项目,我建议两个维护策略。
第一,配置文件里集中管理选择器,日常巡检只需盯着config.py看有没有失效项,不用改业务代码。
第二,给采集任务加一个基础的运行日志,每次运行记录抓取条数、入库条数、耗时,出现异常时方便回看。日志不用复杂,写进一个文本文件或直接输出到控制台都行,关键是能看出来今天抓的数据和昨天是否数量级一致。一旦某天数据量断崖式下降,大概率是选择器失效或页面结构变了,及时排查就能减少数据断档期。
5. 方案的后续扩展与优化方向
5.1 从单页抓取到多页翻页采集
目前这套方案执行一次只抓一个列表页的数据。要构建成规模的样本集,翻页是必须跨出的下一步。
翻页的实现思路有两种。一种是循环改变URL参数,比如把页码参数从1递增到N,重新执行抓取逻辑;另一种是在浏览器内点击“下一页”按钮,让页面自己加载新数据。我推荐第一种,代码更可控,也不依赖页面按钮的DOM结构。具体实现就是在collector方法里增加一个页码参数,循环调用时每页之间预留合理间隔时间即可。
5.2 多职位关键词方向的维度扩展
如果你想分析不同岗位方向的市场行情,可以在TARGET_URL的基础上加关键词维度。一个关键词对应一批样本数据,这样就能横向对比“Python开发”和“Java开发”的薪资分布、技能要求差异。扩展方式和分页类似,本质上是在URL层面做参数化。
采集时可以在记录里增加一个keyword字段,记录当前批次数据来源于哪个搜索词。这样SQLite查询时就多了一个分类维度,后续分析时可以按关键词分组统计。这个字段建议从一开始就加进表结构,就算现在只跑一个关键词,后续扩展也不用折腾表结构。
5.3 数据分析层的联动
数据落库之后,分析才是最终价值所在。SQLite里存的数据可以直接配合pandas做探索式分析,比如按公司规模分组算薪资中位数、按技能标签统计出现频次、按月薪范围画直方图。这一层不需要重新写抓取代码,数据都已经在jobs.db里等着了。
我的建议是数据积累了两到三周之后再开始分析,样本量越大结论越可靠。首次跑完就急着看趋势,数据量太小,分析出的结果容易失真。
6. 实操中的几点额外心得
最后分享几个项目落地过程中的零散心得,不定性归类,但对实操有直接帮助。
第一,调试阶段不要把headless设成False就完事,还应该多利用page对象的几个内置方法,比如page.screenshot(path="debug.png")做截图,page.wait_for_timeout做定点观察。浏览器界面里的可视化操作配合截图比对,能让你更快定位到底页面结构哪里和代码预期不符。
第二,不要迷信任何教程里给出来的CSS选择器。页面变化太快,别人的经验只能帮你理解结构,真正可靠的选择器必须基于你自己对页面当前DOM的检查。任何人给你的选择器都要抱着“可能已经过期”的心态去验证,这是爬虫项目的常态。
第三,采集数据的频率一定要保持克制。我见过有人为了快速积累样本,把并发开到很高,结果第二天账号异常、IP受限。平台数据是持续在变化的资源,今天拿不到的数据,明天调整策略还能再拿。稳定的采集节奏、克制的请求频率,才是让这个项目持续产生价值的核心习惯。
第四,数据备份不要只依赖CSV和SQLite两份。SQLite数据库文件可以定期拷贝一份到网盘或对象存储里,防一手本机磁盘故障。数据来之不易,多一层备份容错,长期积累的数据资产才真正稳。