
写这个项目之前我先交代一下背景。我一直在关注气候变化和碳减排相关的数据领域但发现公开可用的碳减排项目信息散落在各种报告、新闻稿和平台里很难系统性地做对比分析。于是我用 Python 写了一套爬虫目标是搭建一个结构化的全球碳减排项目数据库把这些分散的信息统一抓下来、清洗好、存进 MySQL方便后续做维度分析。这篇文章就是把这个项目的完整过程拆开讲一遍从需求落点到表结构设计、爬虫实现、反爬应对、入库去重、定时调度再到实际踩过的坑全流程记录。适合想用 Python 做数据采集和构建行业数据库的读者参考。1. 项目整体设计与思路拆解1.1 这个项目到底要解决什么问题先说说为什么选碳减排项目这个方向。碳减排项目通常指通过可再生能源、森林保护、甲烷回收、能效提升等方式实现温室气体减排的具体项目这些项目的信息会记录项目名称、类型、地理位置、开发主体、预计年减排量、认证状态等字段。业内做 ESG 分析、碳资产管理、碳中和路径规划的人都需要这类结构化数据做支撑。但我在调研时发现这些数据的获取方式相当原始有人手工复制到 Excel有人写一次性脚本抓完就扔还有人靠二手报告里的截图。问题是项目数量多、字段不统一、不同来源对同一项目的描述出入很大。真正需要的是一个能持续更新的数据库而不是一份用过就过期的 CSV。所以我把目标定位成三层先抓列表页拿到项目全集再进详情页补全典型字段最后统一清洗入库存成可查询的维度表。至于分析、可视化、趋势预测那是数据库建好之后的事前期不贪多。1.2 为什么用 Python 而不是其他方案技术选型上其实没有太多悬念。Python 在爬虫生态里优势太明显requests 处理 HTTP 请求BeautifulSoup 和 lxml 做 HTML 解析pandas 做数据规整PyMySQL 对接 MySQL整个闭环成熟得不能再成熟。语言本身的开发效率也高这个规模的项目不需要像 Java 那样搭一堆工程化框架一个人维护完全够用。数据库选 MySQL 是因为项目后续要做多维度查询而且需要给团队其他人共用MySQL 的配套设施、索引优化和权限管理都比轻量级文件型数据库更省心。至于爬虫本体我特意没有引入重型爬虫框架Scrapy 当然更强但用 requests 更能把一个请求、一次解析、一条入库的完整链路讲清楚。等理解了每一步在干什么再考虑框架化也不迟。1.3 数据源的筛选原则与合规边界数据源是整个项目的命脉这一步踩错后面全白做。我的筛选原则有三条第一信息公开且稳定页面结构不会频繁大改第二字段覆盖要够至少包含项目名称、类型、区域、年减排量、状态这几个核心维度第三数据源本身允许合理访问遵守其公开声明和使用条款。一个实际的选择是公开的碳减排项目登记信息页面这类页面一般有分页列表和详情页结构字段也规整非常适合练手和起步。关于反爬我的立场很明确遵守数据源规则控制抓取频率不在明令禁止的边界上游走。爬虫的工程难点在于如何处理限流、超时、数据一致性而不是琢磨怎么绕过限制。把合规放在第一位项目才能跑得长久。2. 数据模型设计先把表结构想清楚2.1 字段设计与计算口径我之前吃过一个亏先写爬虫后建表抓回来的字段五花八门清洗的时候差点崩溃。这次我反着来先磨表结构。核心表是项目主表字段包括项目唯一编号、项目名称、项目类型、所属地区与二级地区、开发主体、项目状态、备案年份、预估年减排量、减排量单位、方法学、数据来源链接、首次入库时间、最近更新时间。年减排量这个字段我要特别提醒不同来源给的可能是年减排量也可能是十年累计减排量甚至整个人为规划期的总减排量。如果不在入库前统一口径后面做聚合分析会得出完全错误的结论。我的处理方式是同时保留原始值和标准化值标准化统一折算成二氧化碳当量 CO2e 每年原始值留底备查。地理信息也要重视很多分析都是按大洲、按区域做的。我会在预处理阶段把国家或地区名称映射到大洲和各地区分组存成独立的两个字段这样后面查询省去一次 JOIN。如果原始数据里只有经纬度还会补一步逆地理编码这个后文会细说。2.2 建表 SQL 与索引策略表结构我用了一段时间调整最终版本大概是这样的CREATE TABLE carbon_projects ( id BIGINT PRIMARY KEY AUTO_INCREMENT, project_code VARCHAR(64) NOT NULL COMMENT 项目唯一编号, project_name VARCHAR(255) NOT NULL COMMENT 项目名称, project_type VARCHAR(64) COMMENT 项目类型太阳能、风电、甲烷回收等, continent VARCHAR(32) COMMENT 所属大洲, region VARCHAR(64) COMMENT 所属地区/国家, developer VARCHAR(255) COMMENT 开发主体, project_status VARCHAR(32) COMMENT 状态注册、待审、已签发等, registered_year INT COMMENT 备案年份, annual_reduction_original VARCHAR(64) COMMENT 原始减排量值, annual_reduction_tco2e DECIMAL(12,2) COMMENT 标准化年减排量(tCO2e/年), methodology VARCHAR(128) COMMENT 采用方法学, detail_url VARCHAR(512) COMMENT 详情页来源链接, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_project_code (project_code), KEY idx_type (project_type), KEY idx_status (project_status), KEY idx_continent (continent), KEY idx_year (registered_year) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT全球碳减排项目主表;索引这块我的思路是按查询习惯走。最频繁的查询条件一般是项目类型、状态、大洲和年份所以给这四个字段建了单列索引。project_code 建唯一索引是为了配合去重逻辑这个非常关键后面增量更新全靠它。2.3 增量更新与去重策略爬虫跑起来之后不可能只抓一次项目数据是持续变化的。我的更新策略是每日全量扫描 增量入库。每天定时任务从第一页开始扫如果遇到已经存在的 project_code就更新核心字段如果是新项目就执行插入。去重我不用复杂的比对算法就靠数据库唯一索引和代码里的预处理两层保险。代码层面先查一遍库把已有 project_code 放进内存集合抓取时直接跳过万一并发情况下出现竞态数据库的唯一索引会抛异常兜底。这套方案应对几千上万级别的项目数据完全够用不需要引入分布式锁之类的重武器。3. 爬虫核心实现与完整代码3.1 请求层请求头、会话与重试机制爬虫工程化的第一步是把请求层写稳。我习惯把所有请求封装到一个函数里统一处理超时、重试和异常。目标站点如果识别出你是脚本轻则返回验证码页面重则直接封 IP所以请求头要模拟真实浏览器的指纹对话要保持 cookie 和连接池的复用。请求层我用了 requests.Session它会自动管理连接、保持 cookie比每次新建 requests.get 要省资源。同时自定义了重试逻辑连接超时或状态码不符合预期时退避重试最多三次。每次请求之间还会随机 sleep 一段时间这个下面细讲。import requests import time import random from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry HEADERS { User-Agent: ( Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 ), Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: en-US,en;q0.9, } def build_session(): session requests.Session() retry Retry( total3, backoff_factor1.5, status_forcelist[500, 502, 503, 504], allowed_methods[GET, POST], ) adapter HTTPAdapter(max_retriesretry, pool_connections10, pool_maxsize10) session.mount(http://, adapter) session.mount(https://, adapter) session.headers.update(HEADERS) return session def fetch_html(session, url, timeout15): resp session.get(url, timeouttimeout) resp.raise_for_status() resp.encoding resp.apparent_encoding return resp.text3.2 解析层用 BeautifulSoup 提取列表与详情字段列表页解析的核心逻辑是定位项目卡片容器逐条提取链接和摘要字段再拼接出详情页 URL。BeautifulSoup 的 find 和 select 足够完成这类任务不需要上 XPath代码可读性也更好。详情页字段解析是工作量最大的部分。我采取字段兜底策略每个字段先尝试首选选择器拿不到就尝试备选选择器都拿不到就返回空字符串。宁可少字段也不能因为一个项目解析异常导致整个程序崩溃。另外很多页面的数字带逗号或者单位描述混乱这一步我在解析层就顺手做一次清洗。from bs4 import BeautifulSoup def parse_list_page(html): soup BeautifulSoup(html, lxml) items [] for card in soup.select(.project-card): link_tag card.select_one(a.project-name) if not link_tag: continue detail_url link_tag.get(href) title link_tag.get_text(stripTrue) items.append({title: title, detail_url: detail_url}) return items def parse_detail_page(html): soup BeautifulSoup(html, lxml) def pick(selectors): for sel in selectors: node soup.select_one(sel) if node: text node.get_text( , stripTrue) if text: return text return data { project_code: pick([span#project-code, td.project-code]), project_type: pick([td.project-type, div.field-type]), region: pick([td.region, span.region]), developer: pick([td.developer, div.field-developer]), project_status: pick([td.status, span.status]), registered_year: pick([td.year, td.registered-year]), annual_reduction_original: pick([td.reduction, span.reduction-value]), methodology: pick([td.methodology, div.field-methodology]), } return data3.3 入库层事务、批量写入与字段规整解析后的数据不能直接丢数据库得先经过清洗规整。我写了一个 normalize 函数把区域映射到对应大洲把减排量描述字符串转成数字过滤掉无法识别的脏数据。清洗完的数据通过 executemany 批量写入比一条条 insert 快一个数量级。写入和更新都包在事务里保证出错时可以整体回滚。import pymysql from dbutils.pooled_db import PooledDB POOL PooledDB( creatorpymysql, maxconnections10, mincached1, blockingTrue, host127.0.0.1, port3306, usercarbon_user, passwordyour_password, databasecarbon_db, charsetutf8mb4, ) def normalize_record(record): region record.get(region, ) continent region_to_continent(region) raw_reduction record.get(annual_reduction_original, ) tco2e convert_reduction_to_tco2e(raw_reduction) record[continent] continent record[annual_reduction_tco2e] tco2e return record def save_projects(records): conn POOL.connection() try: with conn.cursor() as cursor: sql_upsert INSERT INTO carbon_projects (project_code, project_name, project_type, continent, region, developer, project_status, registered_year, annual_reduction_original, annual_reduction_tco2e, methodology, detail_url) VALUES (%(project_code)s, %(project_name)s, %(project_type)s, %(continent)s, %(region)s, %(developer)s, %(project_status)s, %(registered_year)s, %(annual_reduction_original)s, %(annual_reduction_tco2e)s, %(methodology)s, %(detail_url)s) ON DUPLICATE KEY UPDATE project_name VALUES(project_name), project_type VALUES(project_type), continent VALUES(continent), region VALUES(region), developer VALUES(developer), project_status VALUES(project_status), registered_year VALUES(registered_year), annual_reduction_original VALUES(annual_reduction_original), annual_reduction_tco2e VALUES(annual_reduction_tco2e), methodology VALUES(methodology), detail_url VALUES(detail_url) cursor.executemany(sql_upsert, records) conn.commit() except Exception: conn.rollback() raise finally: conn.close()3.4 全流程串联与定时调度整个抓取流程在主函数里串联建会话、遍历列表页、解析详情、规整数据、批量入库。为了不给目标站点造成压力抓取节奏控制在每个页面之间随机等待 2-5 秒遇到详情页也是一样。定时调度我用了最简单可靠的方案APScheduler 做成一个常驻服务每天凌晨跑一次全量扫描。服务本身写进 systemd 管理异常退出自动拉起日志滚动保留。这里不追求容器化因为单机方案对于这个量级的数据已经绰绰有余。from apscheduler.schedulers.blocking import BlockingScheduler def crawl_job(): session build_session() base_url https://example-data.registry/projects?page{} collected [] for page in range(1, 100): html fetch_html(session, base_url.format(page)) list_items parse_list_page(html) if not list_items: break for item in list_items: detail_html fetch_html(session, item[detail_url]) detail parse_detail_page(detail_html) detail[project_name] item[title] detail[detail_url] item[detail_url] collected.append(normalize_record(detail)) time.sleep(random.uniform(1.5, 3.5)) time.sleep(random.uniform(2, 5)) save_projects(collected) if __name__ __main__: scheduler BlockingScheduler(timezoneAsia/Shanghai) scheduler.add_job(crawl_job, cron, hour2, minute30) scheduler.start()4. 实操过程与数据质量检查4.1 第一轮抓取的实际效果我跑第一轮的时候是分页全量抓共扫到项目列表页约 120 页单页 20 条左右加上部分详情页的字段缺失率比较高最终入库的有效记录在 2100 条左右。这个数量级对 MySQL 来说毫无压力真正的压力在解析和数据规整上。跑完第一轮后我做了个数据质量统计。统计发现项目类型字段缺失率约 8%年减排量字段缺失率约 15%区域字段缺失率不到 3%。所有缺失都来自部分详情页结构和标准模板不一样或者是历史项目信息不完整。我针对这些情况补了备选选择器重新跑了一遍增量更新整体缺失率明显下降。4.2 数据质量核验的笨办法核验数据质量不能只看入库量我按字段做了抽样人工比对。随机抽了 50 条记录逐一打开原始详情页核对字段特别关注项目名称、年减排量和状态这三个关键字段。比对结果发现项目名称匹配率 100%状态字段发现 6 处由于页面文案更新导致爬虫取值不统一年减排量有 3 处单位换算错误。单位换算是这次质检里最大的问题。来源里有的写tCO2e/年有的写MtCO2e/年有的写预计年减排量 50000 tCO2e有的直接写年减排 5万吨。我的转换函数一开始只处理了纯数字加上固定单位的情况遇到混合描述就直接放弃了。后来我重写了正则匹配逻辑把数字和单位拆开统一换算成 tCO2e/年。这个过程是值得花时间的因为后续所有分析都依赖于这个标准字段。4.3 会话与抓取频率的工程优化第一版脚本虽然能跑但速度偏慢而且偶尔会出现某个详情页卡住十几秒的情况。优化方式有三个一是给 session 挂上连接池复用 TCP 连接二是把抓取成功和失败的日志区分开失败 URL 单独记录三是加入快速失败策略连续五次失败就停止当天任务而不是空转。还有个细节是请求头里的 Accept-Language有些目标页面会依据这个字段决定返回哪个语言版本。最开始我没在意导致解析选择器在部分页面上失效。后来统一固定成英文版本解析逻辑才稳定下来。这类问题在真实爬虫项目里特别常见排查思路往往不是代码逻辑错了而是你拿到的页面和你预想的不一样。5. 常见问题与排查技巧实录5.1 页面编码乱码与解析器选择我第一次解析列表页时中文项目名称乱成一团。原因很清楚requests 拿到响应后默认用 ISO-8859-1 解码但页面实际是 UTF-8。我后来做了两步处理显式读取响应里的 charset 判断编码结合 apparent_encoding 兜底。解析库方面我用 lxml 作为 BeautifulSoup 的后端解析器比 Python 标准库的 html.parser 快很多别看数据量不大详情页数量一多差距就出来了。5.2 断点续采与数据异常回滚做第一版全量抓取时我中途断过一次网结果抓到一半的数据直接丢失还得从头跑。这个教训很直接必须支持断点续采。我的方案是写一个抓取进度表记录每个列表页和详情页的完成状态。重新启动任务时先查进度已经抓过的页面直接跳过。这样每次异常中断成本只有一个页面而不是整个采集链路。入库层面的异常回滚同样重要。我用事务把批量写入包起来任何一条数据插错都会整体回滚。选择立即回滚而不是跳过错误记录是因为字段规整出错通常意味着解析逻辑有问题跳过会导致数据缺漏回滚更能暴露问题。5.3 数据库连接池与连接失效长驻服务跑一两天后MySQL 偶尔会报 MySQL server has gone away。原因是 MySQL 的 wait_timeout 默认 8 小时空闲连接被服务端回收了但连接池还保留着旧连接。解决方法是给连接池配置 ping 检测取出连接时先判断存活失效就重建。这个坑不深但是没踩过的人根本想不到。5.4 字段规整中的禁区最后整理一个我自己总结的注意点清单都是实战里容易忽略的场景常见错误正确做法年减排量带单位直接转 float 报错正则拆解数字与单位统一折算 tCO2e/年项目状态文案变化状态维度无法统计建立状态映射表做归一化处理存在多条数据源不同来源字段冲突指定来源优先级保留原始文本备查项目名称包含换行或空格入库出现脏字符串解析时 strip 压缩空白符日期/年份为空排序时出现 NULL 坑入库前兜底为 0查询时排除详情页结构微调选择器拿不到值多选择器兜底解析失败打日志这个表里的每一条都是我实际遇到过的。尤其是状态归一化目标平台的文案从 Registered 改成 REG 再改成 Active三次抓取三种写法如果不处理做趋势分析的时候会凭空多出一堆状态类别。关于数据库连接池我再多说一句。有些人图省事每次操作都新建连接数据量小时感觉不出来等爬虫改成多线程并行抓取数据库会瞬间被打满或者报连接超限。用连接池看似多写了一层代码长期跑下来非常值。我个人在实际操作中最深的体会是爬虫项目真正耗时间的地方从来不是写爬虫本身而是数据规整和异常处理。请求库、解析库、数据库这些技术选型早就成熟了难点在于你面对的真实数据永远比你设想的要脏。构建这套碳减排数据库的过程本质上是在和海量非结构化信息做对抗。每一个字段的清洗规则都是对一类异常数据的预判。项目跑到现在每天凌晨自动更新一次数据量稳定增长查询接口也对应起来了。这个内容后续还可以扩展成项目地图可视化和减排量的区域对比报告但那是数据库跑稳之后的事现在这套底座已经足够扎实了。