You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何使用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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.10.23 15:06:04