☰
全球碳减排项目数据采集与数据库构建实战:Python爬虫、清洗与增量更新
2026/10/8 3:04:10 网站建设 项目流程

1. 为什么取"全球碳减排项目":数据缺口与爬虫的切入点

先说说我为什么要做这个数据库。过去几年我一直在做环境类数据研究和项目评估,最头疼的一件事就是:全球碳减排项目的数据非常分散。CDM(清洁发展机制)注册处有一套,VCS(Verra)登记处有一套,黄金标准(GS)又有一套,各个国家的自愿减排计划还有自己的平台。做研究、做分析、做报告,光是收集这些项目的名称、所在地区、减排量、方法学、状态这些字段,就要在几十个网站之间来回切换,手动复制粘贴,效率低到让人崩溃。

想通之后我决定自己动手:用 Python 写一个爬虫,把这些公开的碳减排项目数据汇总到自己的数据库里。目标很简单,把全球各个主要碳登记平台的项目信息抓下来,标准化之后存进一个关系型数据库,后面要查什么直接用 SQL 查就行,再也不用开着十几个浏览器标签页到处翻。

这个数据库的价值很简单:一个结构化、跨平台、可持续更新的全球碳减排项目清单。它可以支撑:

  • 碳项目的区域分布和类型分布统计;
  • 按开发年份、方法学、签发状态筛选项目;
  • 为后续的排放因子、减排潜力计算提供基础数据;
  • 抽查比对各登记处的数据一致性。

至于为什么用爬虫而不是等别人提供数据库,理由也很现实:很多平台根本没有结构化 API,就算有,也需要申请授权,而且字段定义各不相同。爬虫反而是最直接、最可控的方案。Python 在这个场景下几乎是唯一选择——生态里 Requests、XPath、pandas、SQLAlchemy 这些工具全都现成,写起来快,调试也方便。

这篇博客我就把整套搭建过程完整记录下来:从数据源选择到表结构设计,从登录和请求处理到数据清洗,从批量入库到增量更新,最后是线上跑了半年之后遇到的坑和解决方案。

2. 数据抓取前的结构设计:入库字段与表关系

很多人一上来就写爬虫,抓到什么存什么,结果数据落到库里之后乱七八糟,再回头改表结构,等于全部重来。我的建议是:先花两天时间设计表和字段,再动手写爬虫。表结构是一切的骨架,后面所有的清洗、去重、增量更新都依赖它。

2.1 字段规划:从登记机构的数据结构推导字段

不同碳登记平台的项目详情页虽然长得不一样,但核心字段高度一致。我在调研了 CDM、Verra、GS 三个平台之后,归纳出下面这些公共字段:

字段名称含义示例
project_id项目唯一标识CDM-1234 / VCS-567
project_name项目名称某省水电项目
platform所属登记平台CDM / Verra / GS
country / region项目所在国家或地区China / Brazil
status项目状态Registered / Inactive
methodology采用的方法学ACM0002
methodology_type方法学类型可再生能源
developer项目业主或开发商某能源有限公司
annual_credits年均核证减排量120,000 tCO2e/年
total_credits累计签发减排量1,200,000 tCO2e
registration_date注册日期2021-06-30
crediting_period计入期2021-2031
coordinate项目坐标30.5, 104.0
data_source_url原始链接https://...
data_update_time抓取时间2024-01-01

这里有一个容易被忽略的点:coordinate 字段非常有用,但也最容易缺。有些平台在列表页就给了经纬度,有些在详情页的地图组件里才有,更多平台干脆不公开。我建议把坐标设计成可空字段,后面如果需要做空间分析,再用地名反查补充,不要把非核心字段卡死整个流程。

2.2 数据表设计:项目主表、开发者表、方法学表

单表存所有字段当然可以,但后面统计会写得很难受。我用的是三张表结构:

  • projects 表:存放项目主信息,也就是上面那些字段;
  • developers 表:存放开发者信息,通过 developer_id 关联到 projects 表;
  • methodologies 表:存放方法学定义,比如 ACM0002 对应什么类型、适用条件是什么,通过 methodology_id 关联。

为什么要拆开发者和方法学?因为在实际数据中,同一个开发者可能开发了几百个项目,同一个方法学也可能会被上千个项目复用。如果全塞在 projects 表里,每次改方法学的描述都要批量 UPDATE,数据一多就非常痛苦。拆出来之后,方法学表就变成了一张"字典表",关联查询走 JOIN 就行,逻辑清楚还省空间。

CREATE TABLE projects ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT UNIQUE NOT NULL, project_name TEXT NOT NULL, platform TEXT NOT NULL, country TEXT, status TEXT, methodology_id INTEGER, developer_id INTEGER, annual_credits REAL, total_credits REAL, registration_date TEXT, crediting_period TEXT, coordinate TEXT, data_source_url TEXT, data_update_time TEXT, FOREIGN KEY (methodology_id) REFERENCES methodologies(id), FOREIGN KEY (developer_id) REFERENCES developers(id) );

project_id 我特意加了 UNIQUE 约束。这个字段在各平台内部是唯一的,比如 CDM 的编号在 CDM 平台内不会重复,把它当业务主键是最靠谱的去重依据。后面做增量更新的时候,用这个字段判断记录是否存在,效率非常高。

2.3 增量更新与版本控制的思路

数据抓取不是一次性的活儿。碳减排项目的状态会变,减排量会更新,新项目会不断出现,所以数据库一定要支持增量更新。

我的做法是:在 projects 表里记录 data_update_time,每次抓取时先按 project_id 查库,如果记录存在就比较关键字段(状态、减排量、名称),有变化就 UPDATE,没变化就跳过;如果不存在就走 INSERT。这样一个小时跑完全量数据也没问题,但收益不是全量抓取,而是用最小的请求量保持数据新鲜。

如果还想做得更严谨,可以加一张 data_history 表,把每次 UPDATE 前的快照存进去,相当于项目数据的版本历史。这样以后要做"这个项目哪年状态发生了变更"这类分析,直接查历史表就行。我在第二版里加了这张表,效果很好,推荐有需求的朋友也这样做。

3. 爬虫核心实现:Requests 抓取 + XPath 解析

表结构定了,下面进入爬虫主体。我的方案是Requests 发送 HTTP 请求 + lxml 的 XPath 解析 HTML + 少量正则处理特殊字段,没有用 Scrapy。不是 Scrapy 不好,而是这个项目的数据量级(全球碳项目满打满算几千个)用 Requests 就完全够,代码也更轻量,调试起来一条龙,出了问题在哪里一眼就能看见。

3.1 首轮试探:静态列表页的抓取

先拿最典型的 CDM 项目列表页试水。这类页面通常是一个表格,包含项目编号、名称、注册日期等,下面有分页按钮。第一步是抓取第一页,确认页面结构。

import requests from lxml import html headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } url = "https://example-carbon-platform.org/projects?page=1" resp = requests.get(url, headers=headers, timeout=15) doc = html.fromstring(resp.text) rows = doc.xpath("//table[@id='project-table']/tbody/tr") print(f"当前页项目数: {len(rows)}")

这里有个非常关键的实测经验:第一次访问先不要写完整逻辑,先打两三行代码把页面结构和 XPath 路径确认了。因为碳平台这种数据网站的页面更新频率很高,今天还能跑的 XPath,下个月可能就失效了。先把解析路径快速验证一遍,再写完整爬虫,比你直接写完一跑发现全是空值要省时间得多。

3.2 详情页解析的关键路径

列表页拿到的是摘要信息,很多重要字段(比如减排量、坐标系、计入期)都在详情页里。所以我的逻辑是:第一轮抓列表页拿到每个项目的 project_id 和详情页链接,第二轮再逐条请求详情页。

详情页的解析不建议用一长串复杂的 XPath。比如:

//div[@class='field']/div[2]/span[@class='value']/text()

这种路径看着没错,但只要页面加个层级或者改个 CSS 类名,就整段失效。我习惯用基于锚点文本的 XPath,比如页面里有个标签叫"注册日期:",后面跟着日期,那我就这么写:

date_elem = doc.xpath("//*[contains(text(), '注册日期')]/following-sibling::*[1]")

因为"注册日期"这个文案是不会随便变的,即使页面结构调整,只要字段标签还在,就能找到对应的值。这个思路在处理多平台时特别有用,三个平台的页面结构完全不同,但锚点文本方式几乎可以复用。

3.3 分页、重试、限速与请求头伪装

列表页几乎都有分页,常见的两种方式:URL 参数翻页(?page=1, ?page=2)和按钮点击翻页。碳平台基本都是前者,老实处理参数就行。

def fetch_all_pages(base_url, total_pages): results = [] for page in range(1, total_pages + 1): resp = requests.get(f"{base_url}?page={page}", headers=headers, timeout=15) doc = html.fromstring(resp.text) results.extend(parse_list_page(doc)) time.sleep(1 + random.random()) return results

有两个细节需要特别说明:

第一是限速。碳减排项目平台的服务器容量通常不大,访问频率太快很容易被限流。我一般把间隔设在 0.5 到 2 秒的随机区间,既不影响速度,也不会给服务器造成压力。实测下来,每秒 1 个请求的速度对一个几千条数据的项目来说,总耗时也就半小时左右,完全能接受。

第二是并发。Requests 默认是同步请求,但详情页抓取如果也一个一个请求,几千个项目就要等很久。我后来用concurrent.futures.ThreadPoolExecutor做了并行,把健康检查阶段的40个页面同时抓,明显快了很多。但并发数我控制在 5 个以内,因为详情页请求频率太高同样会触发防护。别贪快,稳定才是第一位。

4. 从HTML表格到干净数据:清洗与标准化的具体方案

爬虫写完之后,你以为就结束了吗?远着呢。HTML 里抓下来的原始数据充斥着逗号分位符、日期格式混乱、减排量带文字后缀、项目名称首尾有空格这些问题。如果不清洗,数据直接入库,后面做统计会错得很离谱。

4.1 文本噪声处理与字段格式统一

我在清洗阶段主要做了这几件事:

  • 去掉所有字段首尾的空白字符和换行符;
  • 统一日期格式,不管是June 2, 2023还是2023-06-02,都转成 ISO 8601;
  • 从"1,200,000 tCO2e/年"这种字符串里提取纯数字 1200000;
  • 项目名称里的特殊引号、版权符号直接替换为空。
def clean_number(value: str) -> float: if not value: return 0.0 value = value.strip().replace(",", "") match = re.search(r"[\d.]+", value) return float(match.group()) if match else 0.0

处理项目名称更有意思。我发现同一个项目在不同平台上的名称会有细微差别,比如大小写不同、缩写不同、"Ltd."写不写。为了解决匹配问题,我加了一个归一化函数:统一小写、去除非字母数字字符、把缩写展开。这样在做跨平台对齐的时候,能多匹配上不少项目。

4.2 坐标、日期、减排量数值的解析

坐标字段是我踩坑最多的一个字段。有些平台的坐标格式是"30.5N, 104.0E",有些是"30.5, 104.0",还有些直接给一个 WKT 字符串。我的处理方案是写一个通用的坐标解析函数,自动识别方向标识,统一转成十进制度坐标。

def parse_coord(text: str): text = text.strip() m = re.match(r"([\d.]+)\s*([NSEW])", text) if m: val = float(m.group(1)) if m.group(2) in ("S", "W"): val = -val return val return float(text)

日期解析也是必踩的坑:"2023/06/02"、"2 June 2023"、"June 2023"(没有天)这些格式,直接用strptime会报错。我最后是自己写了一个多格式匹配函数,把常见日期格式全部列进去,逐个尝试解析,实在解析不了就返回空。

4.3 多数据源ID对齐与去重

全球碳减排项目这个主题,意味着数据来源不止一个。同一个项目既可能出现在 CDM 平台,也可能出现在 Verra——不过说实话这种情况比较少见,更常见的是另一种:不同平台记录的方法学和项目状态描述有差异。

我的做法是:

  • 每个平台的数据都保留自己的原生 project_id;
  • 同时在 projects 表里保存一个 canonical_id,用于跨平台标识;
  • 清洗时通过项目名称归一化、地理坐标匹配、方法学代码匹配三重逻辑,尝试把跨平台项目对齐;
  • 对齐成功的记录更新 canonical_id,没对齐的保持 NULL。

这样一来,既不影响单平台统计,又能支持"全球总项目数"这种跨平台的合并统计。

5. 数据库落库与查询:SQLite起步、MySQL扩展

数据清洗完之后,就该入库了。这里我想说一个很多初学者不太注意的问题:选数据库不要一步到位,先想清楚你的数据量、查询场景和运行环境。

5.1 为什么先选 SQLite 再迁 MySQL

我这个项目最开始用的就是 SQLite,当时理由有三个:

  • 全球碳减排项目即便全量抓下来,也就是几万行的量级,SQLite 单文件处理毫无压力;
  • 开发阶段反复改表结构、清数据重跑,SQLite 改起来最快;
  • 爬虫跑在普通服务器上,不需要额外安装数据库服务,省事。

后来数据量涨到百万行级别、需要多人同时查询、还要做报表分析的时候,我才把数据迁移到了 MySQL。迁移用的基本是 pandas 的to_sql,几百行代码搞定,核心逻辑就是读取 SQLite 里的旧表,清洗后写进 MySQL 新库。

所以我的真实建议是:单机数据量在十万行以下、单人使用,SQLite 完全够用;团队协作、数据量大、需要并发写入,再考虑 MySQL。数据库选型最忌讳一开始就上重型方案,架构复杂度会拖累整个项目进度。

5.2 批量写入与事务控制

入库的写法也有讲究。一条一条 INSERT 对几千条数据勉强可以接受,但数据量大了之后性能差到没法看。我用的是批量插入 + 事务包裹:

def batch_insert(conn, rows, batch_size=500): cursor = conn.cursor() for i in range(0, len(rows), batch_size): batch = rows[i:i+batch_size] cursor.executemany( "INSERT INTO projects (...) VALUES (?, ?, ...)", batch ) conn.commit()

在实测中,executemany比单条插入快了几十倍,特别是数据量过万以后,差距非常明显。另外我要提醒一点:清洗完的数据不要直接覆盖旧库。我的习惯是先把全量数据写入一个临时表,确认行数、去重率、抽样检查都没问题之后,再执行UPDATE projects SET ...或REPLACE INTO真正更新。这样即使清洗逻辑出了问题,也不会污染已经跑得好好的旧数据。

5.3 常用数据查询与索引优化

库建好之后,日常用得最多的是这几类查询:

  • 按国家统计项目数量;
  • 按方法学类型统计减排量;
  • 按注册年份看新项目趋势;
  • 查某个具体项目的完整档案。

为了让这些查询不慢,我在 database 里建了索引:

CREATE INDEX idx_projects_platform ON projects(platform); CREATE INDEX idx_projects_country ON projects(country); CREATE INDEX idx_projects_status ON projects(status);

这里有一个经验:索引别建太多。因为爬虫每天会做增量更新,如果索引过多,INSERT 和 UPDATE 的开销会变大。我只给 JOIN 和 GROUP BY 频繁用到的字段建索引,其他字段宁愿查询时全扫,也不增加维护成本。

6. 线上运行一年的避坑清单

数据库上线之后,前三个月我是手动跑的,后面改成了定时任务。在这期间踩了不少坑,单独拎出来写一节,希望后来的人能少走弯路。

6.1 反爬、封IP与请求频率控制

先说大家最关心的反爬问题。碳减排平台的整体反爬强度不算高,没有滑块验证码、没有字体加密这些高难度对抗,主要就是限速和 IP 频率控制。

我遇到过的问题有两个:

第一个是请求频率过高被短暂拉黑。表现就是连续请求几十个页面之后,服务器开始返回 403 或空页面。排查之后发现是我加并发的时候没控制好节奏,5 个线程同时跑,每个线程之间还没加延时,等于一瞬间把频率拉高了 5 倍。解决方案很简单:把延时放进每个请求里,而不是在 for 循环外面统一延时,从根本上保证任意时刻只有一个请求在线上。

def fetch_with_delay(url, min_delay=1.0, max_delay=2.5): time.sleep(random.uniform(min_delay, max_delay)) resp = requests.get(url, headers=headers, timeout=15) return resp

第二个是 User-Agent 太老。有些平台新版的 WAF 会拦截老旧的浏览器 UA。我后来准备了一个 UA 列表,随机切换,问题就消失了。

提示:请求频率控制的底线是"别把你自己的 IP 打封了"。就算真的要高频抓取,也千万先看一下目标平台的 robots.txt 和访问条款,合理抓取比抓得快重要得多。

6.2 数据源改版时的应对策略

网站改版是爬虫项目的"不可抗力"。我运营这半年里,有两个平台的页面结构发生了明显变化,其中一个是表格 CSS 类名全换,另一个是详情页从静态 HTML 变成了动态加载。

第一个好办,改一下 XPath 就行。第二个比较麻烦,用 Requests 直接请求详情页 URL 拿不到数据,因为内容是通过 JavaScript 渲染的。我当时的应对方案有两条路可选:一是用 Selenium/Playwright 模拟浏览器,二是找数据接口。

我最后的方案是直接抓接口。用浏览器开发者工具打开 Details 面板,查看 Network 请求,发现数据来自一个 JSON API。这个 API 返回的就是干净的结构化数据,解析起来比 HTML 容易得多。

api_url = "https://example-carbon-platform.org/api/projects/1234" resp = requests.get(api_url, headers={"X-Requested-With": "XMLHttpRequest", ...}) data = resp.json()

所以我的经验是:网站改版之后,先别急着改 XPath,打开开发者工具看一眼有没有 JSON 接口。很多平台底层都会用 API 取数,只是页面上没暴露。直接抓 JSON 不仅稳定,解析成本也低。

6.3 合规边界:公开数据、robots协议与爬取尺度

最后聊一个比较现实的问题:合规边界。碳减排项目数据本身属于公开的注册信息,平台方通常也是希望被检索和引用的,但这不代表可以无限度地抓取。

我给自己定了三条原则:

  • 只抓公开数据,不碰需要登录才能看到的非公共信息;
  • 遵守 robots.txt,里面明确 disallow 的路径绝对不访问;
  • 控制抓取频率,不给对方服务器造成明显压力。

另外一点是数据使用时注意来源标注。我在数据库里为每条记录保留了 data_source_url 字段,后期分析报告里引用数据时直接带上原始链接,既是对平台的尊重,也方便数据核验。

这三个原则我建议每个做数据采集的人都在开工前想清楚。技术能力和数据红线是两回事,掌握爬虫技能的同时,也应该知道边界在哪里,这样才能长期、安心地把数据项目做下去。

7. 最终体验:这套方案的实际效果和可扩展方向

这个全球碳减排项目数据库从我动手写第一版到现在,经历了大半年,现在每天定时自动更新,积累了几千条有效项目记录。相比最初手动收集的效率,至少提升了十倍。更关键的是,因为数据结构化了,很多之前没办法快速回答的问题——比如某个区域有多少水电项目、某类方法学的平均减排量是多少——现在一条 SQL 就出来,效率提升非常直观。

回头复盘,这套爬虫 + 数据库方案的核心价值并不在于爬虫本身,而在于把零散的、异构的数据源收敛成一套稳定、可持续使用的结构化数据资产。Python 在里面扮演的角色,从抓取、解析、清洗到入库,每一步都有成熟的工具可以用,这也是为什么我一直推荐用 Python 做这类中轻量级的数据采集项目。

如果后续想继续扩展,我觉得有两个不错的方向:

一是把数据可视化做成一个简单的 Web 看板,按国家、按项目类型、按时间线展示全球碳项目的分布。数据底子已经打好了,前端只需要接数据库做统计查询。

二是加入更多数据源,比如区域级的林业碳汇项目、蓝碳项目、垃圾处理填埋气项目等。每新增一个平台,只要按前面设计好的字段映射关系做一次适配,就能并进同一套库里。多源数据的整合能力,是这个数据库最长期的价值所在。

我个人在实际操作中的体会是:爬虫项目最难的往往不是爬虫本身,而是你怎么设计数据结构、怎么确保数据长期可用。推荐新手先花力气把字段设计和清洗逻辑想清楚,再写爬虫。这样到后面数据量大了,你会发现当初的设计习惯帮你省下了大量的返工时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询