Scrapy爬虫抓取GIS数据总被封?反反爬策略与代理池实战(附:完整代码)

编程与开发
Dr.GIS
wowwwai GIS研习社 · 工具流程与项目排障

《Scrapy爬虫抓取GIS数据总被封?反反爬策略与代理池实战(附:完整代码)》这篇文章,面向需要批量采集公开GIS数据目录、元数据、POI页面或瓦片索引信息的同学。很多GIS工程师第一次用 Scrapy 跑采集任务时,会遇到请求变慢、403、429、验证码、IP被封、数据抓不全等问题。本文不讨论绕过登录、破解验证码或采集受限数据,而是讲清楚如何在合法合规的前提下,让 Scrapy 爬虫更稳定地访问公开GIS数据页面,并用代理池、限速、重试和请求头策略降低被误封的概率。

Scrapy爬虫抓取GIS数据与代理池反反爬流程示意图
Scrapy采集公开GIS数据时,建议把限速、请求头、重试、代理池和数据入库作为一个完整流程来设计。

引言:为什么Scrapy爬虫抓取GIS数据容易被封

GIS数据采集和普通网页采集有一个明显区别:很多目标页面并不是单纯的HTML,而是包含地图服务接口、分页接口、GeoJSON接口、ArcGIS REST服务、WMS/WFS服务或瓦片请求。请求数量一多,就很容易触发服务器的频率限制。

常见场景包括:

  • 批量抓取政府开放数据平台中的数据集列表和元数据。
  • 采集 ArcGIS REST Services Directory 中的图层信息、字段结构、范围和服务地址。
  • 抓取公开POI页面、行政区划页面或地名查询结果。
  • 批量请求 GeoJSON、WFS、CSV 或分页API。
  • 采集地图瓦片索引或服务能力信息,用于数据质量检查。

如果直接使用默认 Scrapy 配置,短时间内大量并发请求会让服务器认为这是异常流量。结果就是 Scrapy爬虫抓取GIS数据总被封,表现为403、429、连接超时、返回空页面、重定向到验证页,或者同一段时间内数据大量缺失。

背景:GIS数据采集常见被封表现

在排查 Scrapy 爬虫被封问题前,先要判断自己到底遇到的是哪一类限制。不同错误对应的解决方法不一样,不能一上来就盲目换代理。

1. HTTP 403 Forbidden

403通常表示服务器拒绝访问。原因可能是请求头太像程序、Referer缺失、User-Agent异常、访问路径不允许,或者IP已经进入限制名单。

2. HTTP 429 Too Many Requests

429通常表示请求频率过高。GIS数据平台的分页接口、ArcGIS REST查询接口和地图服务接口很容易出现这个状态码。此时优先要降速,而不是继续加代理硬冲。

3. 返回HTML验证页而不是数据

有些站点不会直接返回403,而是返回一个验证页面。对Scrapy来说,响应状态码可能仍然是200,但内容已经不是JSON、GeoJSON或目标HTML。

4. 数据不完整但没有报错

这是GIS采集中最容易被忽略的问题。比如你以为抓到了5000条POI,实际上由于接口限流,只返回了前几页;或者某些图层服务返回了空features。必须用数量校验、字段校验和空间范围校验来确认结果。

原理:反反爬不是“绕过限制”,而是模拟合理访问

本文所说的反反爬策略,重点不是突破网站安全机制,而是让 Scrapy 在公开数据允许访问的范围内更加稳定、礼貌、可控。特别是GIS数据采集,很多目标站点属于公共服务平台,更应该控制请求频率,避免影响服务。

一个稳定的 Scrapy GIS采集程序,通常要同时做好以下几件事:

  • 限速:控制并发和请求间隔,避免瞬时请求过高。
  • 请求头:补充合理的 User-Agent、Accept、Referer 等字段。
  • 重试:对超时、429、502、503等临时错误进行有限重试。
  • 代理池:在合规前提下使用稳定代理,降低单IP被频率限制的风险。
  • 日志:记录状态码、失败URL、代理IP、重试次数,方便回溯。
  • 数据校验:用记录数、字段、坐标范围、几何类型检查抓取结果。

重要提醒:采集前请阅读目标网站的robots.txt、服务条款、开放数据授权说明和接口调用限制。不要采集需要登录、付费、受版权保护或明确禁止自动化访问的数据。

步骤:Scrapy爬虫抓取GIS数据的完整代码示例

下面用一个通用示例演示:从公开GIS数据目录页面读取数据集列表,进入详情页解析名称、简介、数据格式、下载链接和空间范围字段。你可以把解析规则替换成自己的目标站点。

1. 创建Scrapy项目

pip install scrapy

scrapy startproject gis_spider
cd gis_spider
scrapy genspider open_data example.com

项目结构大致如下:

gis_spider/
  scrapy.cfg
  gis_spider/
    __init__.py
    items.py
    middlewares.py
    pipelines.py
    settings.py
    spiders/
      open_data.py

2. 定义GIS数据字段

items.py 中定义需要保存的字段。GIS元数据建议至少保留名称、来源URL、数据格式、坐标系、空间范围和下载地址。

import scrapy


class GisDatasetItem(scrapy.Item):
    title = scrapy.Field()
    source_url = scrapy.Field()
    description = scrapy.Field()
    data_format = scrapy.Field()
    crs = scrapy.Field()
    bbox = scrapy.Field()
    download_url = scrapy.Field()
    service_url = scrapy.Field()

3. 编写爬虫解析逻辑

下面代码演示分页列表页和详情页解析。实际使用时,需要把CSS选择器替换成目标GIS开放数据网站的页面结构。

import scrapy
from urllib.parse import urljoin
from gis_spider.items import GisDatasetItem


class OpenDataSpider(scrapy.Spider):
    name = "open_data"
    allowed_domains = ["example.com"]
    start_urls = [
        "https://example.com/opendata/catalog?page=1"
    ]

    custom_settings = {
        "DOWNLOAD_DELAY": 2,
        "CONCURRENT_REQUESTS": 4,
        "CONCURRENT_REQUESTS_PER_DOMAIN": 2
    }

    def parse(self, response):
        self.logger.info("List page: %s status=%s", response.url, response.status)

        detail_links = response.css("a.dataset-title::attr(href)").getall()
        for href in detail_links:
            detail_url = urljoin(response.url, href)
            yield scrapy.Request(
                detail_url,
                callback=self.parse_detail,
                meta={"source_list": response.url}
            )

        next_page = response.css("a.next::attr(href)").get()
        if next_page:
            yield scrapy.Request(
                urljoin(response.url, next_page),
                callback=self.parse
            )

    def parse_detail(self, response):
        item = GisDatasetItem()
        item["title"] = response.css("h2::text").get(default="").strip()
        item["source_url"] = response.url
        item["description"] = " ".join(
            text.strip() for text in response.css(".dataset-description *::text").getall()
            if text.strip()
        )
        item["data_format"] = response.css(".format::text").get(default="").strip()
        item["crs"] = response.css(".crs::text").get(default="").strip()
        item["bbox"] = response.css(".bbox::text").get(default="").strip()
        item["download_url"] = response.css("a.download::attr(href)").get()
        item["service_url"] = response.css("a.service-url::attr(href)").get()

        if item["download_url"]:
            item["download_url"] = urljoin(response.url, item["download_url"])

        yield item

4. 配置限速和自动重试

settings.py 中加入以下配置。对于GIS数据平台,建议先低并发测试,不要一开始就开很高。

BOT_NAME = "gis_spider"

ROBOTSTXT_OBEY = True

CONCURRENT_REQUESTS = 4
CONCURRENT_REQUESTS_PER_DOMAIN = 2
DOWNLOAD_DELAY = 2
RANDOMIZE_DOWNLOAD_DELAY = True

COOKIES_ENABLED = False

DOWNLOAD_TIMEOUT = 30

RETRY_ENABLED = True
RETRY_TIMES = 3
RETRY_HTTP_CODES = [408, 429, 500, 502, 503, 504, 522, 524]

AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_START_DELAY = 2
AUTOTHROTTLE_MAX_DELAY = 20
AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0
AUTOTHROTTLE_DEBUG = False

LOG_LEVEL = "INFO"

FEEDS = {
    "output/gis_datasets.jsonl": {
        "format": "jsonlines",
        "encoding": "utf8",
        "overwrite": True
    }
}

这里的关键不是“跑得越快越好”,而是让请求像正常用户浏览一样平滑。对于容易被封的GIS站点,AUTOTHROTTLE_ENABLED 往往比盲目使用大量代理更有效。

5. 添加请求头中间件

middlewares.py 中添加一个简单的请求头中间件。它可以随机切换常见浏览器User-Agent,并补充Accept、Accept-Language等字段。

import random


class RandomHeaderMiddleware:
    USER_AGENTS = [
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/121.0 Safari/537.36",
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/605.1.15 "
        "(KHTML, like Gecko) Version/16.6 Safari/605.1.15",
        "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/120.0 Safari/537.36"
    ]

    def process_request(self, request, spider):
        request.headers.setdefault("User-Agent", random.choice(self.USER_AGENTS))
        request.headers.setdefault("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,application/json;q=0.8,*/*;q=0.7")
        request.headers.setdefault("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
        request.headers.setdefault("Connection", "keep-alive")

然后在 settings.py 启用它:

DOWNLOADER_MIDDLEWARES = {
    "gis_spider.middlewares.RandomHeaderMiddleware": 400,
}

6. 准备代理池文件

如果目标网站明确允许合理自动化访问,但单IP仍然经常触发频率限制,可以使用代理池。这里用一个简单的本地文件作为代理池示例,每行一个代理。

http://user:password@1.2.3.4:8000
http://user:password@5.6.7.8:8000
http://user:password@9.10.11.12:8000

保存为项目根目录下的 proxies.txt。实际项目中请使用稳定、合规、可追踪的代理服务,不建议使用来源不明的免费代理。

7. 编写代理池中间件

继续在 middlewares.py 中加入代理中间件。它会为每个请求随机分配代理,并在日志中记录当前代理。

import random
from pathlib import Path


class ProxyPoolMiddleware:
    def __init__(self, proxy_file):
        self.proxy_file = proxy_file
        self.proxies = self.load_proxies(proxy_file)

    @classmethod
    def from_crawler(cls, crawler):
        proxy_file = crawler.settings.get("PROXY_FILE", "proxies.txt")
        return cls(proxy_file)

    def load_proxies(self, proxy_file):
        path = Path(proxy_file)
        if not path.exists():
            return []

        proxies = []
        for line in path.read_text(encoding="utf8").splitlines():
            line = line.strip()
            if line and not line.startswith("#"):
                proxies.append(line)
        return proxies

    def process_request(self, request, spider):
        if not self.proxies:
            return None

        proxy = random.choice(self.proxies)
        request.meta["proxy"] = proxy
        request.meta["proxy_used"] = proxy
        spider.logger.debug("Use proxy: %s for %s", proxy, request.url)
        return None

settings.py 中启用代理中间件:

PROXY_FILE = "proxies.txt"

DOWNLOADER_MIDDLEWARES = {
    "gis_spider.middlewares.RandomHeaderMiddleware": 400,
    "gis_spider.middlewares.ProxyPoolMiddleware": 410,
    "scrapy.downloadermiddlewares.retry.RetryMiddleware": 550,
}

8. 对403和429做更清晰的日志记录

当Scrapy爬虫抓取GIS数据总被封时,日志比猜测更重要。建议增加一个响应检查中间件,专门记录疑似封禁的状态码。

class BlockStatusLoggerMiddleware:
    BLOCK_CODES = {403, 429}

    def process_response(self, request, response, spider):
        if response.status in self.BLOCK_CODES:
            spider.logger.warning(
                "Possible block: status=%s url=%s proxy=%s",
                response.status,
                response.url,
                request.meta.get("proxy_used", "")
            )
        return response

启用方式如下:

DOWNLOADER_MIDDLEWARES = {
    "gis_spider.middlewares.RandomHeaderMiddleware": 400,
    "gis_spider.middlewares.ProxyPoolMiddleware": 410,
    "gis_spider.middlewares.BlockStatusLoggerMiddleware": 420,
    "scrapy.downloadermiddlewares.retry.RetryMiddleware": 550,
}

9. 输出为GeoJSON或写入PostGIS前的校验

很多GIS采集任务最终不是只要一个HTML字段,而是要进入GeoJSON、PostGIS或空间数据库。即使当前示例只采集元数据,也建议先做字段完整性检查。

pipelines.py 中加入基础校验:

class GisDatasetValidationPipeline:
    def process_item(self, item, spider):
        if not item.get("title"):
            spider.logger.warning("Missing title: %s", item.get("source_url"))

        if not item.get("download_url") and not item.get("service_url"):
            spider.logger.warning("Missing data link: %s", item.get("source_url"))

        bbox = item.get("bbox")
        if bbox and len(bbox) < 5:
            spider.logger.warning("Suspicious bbox: %s url=%s", bbox, item.get("source_url"))

        return item

settings.py 中启用:

ITEM_PIPELINES = {
    "gis_spider.pipelines.GisDatasetValidationPipeline": 300,
}

10. 运行爬虫

scrapy crawl open_data

运行后检查 output/gis_datasets.jsonl,重点看三类问题:

  • 记录数量是否和目标网站分页数量大致一致。
  • 是否有大量 download_urlservice_url 为空。
  • 日志里是否频繁出现403、429、超时和重试。

常见坑:Scrapy代理池实战中最容易踩的坑

1. 只换代理,不控制并发

这是最常见的错误。代理池不是万能药。如果并发过高、请求太密集,即使用代理也可能很快触发限制。GIS公开数据平台通常资源有限,应该优先使用 DOWNLOAD_DELAYCONCURRENT_REQUESTS_PER_DOMAINAutoThrottle

2. 把地图瓦片当成普通页面暴力抓取

瓦片服务请求量极大,不能随意全量抓取。很多瓦片服务都有明确的使用限制。若只是做数据分析,应优先寻找官方下载数据、WFS、FeatureServer、GeoJSON或开放数据包,而不是抓瓦片。

3. 没有识别“假200”

有些网站被限制后仍返回状态码200,但内容变成验证页、错误页或空JSON。可以在解析前检查响应内容特征,例如是否包含目标字段、是否是合法JSON、features数量是否异常。

def is_valid_geojson(data):
    return (
        isinstance(data, dict)
        and data.get("type") in ["FeatureCollection", "Feature"]
    )

4. 免费代理导致数据污染

来源不明的免费代理可能不稳定、速度慢、被大量站点封禁,甚至篡改响应内容。GIS数据采集重视准确性,不建议把免费代理用于正式数据生产。

5. 没有保存失败URL

如果爬虫跑完才发现缺数据,但没有失败URL记录,就很难补采。建议把失败URL、状态码、代理、时间写入日志或单独文件,方便后续小批量重跑。

方法比较:限速、请求头、代理池和官方API怎么选

方法 适用场景 优点 限制
降低并发与AutoThrottle 公开GIS目录、分页页面、元数据采集 稳定、合规、实现简单 采集速度较慢
随机请求头 默认Scrapy请求被拒绝、缺少浏览器字段 能减少误判为异常客户端 不能解决高频访问问题
代理池 单IP频率限制明显、目标允许合理访问 降低单IP压力,适合长任务 成本高,需要监控质量
官方API或批量下载 开放数据平台提供下载包、WFS、ArcGIS REST接口 数据结构清晰,稳定性最好 可能有调用限制或字段不完整
浏览器自动化 页面强依赖JavaScript渲染 能处理动态页面 慢、资源消耗大,不适合大规模任务

对GIS读者来说,优先级建议是:官方下载数据优先,其次官方API,再考虑Scrapy解析页面。只有在确实需要网页元数据、且访问规则允许的情况下,才使用代理池和反反爬策略。

检查清单:上线前确认这些配置

  • 是否确认目标数据是公开数据,并允许自动化访问或批量下载。
  • 是否开启 ROBOTSTXT_OBEY,并阅读服务条款。
  • 是否设置合理的 DOWNLOAD_DELAY 和并发数。
  • 是否开启 RETRY_ENABLED,但限制了重试次数。
  • 是否记录403、429、超时、失败URL和代理信息。
  • 是否检查返回内容不是验证页或错误页。
  • 是否对数据量、字段完整性、坐标范围和下载链接做校验。
  • 是否避免抓取海量地图瓦片。
  • 是否准备断点续采或失败URL补采方案。
  • 是否能在小样本测试通过后再扩大采集范围。

FAQ:Scrapy爬虫抓取GIS数据被封的常见问题

Q1:Scrapy爬虫抓取GIS数据总被封,是不是必须使用代理池?

不一定。很多情况下,被封的根本原因是并发太高、请求太密集或请求头不完整。建议先降低并发、开启AutoThrottle、检查robots.txt和接口限制,再考虑代理池。

Q2:遇到429应该怎么处理?

429表示请求过多。最直接的处理方式是增加 DOWNLOAD_DELAY,降低 CONCURRENT_REQUESTS_PER_DOMAIN,开启 AUTOTHROTTLE_ENABLED,并对429做有限重试。不要在429出现后继续高频请求。

Q3:GIS数据采集可以直接抓地图瓦片吗?

不建议。瓦片请求量巨大,容易影响服务,也可能违反使用条款。如果目标是分析数据,应优先寻找矢量下载、WFS、FeatureServer、GeoJSON、Shapefile或GeoPackage等数据源。

Q4:代理池里的代理越多越好吗?

不是。代理质量比数量更重要。低质量代理会导致超时、丢包、响应被篡改或频繁失败。正式GIS数据采集更应该选择稳定代理,并记录每条数据的来源URL和采集日志。

Q5:如何判断抓到的GIS数据是否完整?

可以从四个方面检查:记录总数是否符合分页数量,关键字段是否为空,坐标范围是否落在目标区域,几何类型是否符合预期。如果是ArcGIS REST或GeoJSON,还要检查features数量和服务返回的统计信息。

Q6:动态加载的GIS开放数据页面,Scrapy抓不到内容怎么办?

先用浏览器开发者工具查看Network请求,找到真实的JSON、GeoJSON或API接口。很多动态页面只是前端渲染,真实数据接口比HTML更适合采集。只有接口无法直接使用时,才考虑Playwright或Selenium。

结论:稳定采集公开GIS数据,比盲目提速更重要

Scrapy爬虫抓取GIS数据总被封,通常不是单一原因造成的。真正可靠的方案,是把合规访问、限速、请求头、重试、代理池、日志和数据校验组合起来。

对于GIS项目,采集速度不是唯一目标,数据完整性和可追溯性更重要。建议先用小样本验证解析规则和字段质量,再逐步扩大范围;优先使用官方API和开放下载;只有在合理访问仍受单IP限制时,再引入代理池。这样才能让Scrapy在GIS数据采集中既稳定,又不影响目标服务。