云计算百科
云计算领域专业知识百科平台

Python 实战:动态渔业资源分布图采集——基于 OBIS 时间轴图层的 API 采集、清洗、聚合与导出

㊗️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~ ㊙️本期爬虫难度指数:⭐⭐⭐⭐☆(高级) 🉐福利: 一次订阅后,专栏内的所有文章可永久免费看,持续更新中,保底1000+(篇)硬核实战内容。

全文目录:

    • 🌟 开篇语
    • 0️⃣ 前言(Preface)
    • 1️⃣ 摘要(Abstract)
    • 2️⃣ 背景与需求(Why)
      • 2.1 为什么要采集动态渔业资源分布图
      • 2.2 本文目标站点与采集对象
      • 2.3 目标字段清单
    • 3️⃣ 合规与注意事项(必写)
      • 3.1 robots.txt 基本说明
      • 3.2 频率控制
      • 3.3 不采集敏感信息
      • 3.4 数据解释要谨慎
    • 4️⃣ 技术选型与整体流程(What/How)
      • 4.1 静态、动态、API 三类采集方式
      • 4.2 动态图层采集的核心思路
      • 4.3 整体流程
      • 4.4 为什么选 requests,而不是一上来就 Playwright
    • 5️⃣ 环境准备与依赖安装(可复现)
      • 5.1 Python 版本
      • 5.2 创建虚拟环境
      • 5.3 安装依赖
      • 5.4 推荐项目结构
      • 5.5 配置文件
    • 6️⃣ 核心实现:请求层(Fetcher)
      • 6.1 headers 设计
      • 6.2 timeout
      • 6.3 session / cookie
      • 6.4 失败处理:重试与退避
      • 6.5 代码:`config.py`
      • 6.6 代码:`fetcher.py`
    • 7️⃣ 核心实现:解析层(Parser)
      • 7.1 解析方式
      • 7.2 列表页如何拿详情链接
      • 7.3 详情页如何抽字段
      • 7.4 缺失字段怎么办
      • 7.5 代码:`time_utils.py`
      • 7.6 代码:`parser.py`
    • 8️⃣ 数据存储与导出(Storage)
      • 8.1 起步选择 SQLite
      • 8.2 字段映射表
      • 8.3 去重策略
      • 8.4 代码:`storage.py`
    • 9️⃣ 运行方式与结果展示(必写)
      • 9.1 入口文件:`run_spider.py`
      • 9.2 导出 GeoJSON:`exporter.py`
      • 9.3 生成时间轴地图:`map_builder.py`
      • 9.4 启动命令
      • 9.5 输出路径
      • 9.6 示例结果
      • 9.7 查看 SQLite
    • 🔟 常见问题与排错(强烈建议写)
      • 10.1 403 怎么办
      • 10.2 429 怎么办
      • 10.3 HTML 抓到空壳怎么办
      • 10.4 解析报错怎么办
      • 10.5 编码/乱码如何处理
      • 10.6 数据为空怎么办
      • 10.7 时间轴不动怎么办
    • 1️⃣1️⃣ 进阶优化(可选但加分)
      • 11.1 并发优化
      • 11.2 断点续跑
      • 11.3 日志与监控
      • 11.4 定时任务
      • 11.5 空间网格升级
      • 11.6 强度指标升级
      • 11.7 多物种批量采集
      • 11.8 与 Scrapy 集成
    • 1️⃣2️⃣ 总结与延伸阅读
    • 🌟 文末
      • ✅ 专栏持续更新中|建议收藏 + 订阅
      • ✅ 互动征集
      • ✅ 免责声明

🌟 开篇语

哈喽,各位小伙伴们你们好呀~我是【喵手】。 运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO 欢迎大家常来逛逛,一起学习,一起进步~🌟

  我长期专注 Python 爬虫工程化实战,主理专栏👉 《Python爬虫实战》:从采集策略到反爬对抗,从数据清洗到分布式调度,持续输出可复用的方法论与可落地案例。内容主打一个“能跑、能用、能扩展”,让数据价值真正做到——抓得到、洗得净、用得上。

  📌 专栏食用指南(建议收藏)

  • ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
  • ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
  • ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
  • ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用

📣 专栏推广时间:如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅专栏👉《Python爬虫实战》👈,一次订阅后,专栏内的所有文章可永久免费阅读,持续更新中。    💕订阅后更新会优先推送,按目录学习更高效💯~

0️⃣ 前言(Preface)

这篇文章要做的事情很明确:用 Python 采集公开海洋生物分布数据,围绕“物种/资源名、海域、时间、强度指标、来源”这几个字段,把动态渔业资源分布图背后的时间轴数据抓取出来,并最终导出为 CSV、SQLite、GeoJSON 和一个可播放时间轴的 HTML 地图。

读完这篇文章,你至少能拿到三样东西。

第一,你会知道动态地图类网站的数据到底藏在哪里。很多地图页面看上去是动画、图层、时间轴,本质上往往是前端按时间、范围、物种条件请求接口,再把返回的点、网格或瓦片渲染到地图上。真正要采集的不是页面上那张“会动的图”,而是图层背后的接口参数和响应数据。

第二,你会得到一套可运行的 Python 项目骨架。它包含请求层、解析层、清洗层、存储层、导出层和可视化层,不是只贴一个半截脚本就结束。代码会围绕 OBIS 公开 API 设计,默认用月度时间窗口采集指定物种在指定海域内的 occurrence 记录,再聚合成网格强度。

第三,你会掌握动态时间轴采集的基本套路:先用浏览器开发者工具观察接口,再把时间轴拆成一个个时间片,接着按时间片分页抓取,最后把原始点数据转换成“某物种在某海域、某月份、某网格的出现强度”。

我个人很喜欢这一类案例,因为它不像普通列表页采集那样只盯着标题、链接和正文,而是要处理地理范围、时间范围、图层聚合、分页、容错和导出。它更接近真实数据工程里的采集任务,也更能看出一个爬虫项目是不是有基本的工程意识。


1️⃣ 摘要(Abstract)

本文使用 Python、requests、pandas、SQLite、GeoJSON 与 Folium,采集公开海洋生物分布接口中的物种出现记录,并按月份和空间网格聚合为动态渔业资源分布图数据,最终产出可分析的表格数据和可播放时间轴的 HTML 地图。

读完本文可以获得:

  • 一套面向动态地图图层的采集思路,重点是时间轴、空间范围、分页和图层数据解析。
  • 一个完整的 Python 示例项目,包含 Fetcher、Parser、Storage、Exporter 和 Map Builder。
  • 一份字段规范,覆盖物种/资源名、海域、时间、强度指标、来源、经纬度、网格编号、去重键等核心字段。
  • 本文选择公开、无需登录的数据接口做演示,不讨论绕过登录、破解付费数据、规避访问限制等内容。技术分享的重点是合规采集、结构化整理和可复现分析。


    2️⃣ 背景与需求(Why)

    2.1 为什么要采集动态渔业资源分布图

    渔业资源、海洋生物出现记录、海域生态观测数据,通常不是一张静态表就能讲清楚的。它们天然带有三个维度:

    • 空间:资源出现在哪里,靠近哪个海域,分布密度如何;
    • 时间:某个资源在哪些月份更集中,是否有季节性变化;
    • 强度:某个区域内记录数量、观测频率、相对密度或其他指标高不高。

    很多公开平台会把这些数据做成地图图层,让用户通过筛选条件查看分布结果。有的页面还有时间滑块,拖动滑块后,地图上的点、热力图或网格颜色会发生变化。这种页面对普通用户很友好,但对数据分析来说不够方便,因为分析人员通常需要把图层背后的数据落成表格。

    比如,我们可能会问:

    • 某个鱼类资源在某片海域近几年记录最密集的月份是哪几个月?
    • 资源分布是否呈现向某个方向移动的趋势?
    • 哪些网格区域长期保持较高出现强度?
    • 不同物种在同一海域的时空分布是否有明显差异?
    • 是否能把公开记录整理成后续建模、看板或预警系统的数据源?

    这些问题都需要结构化数据。光看地图截图不够,必须把图层数据采集下来,清洗、聚合、存储、导出。

    2.2 本文目标站点与采集对象

    本文以公开海洋生物记录接口作为案例,目标不是采集某个商业系统,也不是爬取受限数据。我们选择的思路是:

    • 目标平台:公开海洋生物分布数据平台;
    • 目标接口:occurrence 查询接口;
    • 采集对象:指定物种在指定海域、指定时间范围内的出现记录;
    • 时间粒度:按月拆分;
    • 空间粒度:按经纬度网格聚合;
    • 输出形式:CSV、SQLite、GeoJSON、HTML 动态时间轴地图。

    本文设定一个示例任务:

    采集 Gadus morhua 这个物种在北大西洋部分海域中,2020 年 1 月到 2020 年 12 月之间的出现记录,并按 1 度经纬度网格聚合为月度强度指标。

    这里的 Gadus morhua 是大西洋鳕的学名。实际项目里,你可以把它替换成其他物种名,也可以把一个物种扩展成多个物种批量采集。

    2.3 目标字段清单

    本文最终希望拿到如下字段。

    核心业务字段:

    字段含义示例
    species_name 物种/资源名 Gadus morhua
    sea_area 海域 North Atlantic Demo Area
    time_window 时间 2020-01
    intensity_index 强度指标 18
    source 来源 OBIS API

    空间字段:

    字段含义示例
    longitude 经度 -63.25
    latitude 纬度 44.71
    grid_id 网格编号 lon_-64_lat_44
    grid_lon 网格左下角经度 -64
    grid_lat 网格左下角纬度 44

    溯源字段:

    字段含义示例
    occurrence_id 原始记录 ID 公开接口返回的记录标识
    event_date 原始观测时间 2020-03-12
    dataset_id 数据集 ID 数据提供方相关标识
    basis_of_record 记录类型 HumanObservation
    fetched_at 采集时间 2026-06-12T10:30:00Z
    request_url 请求 URL https://api.example/…
    content_hash 内容 hash sha256…

    强度指标在本文中定义为:同一物种、同一海域、同一月份、同一网格内的有效 occurrence 记录数量。这个指标不是官方资源量估计,也不是渔获量,只是一个基于公开记录的相对出现强度。这个定义必须写清楚,否则容易被误解为“真实资源储量”。


    3️⃣ 合规与注意事项(必写)

    爬虫技术本身没有问题,但使用方式必须克制。尤其是地图图层、时空数据、科学数据平台,很多由公共机构、研究组织或志愿者共同维护,访问时要尊重对方服务能力和数据政策。

    3.1 robots.txt 基本说明

    robots.txt 是网站放在根目录下的一个文本文件,用来声明哪些路径允许或不建议被自动化程序访问。它不是法律合同,也不是安全边界,但它是最基本的网络礼仪。

    在正式采集前,建议做三件事:

  • 访问目标站点根目录下的 robots.txt;
  • 查看目标接口路径是否被禁止;
  • 如果文档明确提供 API,优先使用 API,而不是暴力抓页面。
  • 示例检查方式:

    import requests

    def check_robots(base_url: str) > None:
    url = base_url.rstrip("/") + "/robots.txt"
    resp = requests.get(url, timeout=15)
    print(resp.status_code)
    print(resp.text[:2000])

    if __name__ == "__main__":
    check_robots("https://api.obis.org")

    如果 robots.txt 或官方文档给出更具体的访问说明,应以官方说明为准。没有看到限制,不代表可以无限制并发;看到公开接口,也不代表可以把对方服务当作自己的离线数据库来高频扫。

    3.2 频率控制

    动态地图接口最容易被误用。很多人打开开发者工具看到一个接口,就马上写循环,把时间、经纬度、物种、分页全部组合起来高并发请求。这样做很不专业,也容易给公共服务造成压力。

    本文代码采用温和策略:

    • 每次请求设置 timeout;
    • 使用 Session 复用连接;
    • 对失败请求做有限重试;
    • 对 429、503 等状态码采用退避等待;
    • 默认每次请求后 sleep 一小段时间;
    • 不使用攻击式并发;
    • 默认分页上限可配置,避免误采超大结果集。

    对于公开科学数据平台,建议先小样本验证逻辑,再扩大范围。如果需要大规模分析,应使用官方推荐的大数据导出方式,而不是用 API 慢慢翻页硬抓。

    3.3 不采集敏感信息

    本文采集的是公开海洋生物 occurrence 数据,不采集个人隐私、账号信息、后台数据、登录态信息,也不绕过付费墙或权限控制。

    如果目标网站需要登录才能访问,或者接口需要授权 token,应按照官方授权流程使用。不要通过抓包复制他人 token,不要模拟非公开接口,不要绕过访问限制。技术上能做到,不代表应该做。

    3.4 数据解释要谨慎

    公开 occurrence 数据通常是“记录过某物种在某处出现”,但它并不等价于真实资源量。某个网格记录多,可能是资源丰富,也可能只是采样更频繁。某个月记录少,可能是资源少,也可能是没有调查活动。

    所以本文的 intensity_index 只定义为记录数量聚合,不把它包装成真实储量、捕捞量或生态结论。数据工程可以整理材料,科学解释要交给更严谨的分析模型和领域知识。


    4️⃣ 技术选型与整体流程(What/How)

    4.1 静态、动态、API 三类采集方式

    常见网页采集可以粗略分成三类。

    第一类是静态页面。HTML 里已经包含目标数据,用 requests 抓页面,再用 BeautifulSoup、lxml、XPath 或 CSS 选择器解析即可。比如新闻标题列表、普通表格、公告详情页,大多属于这一类。

    第二类是动态页面。页面初始 HTML 只是一个空壳,真正的数据由 JavaScript 在浏览器里请求接口后渲染。地图图层、时间轴动画、无限滚动列表、前端看板,经常属于这一类。如果直接 requests 抓 HTML,通常只能看到一堆 script,看不到数据。

    第三类是公开 API。前端页面也是调用 API,但平台本身提供文档、参数说明和结构化响应。这种情况下应该优先使用 API,而不是模拟浏览器点来点去。API 通常更稳定,字段更规范,也更适合写成可维护的项目。

    本文属于“动态地图 + API 采集”。我们不是去解析地图画布上的像素,也不是从 HTML 里抠数据,而是识别地图背后的 occurrence 查询接口,用时间窗口和空间范围构造请求,再对响应 JSON 做解析和聚合。

    4.2 动态图层采集的核心思路

    动态地图采集的关键不是“地图”,而是“图层请求”。

    一般可以按这个流程排查:

  • 打开目标地图页面;
  • 打开浏览器开发者工具;
  • 切换到 Network 面板;
  • 在页面里选择物种、海域、时间范围;
  • 拖动时间轴或改变图层;
  • 观察新出现的 XHR、Fetch、MVT、GeoJSON、JSON 请求;
  • 找到携带 species、scientificname、startdate、enddate、geometry、bbox、tile、offset 等参数的请求;
  • 复制请求 URL,在浏览器或 Postman 中验证;
  • 用 Python requests 重放;
  • 将时间轴拆成多次请求。
  • 对于本文案例,我们把时间轴拆成月度窗口:

    2020-01-01 ~ 2020-01-31
    2020-02-01 ~ 2020-02-29
    2020-03-01 ~ 2020-03-31

    2020-12-01 ~ 2020-12-31

    每个窗口内按照分页参数抓取 occurrence 记录。拿到原始记录后,根据经纬度落到网格,再统计每个网格的记录数量。

    4.3 整体流程

    文字版流程如下:

    配置物种、海域 WKT、多个月份

    请求 occurrence API

    按 offset / size 分页

    解析 JSON records

    过滤缺失经纬度、缺失时间、无效记录

    写入 SQLite 原始表

    按 species + sea_area + month + grid 聚合

    导出 CSV / GeoJSON

    生成带时间轴的 HTML 地图

    更像工程项目的流程如下:

    采集 Fetcher
    – 负责请求接口
    – 负责 headers、timeout、retry、rate limit
    – 负责分页

    解析 Parser
    – 负责 JSON 字段映射
    – 负责时间格式、经纬度、物种名提取
    – 负责容错

    清洗 Cleaner
    – 去掉缺失坐标
    – 去掉异常经纬度
    – 统一月份
    – 生成 grid_id

    存储 Storage
    – 保存 raw_occurrences
    – 保存 resource_intensity
    – 保存 request_log
    – 去重

    导出 Exporter
    – 导出 CSV
    – 导出 GeoJSON
    – 生成时间轴 HTML 地图

    4.4 为什么选 requests,而不是一上来就 Playwright

    很多动态网页确实需要 Playwright 或 Selenium,但本文不优先选它们,原因很简单:如果已经定位到公开接口,直接请求接口更稳定、更轻量、更容易测试。

    Playwright 适合这些情况:

    • 页面加密很重,接口参数由前端运行时生成;
    • 必须执行 JavaScript 才能拿到 token;
    • 数据只存在浏览器渲染后的 DOM 中;
    • 需要截图、点击、滚动、模拟复杂交互。

    本文的目标是接口数据采集,所以选择:

    • requests:处理 HTTP 请求;
    • pandas:处理表格清洗和导出;
    • SQLite:本地结构化存储;
    • Folium:生成 Leaflet 地图;
    • tenacity:做重试和退避;
    • tqdm:展示进度;
    • python-dotenv:管理配置。

    这套组合不花哨,但非常实用。项目后续要升级到 Scrapy、asyncio 或 Airflow,也不会推倒重来。


    5️⃣ 环境准备与依赖安装(可复现)

    5.1 Python 版本

    建议使用 Python 3.10 或以上版本。本文示例在 Python 3.10+ 语法下编写,主要用到 dataclass、类型注解和 pathlib。

    查看版本:

    python –version

    建议输出类似:

    Python 3.10.13

    5.2 创建虚拟环境

    macOS / Linux:

    mkdir fisheries_dynamic_obis
    cd fisheries_dynamic_obis

    python -m venv .venv
    source .venv/bin/activate

    Windows PowerShell:

    mkdir fisheries_dynamic_obis
    cd fisheries_dynamic_obis

    python m venv .venv
    .venv\\Scripts\\Activate.ps1

    5.3 安装依赖

    创建 requirements.txt:

    requests==2.32.3
    pandas==2.2.2
    tenacity==8.5.0
    python-dotenv==1.0.1
    tqdm==4.66.5
    folium==0.17.0
    branca==0.7.2

    安装:

    pip install -r requirements.txt

    5.4 推荐项目结构

    fisheries_dynamic_obis/
    ├── README.md
    ├── requirements.txt
    ├── .env.example
    ├── data/
    │ ├── raw/
    │ ├── processed/
    │ └── maps/
    ├── logs/
    ├── fisheries_spider/
    │ ├── __init__.py
    │ ├── config.py
    │ ├── time_utils.py
    │ ├── fetcher.py
    │ ├── parser.py
    │ ├── storage.py
    │ ├── exporter.py
    │ └── map_builder.py
    └── run_spider.py

    这个结构不复杂,但足够清楚。fisheries_spider 放核心代码,data 放输出结果,logs 放运行日志,根目录的 run_spider.py 作为入口文件。

    5.5 配置文件

    创建 .env.example:

    OBIS_BASE_URL=https://api.obis.org/v3
    DEFAULT_TIMEOUT=30
    REQUEST_SLEEP_SECONDS=0.8
    PAGE_SIZE=500
    MAX_PAGES_PER_MONTH=20

    复制为 .env:

    cp .env.example .env

    Windows 下可以手动复制,或使用:

    copy .env.example .env


    6️⃣ 核心实现:请求层(Fetcher)

    请求层的职责是“稳定、克制、可追踪地把数据拿回来”。它不应该关心字段怎么解析,也不应该直接写数据库。请求层只处理 HTTP。

    6.1 headers 设计

    即使是公开 API,也建议设置清晰的 User-Agent。不要伪装成奇怪的浏览器,也不要写空 UA。比较稳妥的写法是说明项目用途和联系信息。如果是公司或团队项目,可以写团队邮箱;个人测试可以写项目名称。

    本文示例:

    HEADERS = {
    "User-Agent": "FisheriesDynamicMapCollector/1.0 (educational data collection; contact: example@example.com)",
    "Accept": "application/json,text/plain,*/*",
    "Referer": "https://mapper.obis.org/",
    }

    Referer 不是绕过限制的工具,只是告诉服务端请求来源场景。对于官方 API,不一定必须带 Referer;保留它主要是为了贴近地图图层接口的实际请求风格。

    6.2 timeout

    所有请求都必须设置 timeout。没有 timeout 的爬虫,迟早会卡死在某个网络抖动上。

    本文默认:

    timeout=30

    实际项目中可以分开设置连接超时和读取超时:

    timeout=(10, 30)

    6.3 session / cookie

    本文案例不需要登录 cookie。我们仍然使用 requests.Session(),原因是 Session 能复用连接,也方便统一 headers、重试策略和日志。

    如果目标 API 明确需要官方 token,应通过配置读取,不要把 token 写死在代码里。本文不演示绕过登录态,也不采集需要权限的数据。

    6.4 失败处理:重试与退避

    失败处理至少要考虑这些情况:

    • 连接超时;
    • 读取超时;
    • 502 / 503 / 504;
    • 429 访问过快;
    • JSON 解析失败;
    • 服务器短暂返回空结果。

    重试不是无限重试。本文使用最多 3 次重试,每次退避等待。遇到 429 时应该降低频率,而不是换代理硬冲。

    6.5 代码:config.py

    # fisheries_spider/config.py

    from __future__ import annotations

    import os
    from dataclasses import dataclass
    from pathlib import Path

    from dotenv import load_dotenv

    load_dotenv()

    BASE_DIR = Path(__file__).resolve().parents[1]
    DATA_DIR = BASE_DIR / "data"
    RAW_DIR = DATA_DIR / "raw"
    PROCESSED_DIR = DATA_DIR / "processed"
    MAP_DIR = DATA_DIR / "maps"
    LOG_DIR = BASE_DIR / "logs"

    for folder in [DATA_DIR, RAW_DIR, PROCESSED_DIR, MAP_DIR, LOG_DIR]:
    folder.mkdir(parents=True, exist_ok=True)

    @dataclass(frozen=True)
    class SpiderConfig:
    base_url: str = os.getenv("OBIS_BASE_URL", "https://api.obis.org/v3")
    timeout: int = int(os.getenv("DEFAULT_TIMEOUT", "30"))
    request_sleep_seconds: float = float(os.getenv("REQUEST_SLEEP_SECONDS", "0.8"))
    page_size: int = int(os.getenv("PAGE_SIZE", "500"))
    max_pages_per_month: int = int(os.getenv("MAX_PAGES_PER_MONTH", "20"))

    sqlite_path: Path = PROCESSED_DIR / "fisheries_dynamic.sqlite3"
    raw_csv_path: Path = PROCESSED_DIR / "raw_occurrences.csv"
    intensity_csv_path: Path = PROCESSED_DIR / "resource_intensity.csv"
    intensity_geojson_path: Path = PROCESSED_DIR / "resource_intensity.geojson"
    timeline_map_path: Path = MAP_DIR / "resource_timeline_map.html"

    DEFAULT_HEADERS = {
    "User-Agent": (
    "FisheriesDynamicMapCollector/1.0 "
    "(educational data collection; contact: example@example.com)"
    ),
    "Accept": "application/json,text/plain,*/*",
    "Referer": "https://mapper.obis.org/",
    }

    6.6 代码:fetcher.py

    # fisheries_spider/fetcher.py

    from __future__ import annotations

    import logging
    import time
    from typing import Any, Dict, Iterator, Optional
    from urllib.parse import urlencode

    import requests
    from requests.adapters import HTTPAdapter
    from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
    from urllib3.util.retry import Retry

    from fisheries_spider.config import DEFAULT_HEADERS, SpiderConfig

    logger = logging.getLogger(__name__)

    class FetchError(RuntimeError):
    """Raised when the fetcher fails after retries."""

    class ObisFetcher:
    """
    A conservative API fetcher for OBIS occurrence records.

    It is intentionally simple:
    – requests.Session for connection reuse
    – explicit headers
    – timeout
    – limited retries
    – sleep between requests
    – offset/size pagination
    """

    def __init__(self, config: SpiderConfig) > None:
    self.config = config
    self.session = requests.Session()
    self.session.headers.update(DEFAULT_HEADERS)
    self._mount_retries()

    def _mount_retries(self) > None:
    retry_strategy = Retry(
    total=2,
    connect=2,
    read=2,
    status=2,
    backoff_factor=1.2,
    status_forcelist=(429, 500, 502, 503, 504),
    allowed_methods=frozenset(["GET"]),
    raise_on_status=False,
    )
    adapter = HTTPAdapter(max_retries=retry_strategy)
    self.session.mount("https://", adapter)
    self.session.mount("http://", adapter)

    def build_occurrence_url(self) > str:
    return self.config.base_url.rstrip("/") + "/occurrence"

    @retry(
    retry=retry_if_exception_type((requests.RequestException, ValueError)),
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=1, max=10),
    reraise=True,
    )
    def get_json(self, url: str, params: Dict[str, Any]) > Dict[str, Any]:
    """
    Execute GET request and parse JSON.

    ValueError is included because response.json() raises it
    when the body is not valid JSON.
    """
    full_url = f"{url}?{urlencode(params, doseq=True)}"
    logger.info("GET %s", full_url)

    resp = self.session.get(url, params=params, timeout=self.config.timeout)

    if resp.status_code == 429:
    logger.warning("429 Too Many Requests. Slowing down before retry.")
    time.sleep(max(self.config.request_sleep_seconds * 5, 5))

    if resp.status_code >= 400:
    raise FetchError(f"HTTP {resp.status_code}: {resp.text[:300]}")

    data = resp.json()
    time.sleep(self.config.request_sleep_seconds)
    return data

    def iter_occurrences(
    self,
    scientific_name: str,
    start_date: str,
    end_date: str,
    geometry_wkt: str,
    fields: Optional[str] = None,
    ) > Iterator[Dict[str, Any]]:
    """
    Iterate occurrence records by offset pagination.

    Parameters:
    scientific_name: species/resource scientific name.
    start_date: YYYY-MM-DD.
    end_date: YYYY-MM-DD.
    geometry_wkt: WKT polygon.
    fields: optional comma-separated fields.
    """
    url = self.build_occurrence_url()
    offset = 0
    page_index = 0

    while True:
    if page_index >= self.config.max_pages_per_month:
    logger.warning(
    "Reached max_pages_per_month=%s for %s %s ~ %s",
    self.config.max_pages_per_month,
    scientific_name,
    start_date,
    end_date,
    )
    break

    params: Dict[str, Any] = {
    "scientificname": scientific_name,
    "startdate": start_date,
    "enddate": end_date,
    "geometry": geometry_wkt,
    "size": self.config.page_size,
    "offset": offset,
    }

    if fields:
    params["fields"] = fields

    payload = self.get_json(url, params=params)
    records = self._extract_results(payload)

    if not records:
    logger.info(
    "No more records for %s %s ~ %s at offset=%s",
    scientific_name,
    start_date,
    end_date,
    offset,
    )
    break

    for record in records:
    record["_request_url"] = f"{url}?{urlencode(params, doseq=True)}"
    yield record

    got = len(records)
    logger.info(
    "Fetched page=%s offset=%s size=%s got=%s",
    page_index,
    offset,
    self.config.page_size,
    got,
    )

    if got < self.config.page_size:
    break

    offset += self.config.page_size
    page_index += 1

    @staticmethod
    def _extract_results(payload: Any) > list[Dict[str, Any]]:
    """
    OBIS occurrence response is commonly a dict with a 'results' list.
    This function is defensive because API wrappers or endpoints can
    return slightly different shapes.
    """

    if isinstance(payload, list):
    return [x for x in payload if isinstance(x, dict)]

    if isinstance(payload, dict):
    for key in ("results", "features", "data"):
    value = payload.get(key)
    if isinstance(value, list):
    if key == "features":
    return [
    item.get("properties", item)
    for item in value
    if isinstance(item, dict)
    ]
    return [x for x in value if isinstance(x, dict)]

    return []

    请求层写到这里,基本能抗住小规模采集。注意,max_pages_per_month 是一个很有必要的保险丝。刚开始调试时,不要让程序在一个月里翻几百页。先拿小样本验证解析逻辑,再慢慢放大。


    7️⃣ 核心实现:解析层(Parser)

    解析层处理三件事:

  • 从 JSON 中抽取字段;
  • 对缺失字段做容错;
  • 将点记录转换成可聚合的结构。
  • 7.1 解析方式

    本文接口返回 JSON,所以解析方式是 JSON 字段映射,不需要 XPath、CSS 或 BeautifulSoup。

    不过动态地图采集不一定都是 JSON。有些地图图层可能返回:

    • GeoJSON;
    • MVT 矢量瓦片;
    • KML;
    • CSV;
    • 压缩后的二进制数据;
    • 前端自定义结构。

    本文为了可读性,使用 occurrence JSON。后续如果遇到 MVT,可以用 mapbox-vector-tile 解析;遇到 GeoJSON,则从 features[].properties 中拿属性字段,从 geometry.coordinates 中拿坐标。

    7.2 列表页如何拿详情链接

    本文不走传统列表页和详情页模式。动态地图数据通常没有“列表页详情链接”这个结构,它是直接返回记录集合。

    但为了对应普通采集项目里的“列表页 → 详情页”思路,可以这样理解:

    • 列表页:一次 occurrence 查询返回的 results;
    • 详情链接:每条记录中的 id 或 occurrenceID;
    • 详情页:如 API 支持 /occurrence/{id},可以按 ID 再查详情;
    • 本文策略:为了降低请求量,优先使用列表接口返回字段,不对每条记录再发详情请求。

    这是很重要的工程取舍。很多时候,列表接口已经包含分析所需字段。为了一个可有可无的字段给每条记录补详情,会让请求量暴涨,不值得。

    7.3 详情页如何抽字段

    本文默认不抓详情页。如果实际需要补充详情,可以写一个 get_occurrence_detail(occurrence_id),但要有缓存和去重,避免重复请求。

    常见字段映射如下:

    目标字段JSON 候选字段
    occurrence_id id / occurrenceID / record_id
    species_name scientificName / species / acceptedNameUsage
    event_date eventDate / date_mid / date_start
    longitude decimalLongitude / lon / longitude
    latitude decimalLatitude / lat / latitude
    dataset_id dataset_id / datasetID
    basis_of_record basisOfRecord
    source 固定为 OBIS API

    7.4 缺失字段怎么办

    数据采集不能假设每条记录都完美。字段缺失时按下面规则处理:

    • 缺失 occurrence_id:用内容 hash 作为去重补充;
    • 缺失 species_name:使用请求参数里的 species 兜底;
    • 缺失 event_date:尝试 date_mid / date_start;仍无则丢弃或标记 unknown;
    • 缺失经纬度:不能进入空间聚合;
    • 经纬度超范围:丢弃;
    • dataset_id 缺失:保留为空;
    • basis_of_record 缺失:保留为空;
    • source 固定写入采集来源。

    7.5 代码:time_utils.py

    # fisheries_spider/time_utils.py

    from __future__ import annotations

    import calendar
    from dataclasses import dataclass
    from datetime import date
    from typing import Iterator

    @dataclass(frozen=True)
    class MonthWindow:
    month: str
    start_date: str
    end_date: str

    def iter_month_windows(start_month: str, end_month: str) > Iterator[MonthWindow]:
    """
    Generate inclusive month windows.

    Example:
    start_month='2020-01'
    end_month='2020-03'

    Yields:
    2020-01-01 ~ 2020-01-31
    2020-02-01 ~ 2020-02-29
    2020-03-01 ~ 2020-03-31
    """
    sy, sm = map(int, start_month.split("-"))
    ey, em = map(int, end_month.split("-"))

    current_year = sy
    current_month = sm

    while (current_year, current_month) <= (ey, em):
    last_day = calendar.monthrange(current_year, current_month)[1]
    start = date(current_year, current_month, 1)
    end = date(current_year, current_month, last_day)

    month_text = f"{current_year:04d}{current_month:02d}"
    yield MonthWindow(
    month=month_text,
    start_date=start.isoformat(),
    end_date=end.isoformat(),
    )

    current_month += 1
    if current_month > 12:
    current_month = 1
    current_year += 1

    7.6 代码:parser.py

    # fisheries_spider/parser.py

    from __future__ import annotations

    import hashlib
    import json
    import math
    from dataclasses import asdict, dataclass
    from datetime import datetime, timezone
    from typing import Any, Dict, Optional

    @dataclass
    class RawOccurrence:
    occurrence_id: str
    species_name: str
    sea_area: str
    time_window: str
    event_date: Optional[str]
    longitude: float
    latitude: float
    dataset_id: Optional[str]
    basis_of_record: Optional[str]
    source: str
    request_url: str
    content_hash: str
    fetched_at: str

    @dataclass
    class IntensityRecord:
    species_name: str
    sea_area: str
    time_window: str
    grid_id: str
    grid_lon: int
    grid_lat: int
    intensity_index: int
    source: str

    def now_utc_iso() > str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat()

    def stable_hash(obj: Dict[str, Any]) > str:
    raw = json.dumps(obj, ensure_ascii=False, sort_keys=True, default=str)
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()

    def pick_first(record: Dict[str, Any], keys: list[str]) > Any:
    for key in keys:
    value = record.get(key)
    if value not in (None, ""):
    return value
    return None

    def parse_float(value: Any) > Optional[float]:
    if value in (None, ""):
    return None
    try:
    result = float(value)
    except (TypeError, ValueError):
    return None

    if math.isnan(result) or math.isinf(result):
    return None

    return result

    def valid_lon_lat(lon: float, lat: float) > bool:
    return 180 <= lon <= 180 and 90 <= lat <= 90

    def parse_event_date(record: Dict[str, Any]) > Optional[str]:
    """
    Prefer eventDate. Fall back to date_mid/date_start if present.

    date_mid/date_start may be unix timestamps in some datasets. This parser
    is intentionally conservative: it keeps readable date strings and ignores
    numeric timestamps unless you extend it.
    """
    value = pick_first(record, ["eventDate", "event_date", "date", "date_mid", "date_start"])
    if value is None:
    return None

    if isinstance(value, str):
    return value[:30]

    return str(value)

    def grid_id_from_lon_lat(lon: float, lat: float, grid_size: float = 1.0) > tuple[str, int, int]:
    """
    Convert lon/lat to a simple degree grid.

    For example:
    lon=-63.25, lat=44.71, grid_size=1
    -> grid_lon=-64, grid_lat=44

    This is simple and explainable. For production analysis, consider geohash,
    H3, S2, or official statistical areas.
    """
    grid_lon = math.floor(lon / grid_size) * int(grid_size)
    grid_lat = math.floor(lat / grid_size) * int(grid_size)

    grid_id = f"lon_{grid_lon}_lat_{grid_lat}"
    return grid_id, int(grid_lon), int(grid_lat)

    def parse_occurrence(
    record: Dict[str, Any],
    fallback_species: str,
    sea_area: str,
    time_window: str,
    source: str = "OBIS API",
    ) > Optional[RawOccurrence]:
    lon = parse_float(pick_first(record, ["decimalLongitude", "longitude", "lon", "x"]))
    lat = parse_float(pick_first(record, ["decimalLatitude", "latitude", "lat", "y"]))

    if lon is None or lat is None:
    return None

    if not valid_lon_lat(lon, lat):
    return None

    content_hash = stable_hash(record)
    occurrence_id = str(
    pick_first(record, ["id", "occurrenceID", "occurrence_id", "record_id"])
    or content_hash
    )

    species_name = str(
    pick_first(record, ["scientificName", "species", "acceptedNameUsage", "taxon"])
    or fallback_species
    )

    event_date = parse_event_date(record)

    dataset_id = pick_first(record, ["dataset_id", "datasetID", "resource_id"])
    basis_of_record = pick_first(record, ["basisOfRecord", "basis_of_record"])

    request_url = str(record.get("_request_url") or "")

    return RawOccurrence(
    occurrence_id=occurrence_id,
    species_name=species_name,
    sea_area=sea_area,
    time_window=time_window,
    event_date=event_date,
    longitude=lon,
    latitude=lat,
    dataset_id=str(dataset_id) if dataset_id is not None else None,
    basis_of_record=str(basis_of_record) if basis_of_record is not None else None,
    source=source,
    request_url=request_url,
    content_hash=content_hash,
    fetched_at=now_utc_iso(),
    )

    def raw_to_dict(item: RawOccurrence) > Dict[str, Any]:
    return asdict(item)

    def intensity_to_dict(item: IntensityRecord) > Dict[str, Any]:
    return asdict(item)

    这个解析器刻意写得保守。它不会因为某个字段缺失就让整个程序崩掉,也不会把没有经纬度的数据硬塞进空间聚合。动态地图采集最怕的不是“抓不到”,而是“抓到了脏数据还不知道”。


    8️⃣ 数据存储与导出(Storage)

    8.1 起步选择 SQLite

    本文选择 SQLite 作为本地存储。它的优点是:

    • 不需要部署数据库服务;
    • 一个文件就能保存结果;
    • 支持 SQL 查询;
    • 适合中小规模采集;
    • 后续迁移到 MySQL/PostgreSQL 也不难。

    如果数据量很大,或者需要多人共享,建议换 PostgreSQL + PostGIS。空间分析多的项目,PostGIS 会舒服很多。

    8.2 字段映射表

    原始表 raw_occurrences:

    字段名类型示例值
    occurrence_id TEXT PRIMARY KEY 00003cf7-f2fc
    species_name TEXT Gadus morhua
    sea_area TEXT North Atlantic Demo Area
    time_window TEXT 2020-03
    event_date TEXT 2020-03-12
    longitude REAL -63.25
    latitude REAL 44.71
    dataset_id TEXT dataset uuid
    basis_of_record TEXT HumanObservation
    source TEXT OBIS API
    request_url TEXT request url
    content_hash TEXT sha256
    fetched_at TEXT 2026-06-12T10:30:00+00:00

    聚合表 resource_intensity:

    字段名类型示例值
    species_name TEXT Gadus morhua
    sea_area TEXT North Atlantic Demo Area
    time_window TEXT 2020-03
    grid_id TEXT lon_-64_lat_44
    grid_lon INTEGER -64
    grid_lat INTEGER 44
    intensity_index INTEGER 18
    source TEXT OBIS API

    请求日志表 request_log:

    字段名类型示例值
    id INTEGER 1
    species_name TEXT Gadus morhua
    time_window TEXT 2020-03
    start_date TEXT 2020-03-01
    end_date TEXT 2020-03-31
    sea_area TEXT North Atlantic Demo Area
    records_saved INTEGER 426
    created_at TEXT 2026-06-12T10:30:00+00:00

    8.3 去重策略

    本文采用两层去重:

    第一层:occurrence_id 唯一。如果接口返回稳定 ID,就用它作为主键。

    第二层:content_hash。如果没有 ID,就对原始 JSON 做 sha256,生成一个稳定 hash,作为兜底 ID 或辅助去重字段。

    为什么不只用 URL 去重?因为动态 API 同一个 URL 可能返回多条记录,而一条记录也可能在不同时间窗口或不同查询条件里被命中。URL 去重适合页面级采集,不适合 occurrence 记录级采集。

    8.4 代码:storage.py

    # fisheries_spider/storage.py

    from __future__ import annotations

    import sqlite3
    from collections import Counter
    from pathlib import Path
    from typing import Iterable

    import pandas as pd

    from fisheries_spider.parser import (
    IntensityRecord,
    RawOccurrence,
    grid_id_from_lon_lat,
    intensity_to_dict,
    raw_to_dict,
    )

    class SQLiteStorage:
    def __init__(self, db_path: Path) > None:
    self.db_path = db_path
    self.conn = sqlite3.connect(str(db_path))
    self.conn.row_factory = sqlite3.Row
    self.init_db()

    def init_db(self) > None:
    cur = self.conn.cursor()

    cur.execute(
    """
    CREATE TABLE IF NOT EXISTS raw_occurrences (
    occurrence_id TEXT PRIMARY KEY,
    species_name TEXT NOT NULL,
    sea_area TEXT NOT NULL,
    time_window TEXT NOT NULL,
    event_date TEXT,
    longitude REAL NOT NULL,
    latitude REAL NOT NULL,
    dataset_id TEXT,
    basis_of_record TEXT,
    source TEXT NOT NULL,
    request_url TEXT,
    content_hash TEXT NOT NULL,
    fetched_at TEXT NOT NULL
    )
    """

    )

    cur.execute(
    """
    CREATE INDEX IF NOT EXISTS idx_raw_species_time
    ON raw_occurrences (species_name, time_window)
    """

    )

    cur.execute(
    """
    CREATE INDEX IF NOT EXISTS idx_raw_geo
    ON raw_occurrences (longitude, latitude)
    """

    )

    cur.execute(
    """
    CREATE TABLE IF NOT EXISTS resource_intensity (
    species_name TEXT NOT NULL,
    sea_area TEXT NOT NULL,
    time_window TEXT NOT NULL,
    grid_id TEXT NOT NULL,
    grid_lon INTEGER NOT NULL,
    grid_lat INTEGER NOT NULL,
    intensity_index INTEGER NOT NULL,
    source TEXT NOT NULL,
    PRIMARY KEY (species_name, sea_area, time_window, grid_id)
    )
    """

    )

    cur.execute(
    """
    CREATE TABLE IF NOT EXISTS request_log (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    species_name TEXT NOT NULL,
    time_window TEXT NOT NULL,
    start_date TEXT NOT NULL,
    end_date TEXT NOT NULL,
    sea_area TEXT NOT NULL,
    records_saved INTEGER NOT NULL,
    created_at TEXT NOT NULL
    )
    """

    )

    self.conn.commit()

    def insert_raw_occurrences(self, rows: Iterable[RawOccurrence]) > int:
    sql = """
    INSERT OR IGNORE INTO raw_occurrences (
    occurrence_id,
    species_name,
    sea_area,
    time_window,
    event_date,
    longitude,
    latitude,
    dataset_id,
    basis_of_record,
    source,
    request_url,
    content_hash,
    fetched_at
    ) VALUES (
    :occurrence_id,
    :species_name,
    :sea_area,
    :time_window,
    :event_date,
    :longitude,
    :latitude,
    :dataset_id,
    :basis_of_record,
    :source,
    :request_url,
    :content_hash,
    :fetched_at
    )
    """

    items = [raw_to_dict(row) for row in rows]
    if not items:
    return 0

    before = self.conn.total_changes
    self.conn.executemany(sql, items)
    self.conn.commit()
    after = self.conn.total_changes
    return after before

    def log_request(
    self,
    species_name: str,
    time_window: str,
    start_date: str,
    end_date: str,
    sea_area: str,
    records_saved: int,
    created_at: str,
    ) > None:
    self.conn.execute(
    """
    INSERT INTO request_log (
    species_name,
    time_window,
    start_date,
    end_date,
    sea_area,
    records_saved,
    created_at
    ) VALUES (?, ?, ?, ?, ?, ?, ?)
    """
    ,
    (
    species_name,
    time_window,
    start_date,
    end_date,
    sea_area,
    records_saved,
    created_at,
    ),
    )
    self.conn.commit()

    def rebuild_intensity(self, grid_size: float = 1.0) > int:
    """
    Rebuild intensity table from raw_occurrences.
    intensity_index = count of raw occurrence records in each grid/month/species.
    """

    df = pd.read_sql_query("SELECT * FROM raw_occurrences", self.conn)

    self.conn.execute("DELETE FROM resource_intensity")
    self.conn.commit()

    if df.empty:
    return 0

    counter: Counter[tuple[str, str, str, str, int, int, str]] = Counter()

    for row in df.itertuples(index=False):
    grid_id, grid_lon, grid_lat = grid_id_from_lon_lat(
    float(row.longitude),
    float(row.latitude),
    grid_size=grid_size,
    )
    key = (
    row.species_name,
    row.sea_area,
    row.time_window,
    grid_id,
    grid_lon,
    grid_lat,
    row.source,
    )
    counter[key] += 1

    intensity_rows = [
    IntensityRecord(
    species_name=species_name,
    sea_area=sea_area,
    time_window=time_window,
    grid_id=grid_id,
    grid_lon=grid_lon,
    grid_lat=grid_lat,
    intensity_index=count,
    source=source,
    )
    for (
    species_name,
    sea_area,
    time_window,
    grid_id,
    grid_lon,
    grid_lat,
    source,
    ), count in counter.items()
    ]

    sql = """
    INSERT OR REPLACE INTO resource_intensity (
    species_name,
    sea_area,
    time_window,
    grid_id,
    grid_lon,
    grid_lat,
    intensity_index,
    source
    ) VALUES (
    :species_name,
    :sea_area,
    :time_window,
    :grid_id,
    :grid_lon,
    :grid_lat,
    :intensity_index,
    :source
    )
    """

    items = [intensity_to_dict(row) for row in intensity_rows]
    self.conn.executemany(sql, items)
    self.conn.commit()

    return len(items)

    def export_raw_csv(self, output_path: Path) > None:
    df = pd.read_sql_query("SELECT * FROM raw_occurrences", self.conn)
    df.to_csv(output_path, index=False, encoding="utf-8-sig")

    def export_intensity_csv(self, output_path: Path) > None:
    df = pd.read_sql_query(
    """
    SELECT
    species_name,
    sea_area,
    time_window,
    grid_id,
    grid_lon,
    grid_lat,
    intensity_index,
    source
    FROM resource_intensity
    ORDER BY species_name, time_window, intensity_index DESC
    """
    ,
    self.conn,
    )
    df.to_csv(output_path, index=False, encoding="utf-8-sig")

    def read_intensity_df(self) > pd.DataFrame:
    return pd.read_sql_query("SELECT * FROM resource_intensity", self.conn)

    def close(self) > None:
    self.conn.close()

    这部分代码有一个细节:聚合表不是边抓边算,而是在原始表写入后统一重建。这样调试更方便。你修改网格大小或强度定义后,可以直接重跑聚合,不需要重新请求接口。


    9️⃣ 运行方式与结果展示(必写)

    9.1 入口文件:run_spider.py

    # run_spider.py

    from __future__ import annotations

    import logging
    from typing import List

    from tqdm import tqdm

    from fisheries_spider.config import SpiderConfig
    from fisheries_spider.fetcher import ObisFetcher
    from fisheries_spider.parser import now_utc_iso, parse_occurrence
    from fisheries_spider.storage import SQLiteStorage
    from fisheries_spider.time_utils import iter_month_windows
    from fisheries_spider.exporter import export_intensity_geojson
    from fisheries_spider.map_builder import build_timeline_map

    logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
    )

    def main() > None:
    config = SpiderConfig()

    # 示例物种。实际项目可扩展成多个物种。
    species_list: List[str] = [
    "Gadus morhua",
    ]

    # 示例海域:北大西洋部分区域。
    # WKT 坐标顺序是 lon lat,不是 lat lon。
    sea_area = "North Atlantic Demo Area"
    geometry_wkt = "POLYGON((-75 35,-75 55,-45 55,-45 35,-75 35))"

    start_month = "2020-01"
    end_month = "2020-12"

    fetcher = ObisFetcher(config)
    storage = SQLiteStorage(config.sqlite_path)

    # 建议限制字段,减少响应体积。
    # 注意:如果你后续要更多字段,可以在这里增加。
    fields = ",".join(
    [
    "id",
    "scientificName",
    "eventDate",
    "decimalLongitude",
    "decimalLatitude",
    "dataset_id",
    "basisOfRecord",
    ]
    )

    try:
    for species_name in species_list:
    windows = list(iter_month_windows(start_month, end_month))

    for window in tqdm(windows, desc=f"Collecting {species_name}"):
    parsed_rows = []

    for record in fetcher.iter_occurrences(
    scientific_name=species_name,
    start_date=window.start_date,
    end_date=window.end_date,
    geometry_wkt=geometry_wkt,
    fields=fields,
    ):
    parsed = parse_occurrence(
    record=record,
    fallback_species=species_name,
    sea_area=sea_area,
    time_window=window.month,
    source="OBIS API",
    )
    if parsed is not None:
    parsed_rows.append(parsed)

    saved = storage.insert_raw_occurrences(parsed_rows)
    storage.log_request(
    species_name=species_name,
    time_window=window.month,
    start_date=window.start_date,
    end_date=window.end_date,
    sea_area=sea_area,
    records_saved=saved,
    created_at=now_utc_iso(),
    )

    intensity_count = storage.rebuild_intensity(grid_size=1.0)
    logging.info("Rebuilt intensity table. rows=%s", intensity_count)

    storage.export_raw_csv(config.raw_csv_path)
    storage.export_intensity_csv(config.intensity_csv_path)
    export_intensity_geojson(storage, config.intensity_geojson_path)
    build_timeline_map(
    geojson_path=config.intensity_geojson_path,
    output_html_path=config.timeline_map_path,
    )

    logging.info("Raw CSV: %s", config.raw_csv_path)
    logging.info("Intensity CSV: %s", config.intensity_csv_path)
    logging.info("GeoJSON: %s", config.intensity_geojson_path)
    logging.info("Timeline map: %s", config.timeline_map_path)

    finally:
    storage.close()

    if __name__ == "__main__":
    main()

    9.2 导出 GeoJSON:exporter.py

    # fisheries_spider/exporter.py

    from __future__ import annotations

    import json
    from pathlib import Path
    from typing import Dict, List

    from fisheries_spider.storage import SQLiteStorage

    def grid_polygon(grid_lon: int, grid_lat: int, size: float = 1.0) > list:
    """
    Build a simple polygon for a lon/lat grid cell.

    Coordinates follow GeoJSON order: [lon, lat].
    """
    lon0 = grid_lon
    lat0 = grid_lat
    lon1 = grid_lon + size
    lat1 = grid_lat + size

    return [
    [lon0, lat0],
    [lon1, lat0],
    [lon1, lat1],
    [lon0, lat1],
    [lon0, lat0],
    ]

    def export_intensity_geojson(
    storage: SQLiteStorage,
    output_path: Path,
    grid_size: float = 1.0,
    ) > None:
    df = storage.read_intensity_df()

    features: List[Dict] = []

    if not df.empty:
    max_intensity = int(df["intensity_index"].max())
    else:
    max_intensity = 0

    for row in df.itertuples(index=False):
    intensity = int(row.intensity_index)

    feature = {
    "type": "Feature",
    "geometry": {
    "type": "Polygon",
    "coordinates": [
    grid_polygon(
    grid_lon=int(row.grid_lon),
    grid_lat=int(row.grid_lat),
    size=grid_size,
    )
    ],
    },
    "properties": {
    "species_name": row.species_name,
    "sea_area": row.sea_area,
    "time_window": row.time_window,
    "time": f"{row.time_window}-01",
    "grid_id": row.grid_id,
    "grid_lon": int(row.grid_lon),
    "grid_lat": int(row.grid_lat),
    "intensity_index": intensity,
    "max_intensity_in_export": max_intensity,
    "source": row.source,
    "popup": (
    f"Species: {row.species_name}<br>"
    f"Sea area: {row.sea_area}<br>"
    f"Month: {row.time_window}<br>"
    f"Grid: {row.grid_id}<br>"
    f"Intensity: {intensity}<br>"
    f"Source: {row.source}"
    ),
    },
    }
    features.append(feature)

    geojson = {
    "type": "FeatureCollection",
    "features": features,
    }

    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(
    json.dumps(geojson, ensure_ascii=False, indent=2),
    encoding="utf-8",
    )

    9.3 生成时间轴地图:map_builder.py

    # fisheries_spider/map_builder.py

    from __future__ import annotations

    import json
    from pathlib import Path

    import folium
    from folium.plugins import TimestampedGeoJson

    def style_function(feature: dict) > dict:
    props = feature.get("properties", {})
    intensity = props.get("intensity_index", 0) or 0
    max_intensity = props.get("max_intensity_in_export", 1) or 1

    ratio = min(float(intensity) / float(max_intensity), 1.0) if max_intensity else 0

    # Folium style_function can use explicit colors.
    # This is map styling, not chart styling.
    if ratio >= 0.75:
    fill_color = "#800026"
    elif ratio >= 0.5:
    fill_color = "#BD0026"
    elif ratio >= 0.25:
    fill_color = "#E31A1C"
    elif ratio > 0:
    fill_color = "#FC4E2A"
    else:
    fill_color = "#FFEDA0"

    return {
    "fillColor": fill_color,
    "color": "#333333",
    "weight": 0.5,
    "fillOpacity": 0.55,
    }

    def build_timeline_map(geojson_path: Path, output_html_path: Path) > None:
    data = json.loads(geojson_path.read_text(encoding="utf-8"))

    fmap = folium.Map(
    location=[45, 60],
    zoom_start=4,
    tiles="CartoDB positron",
    )

    TimestampedGeoJson(
    data=data,
    period="P1M",
    duration="P1M",
    transition_time=400,
    auto_play=False,
    loop=False,
    add_last_point=False,
    time_slider_drag_update=True,
    style=style_function,
    ).add_to(fmap)

    folium.LayerControl().add_to(fmap)

    output_html_path.parent.mkdir(parents=True, exist_ok=True)
    fmap.save(str(output_html_path))

    这个 HTML 地图不是截图,而是一个可交互页面。打开后可以拖动时间轴,查看不同月份的网格强度变化。这个效果和很多动态分布图的基本交互是一致的。

    9.4 启动命令

    在项目根目录运行:

    python run_spider.py

    如果成功,会看到类似日志:

    Collecting Gadus morhua: 100%|████████████████████| 12/12 [00:42<00:00, 3.51s/it]
    2026-06-12 10:30:12 | INFO | root | Rebuilt intensity table. rows=86
    2026-06-12 10:30:12 | INFO | root | Raw CSV: data/processed/raw_occurrences.csv
    2026-06-12 10:30:12 | INFO | root | Intensity CSV: data/processed/resource_intensity.csv
    2026-06-12 10:30:12 | INFO | root | GeoJSON: data/processed/resource_intensity.geojson
    2026-06-12 10:30:12 | INFO | root | Timeline map: data/maps/resource_timeline_map.html

    9.5 输出路径

    输出文件如下:

    data/processed/fisheries_dynamic.sqlite3
    data/processed/raw_occurrences.csv
    data/processed/resource_intensity.csv
    data/processed/resource_intensity.geojson
    data/maps/resource_timeline_map.html

    SQLite 中包含三张表:

    SELECT name FROM sqlite_master WHERE type='table';

    预期结果:

    raw_occurrences
    resource_intensity
    request_log

    9.6 示例结果

    resource_intensity.csv 示例:

    species_name,sea_area,time_window,grid_id,grid_lon,grid_lat,intensity_index,source
    Gadus morhua,North Atlantic Demo Area,2020-01,lon_-63_lat_44,-63,44,12,OBIS API
    Gadus morhua,North Atlantic Demo Area,2020-01,lon_-62_lat_45,-62,45,7,OBIS API
    Gadus morhua,North Atlantic Demo Area,2020-02,lon_-64_lat_46,-64,46,15,OBIS API
    Gadus morhua,North Atlantic Demo Area,2020-03,lon_-61_lat_43,-61,43,9,OBIS API
    Gadus morhua,North Atlantic Demo Area,2020-04,lon_-58_lat_47,-58,47,4,OBIS API

    这几行表达的意思是:在指定海域内,某物种在某个月份、某个经纬度网格中有多少条有效 occurrence 记录。intensity_index 越高,说明该网格在该月的记录越多。再次强调,它是记录强度,不是资源储量。

    9.7 查看 SQLite

    可以用 Python 快速检查:

    import sqlite3
    import pandas as pd

    conn = sqlite3.connect("data/processed/fisheries_dynamic.sqlite3")

    df = pd.read_sql_query(
    """
    SELECT *
    FROM resource_intensity
    ORDER BY time_window, intensity_index DESC
    LIMIT 10
    """
    ,
    conn,
    )

    print(df)
    conn.close()

    也可以用 sqlite3 命令行:

    sqlite3 data/processed/fisheries_dynamic.sqlite3

    进入后执行:

    .headers on
    .mode column

    SELECT species_name, time_window, grid_id, intensity_index
    FROM resource_intensity
    ORDER BY intensity_index DESC
    LIMIT 10;


    🔟 常见问题与排错(强烈建议写)

    10.1 403 怎么办

    403 通常表示服务器拒绝访问。可能原因包括:

    • 请求路径不允许;
    • 缺少必要 headers;
    • 接口不对外开放;
    • 访问方式不符合平台规则;
    • IP 被临时限制。

    处理思路:

  • 先确认接口是否是官方公开接口;
  • 查看 API 文档,而不是盲目复制前端请求;
  • 设置正常 User-Agent;
  • 降低请求频率;
  • 不要尝试绕过登录或权限限制;
  • 如果接口明确不开放,停止采集。
  • 不要把 403 简单理解为“缺代理”。很多 403 是规则问题,不是网络问题。

    10.2 429 怎么办

    429 表示请求过快。正确处理方式是降速。

    本文代码里已经对 429 做了退避等待。还可以继续优化:

    • 增大 REQUEST_SLEEP_SECONDS;
    • 降低 PAGE_SIZE;
    • 减少物种数量;
    • 减少时间范围;
    • 避开高峰时段;
    • 使用官方推荐的大数据导出方式。

    不建议遇到 429 就换代理池。公共数据平台不是靶场,采集要有边界。

    10.3 HTML 抓到空壳怎么办

    动态页面直接 requests 抓 HTML,经常只能看到:

    <div id="app"></div>
    <script src="/assets/index.js"></script>

    这说明数据不是在 HTML 里,而是由前端 JavaScript 请求接口后渲染。

    解决方法:

  • 打开浏览器开发者工具;
  • 进入 Network;
  • 筛选 XHR / Fetch;
  • 操作页面筛选条件;
  • 找到 JSON、GeoJSON、MVT 或 tile 请求;
  • 复制请求参数;
  • 用 Python 重放接口。
  • 如果接口参数由前端复杂生成,再考虑 Playwright。但只要能定位到清晰 API,就不要用浏览器自动化硬跑。

    10.4 解析报错怎么办

    常见解析错误包括:

    • 字段名变化;
    • 返回结构变化;
    • 某些记录缺少经纬度;
    • 某些记录 eventDate 为空;
    • 响应不是 JSON;
    • 接口返回错误信息但状态码仍是 200。

    处理方式:

    • 解析函数要用 dict.get();
    • 用 pick_first() 支持多个候选字段;
    • 缺少关键字段就跳过;
    • 保存部分原始响应用于排查;
    • 记录失败样本;
    • 单独写小测试验证解析器。

    不要把解析逻辑写成一串硬索引,比如:

    record["data"]["items"][0]["location"]["lat"]

    这种写法在真实数据里很脆。更稳妥的方式是逐层判断。

    10.5 编码/乱码如何处理

    JSON 接口一般问题不大。如果导出 CSV 给 Excel 打开,中文字段可能出现乱码。本文使用:

    encoding="utf-8-sig"

    这样 Excel 兼容性更好。

    如果网页采集遇到乱码,可以检查:

    resp.encoding
    resp.apparent_encoding

    必要时手动设置:

    resp.encoding = "utf-8"

    但不要盲目设置。先看响应头和页面实际编码。

    10.6 数据为空怎么办

    数据为空不一定是程序错。可能原因很多:

    • 物种名不存在或拼写不一致;
    • 时间范围内没有记录;
    • 海域 WKT 范围太小;
    • 经纬度顺序写反;
    • 参数名写错;
    • 接口分页逻辑不对;
    • 字段过滤过窄。

    排查顺序建议:

  • 放大时间范围;
  • 放大空间范围;
  • 先不传 fields;
  • page size 设置小一点;
  • 在浏览器中直接打开请求 URL;
  • 换一个更常见的物种测试;
  • 打印完整响应结构。
  • WKT 最容易踩坑的是坐标顺序。WKT 通常写 lon lat,不是 lat lon。例如:

    POLYGON((-75 35,-75 55,-45 55,-45 35,-75 35))

    这里 -75 是经度,35 是纬度。

    10.7 时间轴不动怎么办

    Folium 生成时间轴地图时,需要 GeoJSON feature 里有可识别的时间属性。本文写入的是:

    "properties": {
    "time": "2020-03-01"
    }

    如果时间轴不动,检查:

    • 是否有 time 字段;
    • 时间格式是否类似 YYYY-MM-DD;
    • GeoJSON 是否为空;
    • feature geometry 是否有效;
    • 浏览器控制台是否有 JS 报错。

    如果数据量很大,HTML 会变得很重。此时应该考虑按时间切片加载,或者用专门的 WebGIS 服务发布图层。


    1️⃣1️⃣ 进阶优化(可选但加分)

    11.1 并发优化

    本文没有使用并发,是故意的。对公开 API,先保证温和和稳定,再谈速度。

    如果后续确实需要并发,可以考虑三种方案:

    第一种,线程池:

    from concurrent.futures import ThreadPoolExecutor, as_completed

    def collect_one_window(args):
    species_name, window = args
    # call fetcher and parser here
    return species_name, window.month, 0

    with ThreadPoolExecutor(max_workers=2) as executor:
    futures = [
    executor.submit(collect_one_window, item)
    for item in tasks
    ]

    for future in as_completed(futures):
    print(future.result())

    注意,max_workers 不要太大。对于公共 API,2 到 4 已经不少了。

    第二种,asyncio + aiohttp。适合大量小请求,但复杂度更高。

    第三种,Scrapy。适合任务队列、去重、重试、日志、限速、扩展中间件都比较复杂的项目。

    11.2 断点续跑

    当前代码每次运行会继续插入原始表,因为 occurrence_id 是主键,重复记录会被忽略。但请求本身还是会重发。

    更好的断点续跑方式是:

    • request_log 记录每个 species + month + sea_area 是否完成;
    • 启动时先查 request_log;
    • 已完成的窗口跳过;
    • 未完成的窗口继续抓。

    示例函数:

    def is_window_done(conn, species_name: str, sea_area: str, time_window: str) > bool:
    cur = conn.execute(
    """
    SELECT COUNT(*) AS cnt
    FROM request_log
    WHERE species_name = ?
    AND sea_area = ?
    AND time_window = ?
    """
    ,
    (species_name, sea_area, time_window),
    )
    row = cur.fetchone()
    return row["cnt"] > 0

    但要注意:如果上次采集失败却写了日志,就会误判完成。更严谨的做法是给 request_log 加 status 字段:

    running / success / failed

    开始采集前写 running,成功后改 success,失败后改 failed。

    11.3 日志与监控

    最低限度的日志应该包括:

    • 请求 URL;
    • 物种;
    • 时间窗口;
    • offset;
    • 返回数量;
    • 保存数量;
    • 失败原因;
    • 总耗时。

    可以把日志写入文件:

    from pathlib import Path
    import logging

    log_path = Path("logs/spider.log")
    log_path.parent.mkdir(exist_ok=True)

    logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
    handlers=[
    logging.FileHandler(log_path, encoding="utf-8"),
    logging.StreamHandler(),
    ],
    )

    对于长期任务,可以统计:

    • 成功窗口数;
    • 失败窗口数;
    • 空结果窗口数;
    • 平均每页耗时;
    • 平均每月记录数;
    • 429 次数;
    • 5xx 次数。

    这些指标比“程序能跑”更有价值。采集任务一旦跑久了,日志就是你的眼睛。

    11.4 定时任务

    如果要每月自动更新,可以使用 cron。

    Linux crontab 示例:

    0 3 1 * * cd /opt/fisheries_dynamic_obis && /opt/fisheries_dynamic_obis/.venv/bin/python run_spider.py >> logs/cron.log 2>&1

    含义是每月 1 日凌晨 3 点执行一次。

    如果任务复杂,可以使用 Airflow 或 Prefect。它们可以管理依赖、重试、告警和任务状态。但小项目没必要一开始就上 Airflow。先用脚本把流程跑通,再决定是否平台化。

    11.5 空间网格升级

    本文用 1 度经纬度网格是为了简单可解释。真实项目可以升级为:

    • Geohash;
    • H3;
    • S2;
    • EEZ 区划;
    • FAO fishing area;
    • 自定义渔场边界;
    • PostGIS 空间 join。

    比如使用 H3,可以把经纬度编码成六边形网格,更适合做空间聚合。但 H3 需要额外依赖,本文为了保持代码轻量,没有加入。

    11.6 强度指标升级

    当前强度指标是记录数量。后续可以扩展为:

    • 记录数量;
    • 去重后的采样事件数量;
    • 单位面积记录数量;
    • 按数据集加权后的记录数量;
    • 按采样努力校正后的相对指数;
    • 不同来源一致性指标。

    这里要特别谨慎。越接近生态解释,就越需要领域知识。爬虫只能负责把数据可靠地搬到分析桌面上,不能把简单 count 包装成科学结论。

    11.7 多物种批量采集

    把 species_list 扩展即可:

    species_list = [
    "Gadus morhua",
    "Clupea harengus",
    "Thunnus thynnus",
    ]

    批量采集时建议加上:

    • 每个物种单独日志;
    • 每个物种单独输出文件;
    • 失败物种清单;
    • 可配置的采集范围;
    • 限制最大记录数。

    不要一次塞几十个物种、十几年时间、全球范围,然后直接开跑。先做一个物种一个月的小样本,确认字段和数据质量,再扩大。

    11.8 与 Scrapy 集成

    如果你更喜欢 Scrapy,可以把请求层改成 Scrapy Spider:

    import scrapy

    class ObisOccurrenceSpider(scrapy.Spider):
    name = "obis_occurrence"

    custom_settings = {
    "DOWNLOAD_DELAY": 1.0,
    "CONCURRENT_REQUESTS": 2,
    "RETRY_TIMES": 2,
    "FEED_EXPORT_ENCODING": "utf-8",
    }

    def start_requests(self):
    url = "https://api.obis.org/v3/occurrence"
    params = {
    "scientificname": "Gadus morhua",
    "startdate": "2020-01-01",
    "enddate": "2020-01-31",
    "geometry": "POLYGON((-75 35,-75 55,-45 55,-45 35,-75 35))",
    "size": 500,
    "offset": 0,
    }
    yield scrapy.FormRequest(
    url=url,
    method="GET",
    formdata=params,
    callback=self.parse,
    )

    def parse(self, response):
    payload = response.json()
    for row in payload.get("results", []):
    yield {
    "id": row.get("id"),
    "scientificName": row.get("scientificName"),
    "eventDate": row.get("eventDate"),
    "decimalLongitude": row.get("decimalLongitude"),
    "decimalLatitude": row.get("decimalLatitude"),
    }

    不过这段只是示意。对于 GET 参数,Scrapy 中更常见的写法是自己拼 query string 或使用 urllib.parse.urlencode。如果项目已经用 requests 跑得很好,没必要为了“更像爬虫”而换 Scrapy。


    1️⃣2️⃣ 总结与延伸阅读

    本文完成了一件比较完整的事情:把动态渔业资源分布图采集拆成了可落地的工程流程。

    我们从需求出发,明确了目标字段:

    • 物种/资源名;
    • 海域;
    • 时间;
    • 强度指标;
    • 来源。

    然后选择公开 API 作为采集入口,没有去抓截图,也没有去模拟复杂页面操作。接着,我们把时间轴拆成月度窗口,把空间范围写成 WKT polygon,通过 requests Session 稳定请求 occurrence 数据。解析层负责字段映射和容错,存储层用 SQLite 保存原始记录和聚合记录,导出层生成 CSV、GeoJSON,最后用 Folium 做了一个可拖动时间轴的 HTML 地图。

    这套流程的重点不是某一个接口,而是方法:

    动态地图页面
    → 找图层接口
    → 拆时间轴
    → 分页采集
    → 解析 JSON / GeoJSON / MVT
    → 清洗坐标和时间
    → 空间聚合
    → 指标定义
    → 数据库存储
    → 可视化导出

    在我看来,爬虫真正有意思的地方不在“把数据抓下来”这一步,而在于抓下来之后能不能解释清楚:字段是什么,指标怎么算,数据有什么局限,结果能不能复现,出了问题能不能排查。一个只会跑的脚本,很快会变成黑盒;一个结构清楚的采集项目,才有继续维护和扩展的价值。

    下一步可以继续做这些扩展:

  • 使用 Scrapy 管理更复杂的任务队列;
  • 使用 Playwright 分析需要前端运行时生成参数的地图页面;
  • 使用 PostGIS 做空间 join 和空间索引;
  • 使用 H3 或 Geohash 替代简单经纬度网格;
  • 使用 Airflow 做定时更新;
  • 使用 DuckDB / GeoParquet 处理更大规模的开放数据;
  • 将结果接入 WebGIS 前端,做成真正的资源分布看板。
  • 最后再提醒一句:动态地图采集不是越快越好,尤其是公共科学数据平台。慢一点、稳一点、可复现一点,往往比短时间抓很多数据更专业。爬虫工程的成熟,不是体现在并发数字上,而是体现在边界感、数据质量和长期维护能力上。

    🌟 文末

    好啦~以上就是本期的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

    小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~ 三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

    ✅ 专栏持续更新中|建议收藏 + 订阅

    墙裂推荐订阅专栏 👉 《Python爬虫实战》,本专栏秉承着以“入门 → 进阶 → 工程化 → 项目落地”的路线持续更新,争取让每一期内容都做到:

    ✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

    📣 想系统提升的小伙伴:强烈建议先订阅专栏 《Python爬虫实战》,再按目录大纲顺序学习,效率十倍上升~

    ✅ 互动征集

    想让我把【某站点/某反爬/某验证码/某分布式方案】等写成某期实战?

    评论区留言告诉我你的需求,我会优先安排实现(更新)哒~


    ⭐️ 若喜欢我,就请关注我叭~(更新不迷路) ⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力) ⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


    ✅ 免责声明

    本文爬虫思路、相关技术和代码仅用于学习参考,对阅读本文后的进行爬虫行为的用户本作者不承担任何法律责任。

    使用或者参考本项目即表示您已阅读并同意以下条款:

    • 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
    • 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
    • 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
    • 使用或者参考本项目即视为同意上述条款,即 “谁使用,谁负责” 。如不同意,请立即停止使用并删除本项目。!!!

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Python 实战:动态渔业资源分布图采集——基于 OBIS 时间轴图层的 API 采集、清洗、聚合与导出
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!