如何使用Scrapy构建可正确爬取Stack Overflow Jobs远程职位的爬虫
问题根源
- 第一版Spider手动拼接的
/jobs?id=<jobid>&r=true属于列表页搜索参数链接,并非真实职位详情路由,请求后会重定向回远程职位列表页,无法拿到职位详情字段 - 裸Scrapy请求的默认请求头缺少浏览器特征字段,Stack Overflow WAF会识别自动化请求,返回经过裁剪的反爬占位页面,和浏览器看到的真实内容存在差异
- 手动从URL字符串尾部匹配页码的分页逻辑容错性极差,遇到URL编码、参数顺序变动就会失效
可落地的优化方案
1. 配置模拟浏览器特征的请求头
不需要额外加装JS渲染中间件,直接在Spider的custom_settings中配置符合真实浏览器特征的请求头,搭配合理的请求频率,即可绕开基础反爬拦截:
custom_settings = { "DEFAULT_REQUEST_HEADERS": { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.5", "Referer": "https://stackoverflow.com/jobs/remote-developer-jobs", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36" }, "DOWNLOAD_DELAY": 2, "ROBOTSTXT_OBEY": False }
2. 修正链接构造逻辑
不要通过拿到的job_id手动拼接详情页URL,直接从列表页DOM中提取已经渲染好的真实职位链接;分页环节直接跟随站点渲染的“下一页”按钮链接,替代硬切字符串取页码的逻辑。
3. 详情页字段提取规则
所有需要的字段都直接嵌在静态HTML中,不需要执行JS即可提取,对应的选择器如下:
- 职位名称:
h1.fs-headline1 a::text - 薪资范围(存在时提取,无则返回空):
.fc-orange-400::text - 工作类型:
.js-remotable-indicator + .grid--cell::text - 经验要求:
.grid--cell:contains("Experience level") + .grid--cell::text - 岗位角色:
.grid--cell:contains("Role") + .grid--cell::text - 所属行业(存在时提取,无则返回空):
.grid--cell:contains("Industry") + .grid--cell::text - 公司规模(存在时提取,无则返回空):
.grid--cell:contains("Company size") + .grid--cell::text - 公司类型(存在时提取,无则返回空):
.grid--cell:contains("Company type") + .grid--cell::text - 偏好时区(存在时提取,无则返回空):
.grid--cell:contains("Time zones") + .grid--cell::text - 所需技术栈:
.js-tag a.post-tag::text
4. 可直接运行的Spider参考实现
import scrapy class JobsSpider(scrapy.Spider): name = "jobs" start_urls = [ "https://stackoverflow.com/jobs/remote-developer-jobs" ] custom_settings = { "DEFAULT_REQUEST_HEADERS": { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.5", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36" }, "DOWNLOAD_DELAY": 2, "ROBOTSTXT_OBEY": False } def parse(self, response): # 遍历列表页所有职位卡片 job_cards = response.xpath("//div[contains(@class, 'job')]") for job_card in job_cards: # 提取DOM中已渲染的真实详情链接,不手动拼接 job_detail_path = job_card.xpath(".//h2/a/@href").get() if job_detail_path: yield response.follow(job_detail_path, callback=self.parse_job_detail) # 跟随渲染好的下一页链接,不手动解析页码 next_page_path = response.xpath("//a[contains(@class, 's-pagination--item') and contains(normalize-space(.), 'Next')]/@href").get() if next_page_path: yield response.follow(next_page_path, callback=self.parse) def parse_job_detail(self, response): # 直接返回结构化字段,不存在的字段默认返回空值 yield { "job_title": response.css("h1.fs-headline1 a::text").get("").strip(), "salary_range": response.css(".fc-orange-400::text").get("").strip(), "work_type": response.css(".js-remotable-indicator + .grid--cell::text").get("").strip(), "experience_requirement": response.css(".grid--cell:contains('Experience level') + .grid--cell::text").get("").strip(), "job_role": response.css(".grid--cell:contains('Role') + .grid--cell::text").get("").strip(), "industry": response.css(".grid--cell:contains('Industry') + .grid--cell::text").get("").strip(), "company_size": response.css(".grid--cell:contains('Company size') + .grid--cell::text").get("").strip(), "company_type": response.css(".grid--cell:contains('Company type') + .grid--cell::text").get("").strip(), "preferred_timezone": response.css(".grid--cell:contains('Time zones') + .grid--cell::text").get("").strip(), "tech_stack": [tag.strip() for tag in response.css(".js-tag a.post-tag::text").getall()] }
避坑提示
- 不要用自定义的内存列表
already_visited_links做请求去重,Scrapy本身自带基于请求指纹的去重机制,自定义列表重启进程就会清空,反而会造成重复请求或漏抓 - 不要为了这个站点加装Splash、Playwright等JS渲染组件,Stack Overflow Jobs的核心职位信息是服务端直接渲染的,JS渲染层的请求频率限制更严格,反而更容易触发反爬
- 如果遇到偶发的反爬占位页面,只需要在详情页解析方法中增加判断:如果提取不到职位名称,就重新发起一次请求即可,不需要调整整体架构
内容的提问来源于stack exchange,提问作者Join_Where
相关产品推荐
相关产品推荐

