Python爬虫实战:从职友集高效采集招聘数据
1. 项目缘起为什么选择职友集作为招聘数据源最近在帮一个做人力资源分析的朋友梳理市场趋势他需要一份结构化的招聘数据来做岗位需求分析和技能图谱绘制。市面上招聘平台很多像BOSS直聘、拉勾、智联招聘但要么反爬策略复杂要么数据接口不开放对于个人开发者或小团队来说直接上手成本太高。几番对比下来我发现职友集Jobui这个网站是个不错的切入点。职友集本身是一个招聘信息聚合平台它从各大招聘网站抓取信息并重新整理展示。对我们爬虫开发者来说这反而成了一个优势页面结构相对统一、稳定不像源头网站那样频繁改版或部署强力的动态反爬机制。而且它提供了按城市、职位、公司等多维度筛选的功能非常适合我们进行定向、批量的数据采集用于分析某一特定岗位比如“Python开发工程师”在全国或特定区域的需求情况、薪资分布和技能要求。这个项目的核心目标很明确写一个Python脚本能够稳定、高效地从职友集网站爬取指定条件的招聘信息并将结果如职位名称、公司、薪资、地点、要求等保存为结构化的数据文件如CSV或Excel为后续的数据分析打下基础。整个过程会涉及请求库选择、页面解析、数据清洗、反爬应对以及数据存储等多个环节是一个比较典型的Web爬虫实战项目。2. 环境准备与核心工具选型工欲善其事必先利其器。在开始写代码之前我们需要搭建好Python环境并选定这次要用的“武器库”。我的原则是在满足需求的前提下优先选择社区活跃、文档清晰、简单易用的库。2.1 Python环境与包管理首先确保你安装了Python版本建议在3.7及以上。我习惯使用conda或venv创建独立的虚拟环境避免包版本冲突。这里以venv为例# 创建名为 jobui_spider 的虚拟环境 python -m venv jobui_spider # 激活环境 (Windows) jobui_spider\Scripts\activate # 激活环境 (macOS/Linux) source jobui_spider/bin/activate激活后命令行提示符前会出现(jobui_spider)表示你已进入该环境。2.2 核心库的选型与安装接下来安装我们需要的库。通过pip命令一键安装pip install requests beautifulsoup4 pandas lxml下面解释一下为什么选这几个以及一些备选方案的考量requests这是发送HTTP请求的绝对主力。相比于Python内置的urllibrequests的API设计极其人性化代码简洁直观处理Cookie、Session、代理等都非常方便。对于职友集这类静态页面为主的网站它完全够用。也有朋友问为什么不用aiohttp做异步对于初期学习和中小规模抓取比如几千条数据同步的requests更简单直接不容易出问题等真有性能瓶颈再考虑异步优化也不迟。BeautifulSoup4(bs4)HTML/XML解析器。它的优势是解析方式灵活支持多种解析器后端我们指定了lxml写起来像在遍历一棵树非常符合直觉。特别是对于职友集这种标签结构清晰的页面用bs4通过标签名、class、id来定位元素非常高效。为什么不直接用lxmllxml的XPath固然强大且速度快但bs4的find和find_all方法对新手更友好代码可读性更高。本项目我们会结合使用bs4和一点lxml的XPath作为补充。pandas数据处理和分析的瑞士军刀。我们爬下来的数据通常是列表形式的字典用pandas的DataFrame来承接、清洗、去重、最后导出为CSV或Excel只需要寥寥几行代码非常优雅。它比直接操作Python列表字典或者用csv模块手动写入要强大和方便得多。lxml一个高性能的解析库。我们主要用它作为BeautifulSoup的解析引擎BeautifulSoup(html, lxml)速度比Python内置的html.parser快很多。同时lxml也直接支持XPath在某些复杂提取场景下可以作为bs4的补充。注意有些教程会推荐Selenium或Playwright这类浏览器自动化工具。对于职友集除非它后期改版成完全由JavaScript渲染的动态页面目前看主要部分仍是静态否则没必要上这种“重型武器”。它们资源消耗大、速度慢主要用于解决反爬或处理复杂交互杀鸡焉用牛刀。3. 页面结构分析与数据定位策略写爬虫最关键的步骤不是敲代码而是“看”页面。我们需要弄清楚目标数据藏在HTML的哪个角落以及如何精准地把它“挖”出来。3.1 确定目标URL与翻页逻辑打开职友集官网搜索“Python”职位。观察浏览器地址栏URL会变成类似https://www.jobui.com/jobs?jobKwPythoncityKw全国的形式。这就是我们的起点URL。我们需要关注几个关键查询参数jobKw搜索关键词。cityKw城市。可能还有industry行业、salary薪资等。翻页逻辑是分析的重点。点击第二页URL可能变为https://www.jobui.com/jobs?jobKwPythoncityKw全国p2。看到了吗多了一个p2的参数。这就是最简单的GET参数翻页。有些网站会用page有些是pn职友集这里是p。这意味着我们只需要在循环中改变这个p的值就能遍历所有列表页。3.2 解析列表页获取详情页链接在列表页例如第一页按F12打开开发者工具使用元素检查工具通常是个箭头图标去点击页面上的一个职位标题。你会发现这个标题通常被包裹在一个a标签里这个标签的href属性值就是该职位详情页的链接。我们需要找到所有这类链接的共同特征。查看其HTML结构它可能在一个div classjob-list下的每个div classjob-item里里面的a classjob-name的href就是我们要的。用BeautifulSoup可以这样定位job_list soup.find_all(div, class_job-item) # 先找到所有职位块 for job in job_list: link_tag job.find(a, class_job-name) # 在每个块里找链接标签 if link_tag and link_tag.get(href): detail_url link_tag[href] # 注意抓到的链接可能是相对路径需要拼接上基础域名 full_url https://www.jobui.com detail_url if detail_url.startswith(/) else detail_url这一步的目标是收集当前列表页上所有职位的详情页URL存入一个列表供后续逐个访问抓取详细信息。3.3 解析详情页提取结构化数据进入一个职位详情页这才是数据的宝库。我们需要提取的信息通常包括职位名称公司名称工作地点薪资范围学历要求工作经验职位描述/要求公司福利发布时间同样使用检查工具去查看这些信息对应的HTML元素。例如职位名称可能在h1标签里公司名称在某个div classcompany-name里薪资在span classsalary里。这里有一个非常重要的技巧不要只盯着一个页面看多打开几个不同公司的职位详情页对比它们的HTML结构。有时候相同信息在不同页面里使用的CSS类名可能略有不同或者包裹的标签层级有差异。你需要找到一个最通用、最稳定的定位方式。可能用class定位不稳那就用标签组合加位置索引或者用find方法配合text属性的字符串匹配。例如提取薪资# 方式1通过class定位最理想如果稳定 salary_tag soup.find(span, class_salary-text) if salary_tag: salary salary_tag.get_text(stripTrue) # 方式2如果class不稳定可以找包含“薪资”或“”文本的标签 for tag in soup.find_all([span, div, p]): if 薪资 in tag.get_text() or in tag.get_text() or K in tag.get_text(): salary tag.get_text(stripTrue) break实操心得数据提取的代码要写得健壮。一定要多用if判断标签是否存在并用.get_text(stripTrue)来获取清理过空白字符的文本。否则一旦某个页面结构稍有不同你的爬虫就会因为AttributeError而崩溃。4. 爬虫核心代码实现与逐行解读分析完页面我们就可以动手编写核心爬虫代码了。我将代码分成几个函数让逻辑更清晰。4.1 请求函数处理网络交互与基础反爬首先写一个通用的请求函数它负责发送请求、处理异常、返回页面HTML。import requests from time import sleep import random def fetch_url(url, headersNone, max_retries3): 发送HTTP GET请求支持重试和简单伪装。 参数: url: 目标URL。 headers: 请求头默认为None时会使用一个常见的浏览器UA。 max_retries: 最大重试次数。 返回: 成功则返回响应文本字符串失败则返回None。 if headers is None: # 设置一个常见的用户代理头模拟浏览器访问 headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 } for attempt in range(max_retries): try: # 每次请求前随机等待一小段时间模拟人工操作避免触发频率限制 sleep_time random.uniform(1, 3) sleep(sleep_time) response requests.get(url, headersheaders, timeout10) # 设置超时时间 response.raise_for_status() # 如果状态码不是200会抛出HTTPError异常 # 检查编码通常可以靠apparent_encoding自动判断 response.encoding response.apparent_encoding return response.text except requests.exceptions.RequestException as e: print(f请求失败 ({attempt1}/{max_retries}): {url} - 错误: {e}) if attempt max_retries - 1: wait_time (attempt 1) * 5 # 重试等待时间递增 print(f等待 {wait_time} 秒后重试...) sleep(wait_time) else: print(f已达到最大重试次数放弃URL: {url}) return None关键点解读User-Agent这是最基本的反爬措施。不带UA或使用爬虫库默认UA的请求很容易被识别并拒绝。我们伪装成一个普通Chrome浏览器。随机延迟sleep(random.uniform(1, 3))是礼貌爬虫的必备。连续高速请求会对服务器造成压力也容易被封IP。随机间隔使其行为更像真人。异常处理与重试网络请求充满不确定性超时、连接错误、服务器临时错误5xx。用try...except包裹并加入重试逻辑能极大提高爬虫的健壮性。编码处理中文网站编码可能是utf-8或gbk等。response.apparent_encoding是requests库基于内容猜测的编码大多数情况下是准确的比硬编码更可靠。4.2 列表页解析函数收集详情链接这个函数解析一个列表页的HTML提取出当前页所有职位的详情页链接。from bs4 import BeautifulSoup def parse_list_page(html_content, base_urlhttps://www.jobui.com): 解析职位列表页提取所有详情页链接。 参数: html_content: 列表页的HTML文本。 base_url: 网站基础URL用于拼接相对链接。 返回: 详情页URL的列表。 if not html_content: return [] soup BeautifulSoup(html_content, lxml) detail_urls [] # 这里的选择器需要根据实际页面结构调整 # 假设每个职位信息在一个class为‘c-job-list’的div里 job_items soup.find_all(div, class_c-job-list) # 如果上述选择器找不到尝试更通用的方法寻找所有可能是职位链接的a标签 if not job_items: # 例如寻找href包含‘/job/’或‘/position/’的链接 all_links soup.find_all(a, hrefTrue) for link in all_links: href link[href] if /job/ in href or /position/ in href: full_url href if href.startswith(http) else base_url href if full_url not in detail_urls: # 简单去重 detail_urls.append(full_url) return detail_urls # 如果能找到具体的job-items进行精确提取 for item in job_items: # 在item内部寻找职位标题链接class名需要根据实际情况修改 title_link item.find(a, class_job-title) if title_link and title_link.get(href): href title_link[href] full_url href if href.startswith(http) else base_url href detail_urls.append(full_url) return detail_urls踩坑提醒网页结构可能会变今天classjob-title明天可能就变成classjob-name。所以代码里我写了两种策略先尝试用已知的、精确的CSS选择器如果找不到就启用备用方案通过链接特征如包含/job/来抓取。在实际运行中要经常检查提取的链接数量和样例是否正确。4.3 详情页解析函数提取核心数据这是最核心的函数负责从详情页HTML中提取我们关心的字段。def parse_detail_page(html_content): 解析职位详情页提取结构化信息。 参数: html_content: 详情页的HTML文本。 返回: 一个包含职位信息的字典。 if not html_content: return None soup BeautifulSoup(html_content, lxml) job_info {} # 1. 提取职位标题 - 通常是最显眼的h1标签 title_tag soup.find(h1) job_info[职位名称] title_tag.get_text(stripTrue) if title_tag else 未知 # 2. 提取公司名称 - 可能需要根据页面具体结构定位 # 假设在公司信息区域class为‘company-name’ company_tag soup.find(div, class_company-name) if not company_tag: # 备用方案找包含“公司”二字的div或a标签 for tag in soup.find_all([a, div, span]): if 公司 in tag.get_text(): company_tag tag break job_info[公司名称] company_tag.get_text(stripTrue) if company_tag else 未知 # 3. 提取工作地点 # 常见模式在某个包含“地点”、“城市”、“工作地”文本的标签后面 location 未知 for tag in soup.find_all([span, div, p]): text tag.get_text(stripTrue) if 地点 in text or 城市 in text or 工作地 in text: # 有时信息就在这个标签里有时在相邻的下一个标签里 # 这里简单处理取这个标签的文本并清理掉“地点”这样的前缀 location text.replace(地点, ).replace(城市, ).strip() break # 或者直接找包含特定class的标签如‘job-location’ loc_tag soup.find(span, class_job-location) if loc_tag: location loc_tag.get_text(stripTrue) break job_info[工作地点] location # 4. 提取薪资 - 通常包含“K”、“万”、“-”等字符 salary 面议 # 默认值 for tag in soup.find_all([span, div, p, strong]): text tag.get_text(stripTrue) if (K in text or k in text or 万 in text) and (- in text or ~ in text or 以上 in text): salary text break job_info[薪资范围] salary # 5. 提取经验与学历要求 - 它们经常在一起出现 requirements {} req_text # 尝试找到一个包含“经验”、“学历”、“要求”等关键词的区块 exp_edu_section soup.find(div, class_job-requirement) or soup.find(div, class_job-qualification) if exp_edu_section: req_text exp_edu_section.get_text( , stripTrue) # 用空格连接所有子文本 else: # 如果找不到特定区块就从整个页面文本中匹配关键词附近的内容较粗糙 # 这里简化处理实际可能需要更复杂的文本解析 pass # 简单地从req_text中提取 import re experience_pattern r经验[:]?\s*([\d\-~以上]年?) education_pattern r学历[:]?\s*([大专本科硕士博士以上]) exp_match re.search(experience_pattern, req_text) edu_match re.search(education_pattern, req_text) job_info[工作经验] exp_match.group(1) if exp_match else 不限 job_info[学历要求] edu_match.group(1) if edu_match else 不限 # 6. 提取职位描述/要求 (JD) jd jd_section soup.find(div, class_job-desc) or soup.find(div, class_job-description) if jd_section: # 获取该部分下所有文本并清理多余的空格和换行 jd jd_section.get_text( , stripTrue) # 可以进一步清理比如去掉过长的空格序列 jd .join(jd.split()) job_info[职位描述] jd[:500] ... if len(jd) 500 else jd # 只取前500字符避免过长 # 7. 提取发布时间 pub_time 未知 time_tag soup.find(span, class_publish-time) if time_tag: pub_time time_tag.get_text(stripTrue) job_info[发布时间] pub_time return job_info经验技巧防御性编程每个字段的提取都伴随着if判断防止因标签缺失导致程序崩溃。字段默认值如‘未知’、‘面议’保证了数据结构的完整性。多层提取策略对于关键信息如公司名、地点我设计了主方案通过特定class和备用方案通过文本关键词搜索。这大大增强了代码的适应性。正则表达式辅助对于“经验”、“学历”这类格式相对固定的文本正则表达式re是非常高效的工具。但要注意模式的设计避免匹配到无关内容。文本清理.get_text(stripTrue)和‘ ‘.join(text.split())可以有效地移除HTML标签和规整空白字符得到干净的文本。4.4 主控流程与数据存储最后我们把所有函数串联起来并加入翻页逻辑和数据保存功能。import pandas as pd from tqdm import tqdm # 可选用于显示进度条 def main(keywordPython, city全国, max_pages5): 爬虫主函数。 参数: keyword: 搜索关键词。 city: 城市。 max_pages: 最大爬取列表页数。 base_url https://www.jobui.com all_jobs [] # 存储所有职位信息字典 # 1. 遍历列表页 for page in range(1, max_pages 1): print(f\n正在爬取第 {page} 页列表...) # 构造列表页URL list_url f{base_url}/jobs?jobKw{keyword}cityKw{city}p{page} list_html fetch_url(list_url) if not list_html: print(f第 {page} 页列表获取失败跳过。) continue # 2. 解析列表页得到详情页链接列表 detail_urls parse_list_page(list_html, base_url) print(f在第 {page} 页找到 {len(detail_urls)} 个职位链接。) if not detail_urls: print(未找到详情链接可能页面结构已变化或已无更多数据。) # 可以考虑在这里break停止翻页 break # 3. 遍历当前页的每个详情页 for detail_url in tqdm(detail_urls, descf处理第{page}页详情): detail_html fetch_url(detail_url) if detail_html: job_data parse_detail_page(detail_html) if job_data: # 补充来源URL方便追溯 job_data[详情页链接] detail_url all_jobs.append(job_data) # 详情页请求间隔已在fetch_url函数中控制 # 列表页之间的间隔可以稍长一点 sleep(random.uniform(2, 5)) # 4. 数据保存 if all_jobs: df pd.DataFrame(all_jobs) # 重排一下列的顺序让关键信息在前 columns_order [职位名称, 公司名称, 工作地点, 薪资范围, 工作经验, 学历要求, 发布时间, 职位描述, 详情页链接] # 只保留DataFrame中存在的列 existing_columns [col for col in columns_order if col in df.columns] df df[existing_columns [col for col in df.columns if col not in existing_columns]] filename fjobui_{keyword}_{city}_jobs.csv df.to_csv(filename, indexFalse, encodingutf-8-sig) # utf-8-sig支持Excel直接打开不乱码 print(f\n爬取完成共获取 {len(all_jobs)} 条数据已保存至 {filename}) # 简单预览前几行数据 print(\n数据预览) print(df.head()) else: print(未爬取到任何数据。) if __name__ __main__: # 可以在这里修改搜索参数 main(keywordPython, city上海, max_pages3) # 先爬3页测试核心逻辑解读翻页循环for page in range(1, max_pages 1)控制爬取的列表页数量。max_pages不宜一开始就设置太大先测试几页。URL构造使用f-string动态生成带有关键词、城市和页码的列表页URL。两级抓取外层循环抓列表页内层循环抓列表页中的每一个详情页。这是爬虫最常见的“列表-详情”模式。进度反馈使用tqdm库可以给详情页的遍历加上美观的进度条对于长时间运行的任务非常有用。数据存储使用pandas.DataFrame将字典列表转换为表格数据结构然后一键导出为CSV。utf-8-sig编码是为了让生成的CSV文件用Microsoft Excel打开时中文字符能正常显示。优雅终止如果解析列表页时发现没有提取到任何详情链接可能意味着已经爬完所有页面或页面结构变了这时用break跳出翻页循环是合理的。5. 反爬策略应对与脚本健壮性提升我们的基础脚本已经能跑了但要在真实环境中稳定运行必须考虑网站可能设置的反爬机制。职友集虽然相对友好但必要的防护措施不能少。5.1 User-Agent轮换与请求头完善单一的User-Agent用久了可能被识别。我们可以准备一个UA列表每次请求随机选择一个。import random USER_AGENTS [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Safari/605.1.15, Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0, # ... 可以添加更多 ] def fetch_url(url, headersNone, max_retries3): if headers is None: headers {} if User-Agent not in headers: headers[User-Agent] random.choice(USER_AGENTS) # 可以添加更多常见的请求头使其更像浏览器 headers.update({ Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept-Encoding: gzip, deflate, br, Connection: keep-alive, Upgrade-Insecure-Requests: 1, }) # ... 其余请求代码不变注意Accept-Encoding中声明了brBrotlirequests默认不支持如果服务器返回Brotli压缩的内容可能会出错。更稳妥的做法是只写gzip, deflate或者安装brotli库。这里为了简化我们在headers里保留它但requests会忽略不支持的编码。5.2 IP代理与访问频率控制如果爬取速度过快或数据量很大可能会触发IP限制。这时需要考虑降低频率我们已经有了随机延迟但如果还不行可以进一步增加sleep的时间比如在列表页之间sleep(5, 10)。使用代理IP这是应对IP封锁的终极方案。可以将代理IP集成到requests中proxies { http: http://your_proxy_ip:port, https: https://your_proxy_ip:port, } response requests.get(url, headersheaders, proxiesproxies, timeout10)代理IP可以从一些付费或免费的代理服务商获取但免费代理大多不稳定。对于重要项目建议使用可靠的付费代理服务并实现代理IP池的自动切换和失效检测。5.3 处理Cookie与Session有些网站需要维护会话状态。requests的Session对象可以自动处理Cookie在多次请求间保持状态。session requests.Session() # 可以给session统一设置headers session.headers.update({User-Agent: random.choice(USER_AGENTS)}) # 然后在fetch_url函数中使用session.get()代替requests.get() response session.get(url, timeout10)对于职友集目前看不需要维持复杂会话但了解这个技术点很有必要。5.4 异常数据记录与断点续爬爬虫长时间运行难免遇到个别页面解析失败。我们不能让一个页面的错误导致整个程序停止。详细日志使用Python的logging模块替代print将错误信息、爬取状态记录到文件方便后期排查。数据去重在将job_data加入all_jobs列表前可以检查详情页链接是否已经存在避免因翻页重复或重试导致数据重复。断点续爬这是一个进阶功能。思路是定期将all_jobs列表保存到文件如JSON格式同时记录当前爬到的页码和详情页URL索引。如果程序意外中断重启时先加载已保存的数据和进度然后从断点处继续爬取。这需要对主循环逻辑进行一些改造。6. 数据清洗与初步分析示例爬下来的原始数据往往比较“脏”直接分析可能有问题。用pandas可以很方便地进行清洗。假设我们已经将数据加载到DataFramedf中import pandas as pd df pd.read_csv(jobui_Python_上海_jobs.csv) # 1. 查看数据概览和信息 print(df.info()) # 查看各列数据类型和非空值数量 print(df.head()) # 查看前几行 # 2. 处理缺失值 # 删除所有列都为NaN的行 df_cleaned df.dropna(howall) # 对于关键列比如‘公司名称’为空的可以考虑删除该行或填充为‘未知’ df_cleaned[公司名称].fillna(未知, inplaceTrue) # ‘薪资范围’为‘面议’或空的可以单独标记分析时排除 df_cleaned[薪资范围].replace(, 面议, inplaceTrue) # 3. 薪资字段的标准化处理 (这是一个复杂但很有价值的步骤) # 原始薪资可能是“15-30K·13薪”、“20-40万/年”、“面议” def parse_salary(salary_str): if pd.isna(salary_str) or 面议 in salary_str: return None, None salary_str salary_str.upper() # 处理“万/年” if 万 in salary_str and 年 in salary_str: try: # 提取数字如“20-40” import re nums re.findall(r(\d\.?\d*), salary_str) if len(nums) 2: low, high float(nums[0]), float(nums[1]) # 转换为月薪粗略按13薪算 low_month low * 10000 / 13 high_month high * 10000 / 13 return low_month, high_month except: pass # 处理“K”或“K·13薪” elif K in salary_str: try: salary_str salary_str.replace(K, ).replace(·, -) nums re.findall(r(\d\.?\d*), salary_str) if len(nums) 2: return float(nums[0]), float(nums[1]) elif len(nums) 1: return float(nums[0]), float(nums[0]) except: pass return None, None # 应用函数创建新的月薪范围列 df_cleaned[[月薪下限(K), 月薪上限(K)]] df_cleaned[薪资范围].apply( lambda x: pd.Series(parse_salary(x)) ) # 4. 计算平均月薪用于粗略分析 df_cleaned[月薪均值(K)] df_cleaned[[月薪下限(K), 月薪上限(K)]].mean(axis1) # 5. 保存清洗后的数据 df_cleaned.to_csv(cleaned_jobs.csv, indexFalse, encodingutf-8-sig) # 6. 简单的统计分析 print(f有效薪资职位数: {df_cleaned[月薪均值(K)].notna().sum()}) print(f平均月薪: {df_cleaned[月薪均值(K)].mean():.2f}K) print(f月薪中位数: {df_cleaned[月薪均值(K)].median():.2f}K) # 按公司统计招聘数量 company_counts df_cleaned[公司名称].value_counts().head(10) print(\n招聘数量最多的前十家公司:) print(company_counts)这个清洗示例展示了如何将杂乱的文本薪资转换为可计算的数值这是数据分析前非常关键的一步。当然真实的薪资解析会更复杂需要处理更多边缘情况。7. 常见问题排查与优化建议在开发和运行爬虫的过程中你肯定会遇到各种问题。这里总结几个常见的坑和解决办法。问题1请求返回403 Forbidden错误。可能原因请求头不够像浏览器或IP被暂时限制。解决检查并完善headers特别是User-Agent,Accept,Accept-Language。显著降低请求频率增加随机延迟。考虑使用代理IP。问题2BeautifulSoup找不到任何标签返回空列表。可能原因网页是动态加载的初始HTML不包含数据或者CSS选择器写错了页面结构已更新。解决在浏览器中右键“查看网页源代码”不是检查元素搜索你想要的文本。如果源代码里没有说明是JS动态渲染的需要用Selenium或Playwright。如果源代码里有但你的选择器找不到用浏览器的检查工具重新确认标签和class名。尝试更通用的选择器比如用find_all(True)打印所有标签名看看结构。问题3爬取过程中突然中断报错ChunkedEncodingError或ConnectionError。可能原因网络不稳定或服务器主动断开连接。解决在fetch_url函数中我们已经实现了重试机制。可以适当增加max_retries和重试等待时间。确保网络连接稳定。问题4数据中有大量重复条目。可能原因列表页翻页时内容有重叠或者详情页链接去重不彻底。解决在将详情页URL加入任务队列前使用集合(set)进行去重。在保存最终数据时也可以根据详情页链接或职位名称公司名称进行去重。问题5爬虫速度太慢。原因我们使用的是同步请求且加了延迟速度必然受限。优化方向进阶并发/异步请求使用concurrent.futures的ThreadPoolExecutor实现多线程或者使用aiohttpasyncio实现异步IO。这可以成倍提升抓取详情页的速度。但务必注意控制并发数避免对目标网站造成过大压力。优化解析逻辑如果BeautifulSoup解析成为瓶颈对于超大型HTML可以考虑直接用lxml的XPath速度更快。剥离IO与计算将网络请求IO密集型和HTML解析CPU密集型分开到不同的线程/进程池充分利用资源。给新手的最终建议先从我们上面这个同步、单线程、有延迟的版本开始。它能帮你理解爬虫的完整流程和可能遇到的所有基础问题。当这个版本稳定运行并且你确实需要更快速度时再去研究并发和异步技术。过早优化是万恶之源一个稳定、健壮、可维护的爬虫远比一个快但脆弱的爬虫有价值得多。