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

全文目录:

🌟 开篇语

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

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

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

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

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

0️⃣ 前言(Preface)

这篇文章要爬取 W3C 标准与草案归档中的 Working Draft 历史版本,使用 Python、requests、BeautifulSoup、lxml、SQLite/CSV 完成从列表页到 history 详情页的采集,最终产出包含“标题、状态、发布日期、工作组、链接”的结构化数据集。

读完本文,你能获得:

  1. 一套可以直接运行的 W3C Working Draft 历史归档爬虫工程。
  2. 一种处理“列表页 + 归档详情页 + 状态字段归一”的通用爬虫方法。
  3. 一套可复用的请求层、解析层、存储层拆分思路,适合继续扩展到 Scrapy、异步爬虫或定时任务。

我个人写这类爬虫时最看重两件事:一是别急着上并发,先把结构摸透;二是数据字段要能解释清楚,尤其是“状态”这种看起来简单、实际容易混乱的字段。W3C 的技术报告页面非常适合作为练手对象,因为页面公开、结构相对规范、历史版本清晰,同时又存在足够多的真实细节,比如历史状态名称不统一、不同规范族有不同工作组、部分旧页面链接存在格式差异等。

1️⃣ 摘要(Abstract)

本文将以 W3C standards and drafts 页面及其 publication history 页面为目标,使用 Python requests + BeautifulSoup + lxml 编写一个可复现爬虫,将 W3C Working Draft 历史版本采集为 CSV、JSONL 和 SQLite 数据。

读完本文,你将掌握:

  1. 如何从 W3C TR 列表页提取规范标题、当前状态、发布日期、工作组、history 链接。
  2. 如何进入每个 publication history 页面抓取历史版本,并筛选 Working Draft、First Public Working Draft、Last Call Working Draft 等相关状态。
  3. 如何把状态字段归一为稳定的枚举值,例如 working_draft,方便后续统计、检索和分析。

本文不是讲“炫技式爬虫”,也不是追求把请求打满。它更像一次扎实的工程拆解:先确定数据源,再拆请求、解析、清洗和存储,最后把异常、去重、断点续跑、频率控制这些看似琐碎但实际很要命的事情补上。

2️⃣ 背景与需求(Why)

W3C 是 Web 标准世界里绕不开的组织。我们日常接触的 HTML、CSS、DOM、SVG、Web API、Accessibility、Internationalization 等许多技术,都能在 W3C 的技术报告体系里找到对应的文档或历史痕迹。

在实际数据分析中,W3C Working Draft 的历史归档有不少价值。例如:

  • 分析某个技术规范从第一次公开草案到候选推荐、正式推荐经历了多久。
  • 观察不同工作组在某些年份发布 Working Draft 的频率。
  • 建立一个本地可搜索的规范历史索引。
  • 给内部知识库、标准追踪系统、研发资料库做自动化数据补全。
  • 统计某些领域,例如 CSS、DOM、Accessibility、Privacy、Web API,在不同时间段的活跃度变化。

本篇文章的目标站点是 W3C 的标准与草案列表页,以及每个规范对应的 publication history 页面。我们关注的字段如下:

字段 英文字段名 来源 说明
标题 title TR 列表页或 history 页标题 规范名称,例如 UI Events
状态 status history 页状态文本归一化 统一为 working_draft 等枚举
原始状态 status_raw history 页原始状态文本 例如 Working DraftLast Call Working Draft
发布日期 publish_date history 页日期 ISO 日期格式,例如 2026-02-21
工作组 working_group TR 列表页 Deliverers 区域 例如 Web Applications Working Group
链接 link history 页中对应版本链接 指向某个具体历史版本页面
来源历史页 source_history_url TR 列表页 history 链接 方便回溯
规范族 family TR 列表页分组标题 例如 UI EventsCSS Text

严格来说,用户要求的字段只有“标题、状态、发布日期、工作组、链接”。我在工程里额外保留 status_rawsource_history_urlfamily,不是为了复杂化,而是为了可审计。做爬虫的人都知道,一旦数据清洗后丢掉原始值,后面排查问题会非常难受。


3️⃣ 合规与注意事项

爬虫不是“能抓到就算赢”。尤其是面向公开机构、标准组织或文档站点时,合规和克制是基本功。

3.1 robots.txt 基本说明

robots.txt 是站点给爬虫看的访问建议文件。它通常会说明哪些路径允许或不建议自动化访问。实际工程里不要把“我浏览器能打开”当成“爬虫一定可以抓”。本文代码会使用 Python 的 urllib.robotparser.RobotFileParser 读取目标站点 robots 规则,并在请求前判断当前 User-Agent 是否可以访问目标 URL。

需要说明的是,robots 不是法律文本,也不是权限系统,但它是爬虫礼貌协议的重要组成部分。技术分享场景下,至少应该做到:

  • 请求前检查 robots。
  • 不绕过明确禁止的路径。
  • 不伪装成浏览器进行攻击式访问。
  • 不采集与主题无关的数据。

3.2 频率控制

本文示例默认在每次成功请求后等待 0.8s + 随机抖动,并提供 --sleep 参数。完整抓取所有规范族 history 页面时,请根据网络环境和站点响应速度调大间隔,比如 1.5 秒、2 秒甚至更高。

爬虫的目标是稳定拿到数据,不是制造压力。对这种文档站点来说,没有必要搞几十上百并发。我的习惯是:先单线程跑通 20 条,再跑 100 条,确认解析和存储都稳定后,再考虑是否需要温和并发。

3.3 不采集敏感信息

本文只采集公开技术报告页面中的元数据,包括标题、状态、发布日期、工作组和公开链接。不采集个人隐私、不采集登录后页面、不绕过付费或访问限制,也不做任何形式的漏洞探测。

3.4 不绕过登录、付费和访问控制

如果一个页面需要登录、验证码、令牌、付费订阅或明确的访问授权,就不应该在没有授权的情况下绕过。本文目标页面属于公开文档,代码也不包含登录绕过、验证码识别、批量代理池压测等内容。遇到 403、429 时,正确处理方式是降低频率、检查请求头、确认 robots、必要时停止采集,而不是继续加压。


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

4.1 静态、动态还是 API?

这篇属于“静态 HTML 页面抓取 + 归档详情页解析”。

W3C TR 列表页本身可以通过普通 HTTP 请求拿到可解析的 HTML 内容,history 页面也能通过普通请求获取。因此没有必要一上来就用 Playwright 或 Selenium。浏览器自动化工具适合动态渲染严重、接口加密复杂、页面内容必须执行 JavaScript 才能出现的场景。本文目标页面不属于这种类型。

4.2 为什么选 requests + BeautifulSoup + lxml?

选择理由很直接:

  • requests:请求层成熟稳定,适合控制 headers、timeout、session、重试。
  • BeautifulSoup:容错能力强,处理历史页面时比纯 XPath 更舒服。
  • lxml:作为 BeautifulSoup 的解析器,速度和容错都不错。
  • python-dateutil:解析英文日期,例如 21 February 2026
  • sqlite3:Python 标准库自带,适合本地结构化存储和去重。
  • csv/json:通用格式,方便给 Excel、Pandas 或其他系统使用。

我没有用 Scrapy,不是因为 Scrapy 不好,而是本文更偏教学。requests + bs4 更容易把请求、解析、清洗、存储拆开讲清楚。等需求变成大规模定时采集、分布式队列、失败重试监控,再迁移到 Scrapy 会更自然。

4.3 整体流程

这次爬虫的流程可以写成四个词:

采集 → 解析 → 清洗 → 存储

展开后是:

启动命令
  ↓
读取配置
  ↓
检查 robots.txt
  ↓
请求 W3C TR 列表页
  ↓
解析每个规范条目
  ├─ 标题
  ├─ 当前状态
  ├─ 最新发布日期
  ├─ 工作组
  └─ history 链接
  ↓
逐个请求 history 页面
  ↓
解析历史版本列表
  ├─ 历史发布日期
  ├─ 历史状态
  └─ 历史版本链接
  ↓
状态归一化
  ├─ Working Draft
  ├─ First Public Working Draft
  └─ Last Call Working Draft
       ↓
      working_draft
  ↓
按链接去重
  ↓
导出 CSV / JSONL / SQLite

4.4 核心难点

这个项目看似简单,真正容易出问题的地方有四个:

第一,列表页不是传统表格。它更接近“分组标题 + 规范卡片”的结构。解析时不能只写死某个表格选择器,而要根据 h2h3 和相邻节点做相对稳健的抽取。

第二,history 页面有些像表格,有些旧页面的结构可能更松散。解析器要有主路径和兜底路径。

第三,状态名称存在历史差异。例如 Working DraftLast Call Working DraftFirst Public Working Draft 都应该归入 Working Draft 这一类。

第四,工作组字段来自当前 TR 列表页的 Deliverers 区域。历史版本对应的工作组通常可沿用当前规范条目中的 Deliverers,但对于非常古老、迁移过工作组的规范,可能存在历史偏差。因此工程里保留 source_history_urlfamily,方便后续复查。


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

5.1 Python 版本

建议使用:

Python 3.10+

我更推荐 3.11 或 3.12,主要是类型标注、性能和标准库体验都更好。但本文代码没有使用特别激进的新语法,3.10 以上即可。

5.2 创建虚拟环境

Linux / macOS:

mkdir w3c_wd_archive
cd w3c_wd_archive

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

Windows PowerShell:

mkdir w3c_wd_archive
cd w3c_wd_archive

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

5.3 安装依赖

新建 requirements.txt

requests==2.32.3
beautifulsoup4==4.12.3
lxml==5.2.2
python-dateutil==2.9.0.post0
tqdm==4.66.5

安装:

pip install -r requirements.txt

5.4 推荐项目结构

w3c_wd_archive/
├── requirements.txt
├── data/
│   └── .gitkeep
├── logs/
│   └── .gitkeep
└── src/
    └── w3c_wd_archive/
        ├── __init__.py
        ├── config.py
        ├── models.py
        ├── normalize.py
        ├── fetcher.py
        ├── parser.py
        ├── storage.py
        ├── crawler.py
        └── cli.py

这种结构比把所有代码塞进一个 main.py 更适合文章演示。原因是每一层职责清楚:

  • config.py:配置。
  • models.py:数据模型。
  • normalize.py:状态归一化。
  • fetcher.py:请求层。
  • parser.py:解析层。
  • storage.py:存储层。
  • crawler.py:编排逻辑。
  • cli.py:命令行入口。

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

请求层的职责只有一个:稳定、礼貌、可控地获取 HTML。

它不应该知道页面怎么解析,也不应该知道 CSV 怎么保存。很多爬虫项目一开始就乱,是因为请求、解析、存储混在一个函数里,后面出错时根本不知道是哪一层坏了。

6.1 配置文件

创建 src/w3c_wd_archive/config.py

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class CrawlConfig:
    base_url: str = "https://www.w3.org"
    tr_url: str = "https://www.w3.org/TR/"
    robots_url: str = "https://www.w3.org/robots.txt"

    user_agent: str = (
        "Mozilla/5.0 (compatible; W3CWDArchiveBot/0.1; "
        "+https://example.com/bot-info; educational-crawler)"
    )

    referer: str = "https://www.w3.org/TR/"
    timeout: tuple[float, float] = (5.0, 30.0)

    min_sleep: float = 0.8
    max_retries: int = 3
    backoff_factor: float = 1.6

    data_dir: Path = Path("data")

这里的 timeout 使用了 (connect_timeout, read_timeout) 形式。连接超时 5 秒,读取超时 30 秒。爬虫里不要不写 timeout,因为一旦网络卡住,整个程序可能挂在那里半天不动。

6.2 请求层实现

创建 src/w3c_wd_archive/fetcher.py

from __future__ import annotations

import random
import time
from urllib.robotparser import RobotFileParser

import requests

from .config import CrawlConfig


class FetchError(RuntimeError):
    pass


class Fetcher:
    def __init__(self, config: CrawlConfig) -> None:
        self.config = config
        self.session = requests.Session()
        self.session.headers.update(
            {
                "User-Agent": config.user_agent,
                "Accept": (
                    "text/html,application/xhtml+xml,"
                    "application/xml;q=0.9,*/*;q=0.8"
                ),
                "Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8",
                "Referer": config.referer,
                "Connection": "keep-alive",
            }
        )
        self._robots: RobotFileParser | None = None

    def load_robots(self) -> None:
        parser = RobotFileParser()
        parser.set_url(self.config.robots_url)
        try:
            parser.read()
        except Exception:
            # robots.txt 读取失败时,不要假装“明确允许”。
            # 本文示例选择继续,但真实生产环境可以改成直接停止。
            parser = None
        self._robots = parser

    def can_fetch(self, url: str) -> bool:
        if self._robots is None:
            self.load_robots()

        if self._robots is None:
            return True

        return self._robots.can_fetch(self.config.user_agent, url)

    def get_text(self, url: str) -> str:
        if not self.can_fetch(url):
            raise FetchError(f"robots.txt disallows fetching: {url}")

        last_error: Exception | None = None

        for attempt in range(1, self.config.max_retries + 1):
            try:
                response = self.session.get(url, timeout=self.config.timeout)

                if response.status_code in {403, 404}:
                    raise FetchError(f"HTTP {response.status_code}: {url}")

                if response.status_code in {429, 500, 502, 503, 504}:
                    raise requests.HTTPError(
                        f"retryable HTTP {response.status_code}",
                        response=response,
                    )

                response.raise_for_status()

                if not response.encoding:
                    response.encoding = response.apparent_encoding or "utf-8"

                self._polite_sleep()
                return response.text

            except (requests.RequestException, FetchError) as exc:
                last_error = exc

                if attempt >= self.config.max_retries:
                    break

                sleep_seconds = (
                    self.config.backoff_factor ** attempt
                ) + random.uniform(0, 0.5)

                time.sleep(sleep_seconds)

        raise FetchError(f"failed to fetch {url}: {last_error}") from last_error

    def _polite_sleep(self) -> None:
        time.sleep(self.config.min_sleep + random.uniform(0, 0.35))

6.3 headers 说明

这里设置了几个请求头:

"User-Agent": config.user_agent

用于说明请求来自一个教学用途的爬虫。实际项目中建议写清楚项目名、版本和联系页面。不要用空 UA,也不要滥用知名搜索引擎 UA。

"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"

告诉服务端我们希望拿到 HTML。

"Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8"

W3C 文档主体是英文,优先英文页面可以减少语言版本差异。

"Referer": config.referer

这里 referer 指向 TR 列表页,属于合理的上下文说明。

6.4 session/cookie 是否需要?

本文不需要登录,也不需要特殊 cookie。使用 requests.Session() 主要是为了复用连接和统一 headers。对于公开文档页,session 足够了。

6.5 失败处理:重试与退避

代码对以下状态做了区分:

  • 403:权限或访问策略问题,不建议盲目重试。
  • 404:页面不存在,直接失败。
  • 429:频率过高,属于可重试,但更应该降低频率。
  • 500/502/503/504:服务端或网关错误,可以退避重试。

退避策略是:

sleep_seconds = backoff_factor ** attempt + random_jitter

这样每次失败后等待时间会增长,避免短时间重复打扰服务器。


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

解析层是这个项目最关键的部分。W3C 的 TR 列表页看起来像“规范卡片列表”,history 页面看起来像“日期 + 状态”的归档列表。我们需要分别解析。

7.1 数据模型

创建 src/w3c_wd_archive/models.py

from __future__ import annotations

from dataclasses import dataclass, asdict
from typing import Iterable
import hashlib


@dataclass(slots=True)
class SpecFamilyItem:
    title: str
    current_status_raw: str
    current_status_norm: str
    latest_date: str
    working_groups: list[str]
    spec_url: str
    history_url: str
    family: str = ""


@dataclass(slots=True)
class PublicationRecord:
    title: str
    status: str
    status_raw: str
    publish_date: str
    working_group: str
    link: str
    source_history_url: str = ""
    family: str = ""

    @property
    def unique_key(self) -> str:
        if self.link:
            return self.link

        seed = "|".join(
            [
                self.title,
                self.status_raw,
                self.publish_date,
                self.working_group,
            ]
        )
        return hashlib.sha1(seed.encode("utf-8")).hexdigest()

    def to_row(self) -> dict[str, str]:
        row = asdict(self)
        row["unique_key"] = self.unique_key
        return row


def dedupe_records(records: Iterable[PublicationRecord]) -> list[PublicationRecord]:
    seen: set[str] = set()
    output: list[PublicationRecord] = []

    for item in records:
        key = item.unique_key

        if key in seen:
            continue

        seen.add(key)
        output.append(item)

    return output

SpecFamilyItem 表示列表页上的一个规范条目。PublicationRecord 表示 history 页中的某个历史版本。

我把去重键优先设置为 link。原因很简单:历史版本 URL 通常是最稳定的唯一标识。如果某些旧条目没有链接,再退回到 title + status + date + working_group 做 hash。

7.2 状态字段归一

创建 src/w3c_wd_archive/normalize.py

from __future__ import annotations

import re


_STATUS_RULES: list[tuple[str, str]] = [
    (r"\bfirst\s+public\s+working\s+draft\b", "working_draft"),
    (r"\blast\s+call\s+working\s+draft\b", "working_draft"),
    (r"\bworking\s+draft\b", "working_draft"),
    (r"\bwd\b", "working_draft"),

    (r"\bcandidate\s+recommendation\s+draft\b", "candidate_recommendation"),
    (r"\bcandidate\s+recommendation\s+snapshot\b", "candidate_recommendation"),
    (r"\bcandidate\s+standard\b", "candidate_recommendation"),
    (r"\bcandidate\s+recommendation\b", "candidate_recommendation"),

    (r"\bproposed\s+recommendation\b", "proposed_recommendation"),

    (r"\brecommendation\b", "recommendation"),
    (r"\bdraft\s+standard\b", "draft_standard"),
    (r"\bstandard\b", "recommendation"),

    (r"\bdraft\s+note\b", "draft_note"),
    (r"\bgroup\s+note\b", "note"),
    (r"\bnote\b", "note"),

    (r"\bdiscontinued\s+draft\b", "discontinued_draft"),
]


def normalize_space(value: str | None) -> str:
    if not value:
        return ""

    return re.sub(r"\s+", " ", value).strip()


def normalize_status(status: str | None) -> str:
    text = normalize_space(status).lower()

    if not text:
        return "unknown"

    for pattern, canonical in _STATUS_RULES:
        if re.search(pattern, text):
            return canonical

    return "other"


def is_working_draft(status: str | None) -> bool:
    return normalize_status(status) == "working_draft"

这里最重要的是匹配顺序。比如 Last Call Working Draft 同时包含 Working Draft,所以更具体的规则必须放在前面。

本文最终只保留 working_draft,但仍保留原始状态 status_raw。这对后续统计很有帮助。例如你可以进一步区分:

  • 普通 Working Draft。
  • First Public Working Draft。
  • Last Call Working Draft。

7.3 列表页解析思路

W3C TR 列表页中,每个规范条目大体包含:

  • h3:规范标题,里面有规范链接。
  • 紧随其后的文本:当前状态。
  • 日期行:最新发布日期,并带有 history 链接。
  • Deliverers:工作组链接。
  • 外层 h2:规范族或分组。

我们不强依赖某一个 CSS class,而是围绕 h3 进行相邻节点解析:

  1. 找到所有 h3
  2. h3 内找第一个链接作为标题和规范链接。
  3. 向前找最近的 h2 作为 family
  4. 向后收集兄弟节点,直到遇到下一个 h2h3
  5. 在这些节点中寻找状态、日期、history 链接、Deliverers。

创建 src/w3c_wd_archive/parser.py

from __future__ import annotations

import re
from urllib.parse import urljoin

from bs4 import BeautifulSoup, Tag
from dateutil import parser as date_parser

from .models import PublicationRecord, SpecFamilyItem
from .normalize import normalize_space, normalize_status, is_working_draft


DATE_RE = re.compile(
    r"\b("
    r"\d{1,2}\s+[A-Za-z]+\s+\d{4}"
    r"|[A-Za-z]+\s+\d{1,2},\s+\d{4}"
    r"|\d{4}-\d{2}-\d{2}"
    r")\b"
)

KNOWN_STATUS_WORDS = (
    "Working Draft",
    "First Public Working Draft",
    "Last Call Working Draft",
    "Candidate Recommendation",
    "Candidate Recommendation Draft",
    "Candidate Recommendation Snapshot",
    "Proposed Recommendation",
    "Recommendation",
    "Draft Standard",
    "Candidate Standard",
    "Standard",
    "Draft Note",
    "Note",
    "Discontinued Draft",
)


def parse_date_to_iso(value: str | None) -> str:
    text = normalize_space(value)

    if not text:
        return ""

    match = DATE_RE.search(text)

    if match:
        text = match.group(1)

    try:
        return date_parser.parse(text, dayfirst=True, fuzzy=True).date().isoformat()
    except (ValueError, TypeError, OverflowError):
        return ""


def clean_title_from_history_h1(value: str) -> str:
    text = normalize_space(value)
    text = re.sub(r"\s+publication\s+history$", "", text, flags=re.I)
    text = re.sub(r"\s+history$", "", text, flags=re.I)
    return text

日期解析要容忍几种格式:

21 February 2026
February 21, 2026
2026-02-21

接着写列表页解析函数:

def parse_tr_listing(html: str, base_url: str) -> list[SpecFamilyItem]:
    soup = BeautifulSoup(html, "lxml")
    root = soup.find("main") or soup

    items: list[SpecFamilyItem] = []

    for h3 in root.find_all("h3"):
        title_a = h3.find("a", href=True)

        if not title_a:
            continue

        title = normalize_space(title_a.get_text(" ", strip=True))
        spec_url = urljoin(base_url, title_a["href"])
        family = _find_family_name(h3)

        block_nodes = _collect_until_next_report(h3)

        block_text = "\n".join(
            node.get_text("\n", strip=True)
            for node in block_nodes
            if isinstance(node, Tag)
        )

        lines = [
            normalize_space(x)
            for x in block_text.splitlines()
            if normalize_space(x)
        ]

        status_raw = _first_status_line(lines)
        latest_date = parse_date_to_iso(block_text)
        history_url = _extract_history_url(block_nodes, base_url)
        working_groups = _extract_deliverers(block_nodes)

        if not history_url:
            continue

        items.append(
            SpecFamilyItem(
                title=title,
                current_status_raw=status_raw,
                current_status_norm=normalize_status(status_raw),
                latest_date=latest_date,
                working_groups=working_groups,
                spec_url=spec_url,
                history_url=history_url,
                family=family,
            )
        )

    return items

辅助函数如下:

def _find_family_name(h3: Tag) -> str:
    previous_h2 = h3.find_previous("h2")

    if previous_h2:
        return normalize_space(previous_h2.get_text(" ", strip=True))

    return ""


def _collect_until_next_report(h3: Tag) -> list[Tag]:
    nodes: list[Tag] = []

    for sibling in h3.next_siblings:
        if isinstance(sibling, Tag) and sibling.name in {"h2", "h3"}:
            break

        if isinstance(sibling, Tag):
            nodes.append(sibling)

    return nodes


def _first_status_line(lines: list[str]) -> str:
    for line in lines[:8]:
        low = line.lower()

        if "history" in low or DATE_RE.search(line):
            continue

        for status in KNOWN_STATUS_WORDS:
            if low == status.lower():
                return status

    for line in lines[:12]:
        for status in KNOWN_STATUS_WORDS:
            if status.lower() in line.lower():
                return status

    return ""


def _extract_history_url(block_nodes: list[Tag], base_url: str) -> str:
    for node in block_nodes:
        for a in node.find_all("a", href=True):
            label = normalize_space(a.get_text(" ", strip=True)).lower()

            if label == "history" or label.endswith(" history"):
                return urljoin(base_url, a["href"])

    return ""

工作组解析稍微绕一点,因为 Deliverers 下面可能有一个或多个工作组:

def _extract_deliverers(block_nodes: list[Tag]) -> list[str]:
    groups: list[str] = []
    capture = False

    for node in block_nodes:
        for element in node.descendants:
            if not isinstance(element, Tag):
                continue

            text = normalize_space(element.get_text(" ", strip=True))

            if not text:
                continue

            low = text.lower()

            if low == "deliverers":
                capture = True
                continue

            if capture and low in {"tags", "translations", "translation"}:
                capture = False

            if capture and element.name == "a":
                label = normalize_space(element.get_text(" ", strip=True))

                if label and "working group" in label.lower():
                    groups.append(label)

    if not groups:
        for node in block_nodes:
            for a in node.find_all("a", href=True):
                label = normalize_space(a.get_text(" ", strip=True))

                if "working group" in label.lower():
                    groups.append(label)

    return list(dict.fromkeys(groups))

这里用了一个小技巧:

list(dict.fromkeys(groups))

它可以在保持顺序的同时去重。Python 3.7 以后字典保持插入顺序,这种写法很适合轻量去重。

7.4 history 页解析思路

history 页面目标是抽出:

  • 发布日期。
  • 历史状态。
  • 历史版本链接。

优先解析表格行:

def _parse_history_table_rows(
    root: Tag,
    history_url: str,
) -> list[tuple[str, str, str]]:
    rows: list[tuple[str, str, str]] = []

    for tr in root.find_all("tr"):
        cells = [
            normalize_space(td.get_text(" ", strip=True))
            for td in tr.find_all(["td", "th"])
        ]

        if len(cells) < 2:
            continue

        date = parse_date_to_iso(cells[0])
        status_raw = cells[1]
        status_link = ""

        status_anchor = tr.find("a", href=True)

        if status_anchor:
            status_raw = (
                normalize_space(status_anchor.get_text(" ", strip=True))
                or status_raw
            )
            status_link = urljoin(history_url, status_anchor["href"])

        if date and status_raw:
            rows.append((date, status_raw, status_link))

    return rows

如果不是表格结构,就使用锚点兜底:

def _parse_history_anchor_rows(
    root: Tag,
    history_url: str,
) -> list[tuple[str, str, str]]:
    rows: list[tuple[str, str, str]] = []
    seen: set[tuple[str, str, str]] = set()

    for a in root.find_all("a", href=True):
        status_raw = normalize_space(a.get_text(" ", strip=True))

        if normalize_status(status_raw) in {"unknown", "other"}:
            continue

        parent = a.find_parent(["tr", "li", "p", "div"])
        row_text = (
            normalize_space(parent.get_text(" ", strip=True))
            if parent
            else ""
        )

        date = parse_date_to_iso(row_text)

        if not date:
            previous_text = a.previous_sibling if a.previous_sibling else ""
            date = parse_date_to_iso(str(previous_text))

        if not date:
            continue

        link = urljoin(history_url, a["href"])
        key = (date, status_raw, link)

        if key in seen:
            continue

        seen.add(key)
        rows.append(key)

    return rows

完整 history 解析函数:

def parse_history_page(
    html: str,
    history_url: str,
    fallback_item: SpecFamilyItem | None = None,
) -> list[PublicationRecord]:
    soup = BeautifulSoup(html, "lxml")
    root = soup.find("main") or soup

    h1 = root.find("h1")
    page_title = (
        clean_title_from_history_h1(h1.get_text(" ", strip=True))
        if h1
        else ""
    )

    title = fallback_item.title if fallback_item and fallback_item.title else page_title

    working_group = ""
    family = ""

    if fallback_item:
        working_group = "; ".join(fallback_item.working_groups)
        family = fallback_item.family

    rows = _parse_history_table_rows(root, history_url)

    if not rows:
        rows = _parse_history_anchor_rows(root, history_url)

    records: list[PublicationRecord] = []

    for publish_date, status_raw, link in rows:
        status_norm = normalize_status(status_raw)

        records.append(
            PublicationRecord(
                title=title,
                status=status_norm,
                status_raw=normalize_space(status_raw),
                publish_date=publish_date,
                working_group=working_group,
                link=link,
                source_history_url=history_url,
                family=family,
            )
        )

    return records

筛选 Working Draft:

def filter_working_drafts(
    records: list[PublicationRecord],
) -> list[PublicationRecord]:
    return [item for item in records if is_working_draft(item.status_raw)]

7.5 缺失字段怎么办?

真实页面不会永远按你的想象长。本文的容错策略是:

字段 缺失处理
标题 优先列表页标题,history 页标题兜底
状态 无法识别时标为 unknownother
发布日期 解析失败时为空字符串
工作组 列表页 Deliverers 找不到时为空字符串
链接 history 页链接找不到时保留空链接,并用 hash 去重
history URL 列表页找不到时跳过该规范条目

我不建议在解析阶段“猜”太多。比如工作组缺失,不要根据标题强行推断。空值是问题,但假值更麻烦。


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

本文同时导出 CSV、JSONL 和 SQLite。起步使用 CSV 就够了,但 SQLite 更适合后续增量更新和查询。

8.1 字段映射表

字段名 类型 示例值 说明
title TEXT UI Events 规范标题
status TEXT working_draft 归一化状态
status_raw TEXT Last Call Working Draft 原始状态
publish_date TEXT 2012-09-06 ISO 日期
working_group TEXT Web Applications Working Group 工作组
link TEXT https://www.w3.org/TR/2026/WD-uievents-20260221/ 历史版本链接
source_history_url TEXT https://www.w3.org/standards/history/uievents/ 来源 history 页
family TEXT UI Events 规范族
unique_key TEXT 历史版本 URL 或 hash 去重键

8.2 存储层代码

创建 src/w3c_wd_archive/storage.py

from __future__ import annotations

import csv
import json
import sqlite3
from pathlib import Path
from typing import Iterable

from .models import PublicationRecord


CSV_FIELDS = [
    "title",
    "status",
    "status_raw",
    "publish_date",
    "working_group",
    "link",
    "source_history_url",
    "family",
    "unique_key",
]


def ensure_parent(path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)


def save_csv(records: Iterable[PublicationRecord], path: Path) -> None:
    ensure_parent(path)

    with path.open("w", newline="", encoding="utf-8-sig") as fp:
        writer = csv.DictWriter(fp, fieldnames=CSV_FIELDS)
        writer.writeheader()

        for item in records:
            writer.writerow(item.to_row())


def save_jsonl(records: Iterable[PublicationRecord], path: Path) -> None:
    ensure_parent(path)

    with path.open("w", encoding="utf-8") as fp:
        for item in records:
            fp.write(json.dumps(item.to_row(), ensure_ascii=False) + "\n")


def save_sqlite(records: Iterable[PublicationRecord], db_path: Path) -> None:
    ensure_parent(db_path)

    with sqlite3.connect(db_path) as conn:
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS w3c_working_draft_history (
                unique_key TEXT PRIMARY KEY,
                title TEXT NOT NULL,
                status TEXT NOT NULL,
                status_raw TEXT,
                publish_date TEXT,
                working_group TEXT,
                link TEXT,
                source_history_url TEXT,
                family TEXT
            )
            """
        )

        conn.executemany(
            """
            INSERT OR REPLACE INTO w3c_working_draft_history (
                unique_key,
                title,
                status,
                status_raw,
                publish_date,
                working_group,
                link,
                source_history_url,
                family
            )
            VALUES (
                :unique_key,
                :title,
                :status,
                :status_raw,
                :publish_date,
                :working_group,
                :link,
                :source_history_url,
                :family
            )
            """,
            [item.to_row() for item in records],
        )

        conn.commit()

8.3 去重策略

去重策略优先级:

  1. 有历史版本链接时,使用 link 作为唯一键。
  2. 没有链接时,使用 title + status_raw + publish_date + working_group 生成 SHA1。
  3. SQLite 使用 unique_key 作为主键,重复数据执行 INSERT OR REPLACE

这种策略比较稳。URL 是最好的唯一标识;URL 缺失时,组合 hash 也能避免绝大多数重复。


9️⃣ 运行方式与结果展示

9.1 爬虫编排层

创建 src/w3c_wd_archive/crawler.py

from __future__ import annotations

import logging

from tqdm import tqdm

from .config import CrawlConfig
from .fetcher import Fetcher, FetchError
from .models import PublicationRecord, dedupe_records
from .parser import (
    parse_tr_listing,
    parse_history_page,
    filter_working_drafts,
)


logger = logging.getLogger(__name__)


def crawl_working_draft_history(
    config: CrawlConfig,
    limit: int | None = None,
    only_current_draft_families: bool = False,
) -> list[PublicationRecord]:
    fetcher = Fetcher(config)

    listing_html = fetcher.get_text(config.tr_url)
    items = parse_tr_listing(listing_html, config.base_url)

    if only_current_draft_families:
        items = [
            item
            for item in items
            if item.current_status_norm == "draft_standard"
        ]

    if limit is not None and limit > 0:
        items = items[:limit]

    records: list[PublicationRecord] = []

    for item in tqdm(items, desc="history pages"):
        try:
            html = fetcher.get_text(item.history_url)
            history_records = parse_history_page(
                html,
                item.history_url,
                fallback_item=item,
            )
            wd_records = filter_working_drafts(history_records)
            records.extend(wd_records)

        except FetchError as exc:
            logger.warning("skip history page: %s", exc)

    return dedupe_records(records)

这里的 only_current_draft_families 需要解释一下。W3C 新版列表页可能把当前仍处于草案阶段的标准轨文档显示为 Draft Standard。如果你只想先试跑当前草案族,可以加这个参数。但如果你想要完整历史 Working Draft,不建议只限制当前状态,因为很多已经成为 Standard 或 Candidate Standard 的规范,历史上也曾有 Working Draft。

9.2 命令行入口

创建 src/w3c_wd_archive/cli.py

from __future__ import annotations

import argparse
import logging
from pathlib import Path

from .config import CrawlConfig
from .crawler import crawl_working_draft_history
from .storage import save_csv, save_jsonl, save_sqlite


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description=(
            "Crawl W3C Working Draft publication history "
            "from W3C TR pages."
        )
    )

    parser.add_argument(
        "--limit",
        type=int,
        default=30,
        help="Limit history pages for a demo run.",
    )

    parser.add_argument(
        "--sleep",
        type=float,
        default=0.8,
        help="Minimum polite delay between requests.",
    )

    parser.add_argument(
        "--out-csv",
        default="data/w3c_working_drafts.csv",
    )

    parser.add_argument(
        "--out-jsonl",
        default="data/w3c_working_drafts.jsonl",
    )

    parser.add_argument(
        "--out-db",
        default="data/w3c_working_drafts.sqlite3",
    )

    parser.add_argument(
        "--only-current-draft-families",
        action="store_true",
        help=(
            "Only crawl specs whose current list-page status "
            "is Draft Standard."
        ),
    )

    parser.add_argument("--verbose", action="store_true")

    return parser


def main() -> None:
    args = build_parser().parse_args()

    logging.basicConfig(
        level=logging.DEBUG if args.verbose else logging.INFO,
        format="%(asctime)s %(levelname)s %(name)s - %(message)s",
    )

    config = CrawlConfig(min_sleep=args.sleep)

    records = crawl_working_draft_history(
        config=config,
        limit=args.limit,
        only_current_draft_families=args.only_current_draft_families,
    )

    save_csv(records, Path(args.out_csv))
    save_jsonl(records, Path(args.out_jsonl))
    save_sqlite(records, Path(args.out_db))

    print(f"saved {len(records)} records")
    print(f"csv:   {args.out_csv}")
    print(f"jsonl: {args.out_jsonl}")
    print(f"db:    {args.out_db}")


if __name__ == "__main__":
    main()

创建 src/w3c_wd_archive/__init__.py

__version__ = "0.1.0"

9.3 启动命令

在项目根目录执行:

PYTHONPATH=src python -m w3c_wd_archive.cli --limit 30 --sleep 1.0

Windows PowerShell:

$env:PYTHONPATH="src"
python -m w3c_wd_archive.cli --limit 30 --sleep 1.0

输出文件默认在:

data/w3c_working_drafts.csv
data/w3c_working_drafts.jsonl
data/w3c_working_drafts.sqlite3

如果你要完整抓取,可以逐步放开限制:

PYTHONPATH=src python -m w3c_wd_archive.cli --limit 100 --sleep 1.2

确认稳定后再运行:

PYTHONPATH=src python -m w3c_wd_archive.cli --limit 0 --sleep 1.5

这里约定 --limit 0 表示不限制数量。为了礼貌起见,全量运行时不建议把 --sleep 设置太低。

9.4 示例结果

CSV 中的结果大致如下:

title,status,status_raw,publish_date,working_group,link,source_history_url,family,unique_key
UI Events,working_draft,Working Draft,2026-02-21,Web Applications Working Group,https://www.w3.org/TR/2026/WD-uievents-20260221/,https://www.w3.org/standards/history/uievents/,UI Events,https://www.w3.org/TR/2026/WD-uievents-20260221/
UI Events,working_draft,Working Draft,2026-02-10,Web Applications Working Group,https://www.w3.org/TR/2026/WD-uievents-20260210/,https://www.w3.org/standards/history/uievents/,UI Events,https://www.w3.org/TR/2026/WD-uievents-20260210/
UI Events,working_draft,Working Draft,2024-09-07,Web Applications Working Group,https://www.w3.org/TR/2024/WD-uievents-20240907/,https://www.w3.org/standards/history/uievents/,UI Events,https://www.w3.org/TR/2024/WD-uievents-20240907/
UI Events,working_draft,Last Call Working Draft,2012-09-06,Web Applications Working Group,https://www.w3.org/TR/2012/WD-DOM-Level-3-Events-20120906/,https://www.w3.org/standards/history/uievents/,UI Events,https://www.w3.org/TR/2012/WD-DOM-Level-3-Events-20120906/

SQLite 查询示例:

sqlite3 data/w3c_working_drafts.sqlite3

进入 SQLite 后:

SELECT
    title,
    status_raw,
    publish_date,
    working_group,
    link
FROM w3c_working_draft_history
ORDER BY publish_date DESC
LIMIT 5;

按工作组统计:

SELECT
    working_group,
    COUNT(*) AS total
FROM w3c_working_draft_history
GROUP BY working_group
ORDER BY total DESC
LIMIT 20;

按年份统计:

SELECT
    substr(publish_date, 1, 4) AS year,
    COUNT(*) AS total
FROM w3c_working_draft_history
WHERE publish_date <> ''
GROUP BY year
ORDER BY year DESC;

🔟 常见问题与排错

10.1 遇到 403 怎么办?

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

  • User-Agent 不合理。
  • robots 不允许访问。
  • 请求频率或行为被识别为异常。
  • 目标页面本身不对自动化请求开放。

处理建议:

  1. 先降低频率,不要继续高频重试。
  2. 检查 robots。
  3. 使用清晰、真实的 User-Agent。
  4. 确认访问的是公开页面。
  5. 如果仍然 403,停止采集,不要尝试绕过。

不要把代理池当成默认解决方案。代理能解决的通常不是合规问题,只是把问题藏起来。

10.2 遇到 429 怎么办?

429 表示 Too Many Requests,通常是请求太快。

处理方式:

  • 增大 --sleep,比如从 1.0 调到 3.0
  • 降低抓取范围,先用 --limit 30--limit 100
  • 增加退避等待。
  • 不要并发。
  • 分批运行。

本文请求层已经对 429 做了重试,但重试只是临时补救,根本解决方法是降低频率。

10.3 HTML 抓到空壳怎么办?

如果请求得到的 HTML 只有框架,没有目标数据,常见原因是页面由 JavaScript 动态渲染。处理思路:

  1. 浏览器打开页面,查看“查看网页源代码”中是否有目标数据。
  2. 如果源代码没有,打开开发者工具 Network 面板,找真实接口。
  3. 如果有公开接口,优先抓接口。
  4. 如果没有稳定接口,再考虑 Playwright。
  5. 如果必须登录或授权,不要绕过。

本项目目标页面可以通过普通 HTML 解析,所以不需要 Playwright。

10.4 解析报错怎么办?

解析报错通常有几类:

第一类,选择器失效。页面结构变了,原来依赖的标签或 class 不存在了。本文尽量不依赖 class,而是根据 h2/h3 和相邻节点解析,就是为了减少这种风险。

第二类,字段缺失。比如某些条目没有 Deliverers,代码会输出空工作组,而不是直接崩溃。

第三类,日期解析失败。可以把原始行打印出来,观察是否出现了新的日期格式。

第四类,旧页面结构太特殊。可以针对某类旧页面增加专门的兜底解析器,但不要把所有逻辑都塞进一个函数里。

10.5 编码或乱码如何处理?

requests 一般会根据响应头设置编码,但有些页面可能没写或写得不准确。本文代码中:

if not response.encoding:
    response.encoding = response.apparent_encoding or "utf-8"

CSV 使用:

encoding="utf-8-sig"

这样 Excel 打开 CSV 时更不容易乱码。如果你主要使用 Pandas 或命令行工具,也可以改成纯 utf-8

10.6 为什么有些 Working Draft 链接看起来不是当前短名?

W3C 历史版本 URL 往往包含历史短名和发布日期。例如早期版本可能使用旧短名,后续规范族改名后 history 页面仍然保留旧版本链接。这正是归档数据的价值之一。不要强行把历史 URL 改成当前短名,否则会破坏可回溯性。

10.7 为什么工作组字段可能为空?

可能原因包括:

  • 列表页对应条目没有 Deliverers。
  • 页面结构发生变化。
  • 某些旧条目不是由 Working Group 交付。
  • 解析器没有覆盖该结构。

工程上建议先保留空值,然后针对高频缺失样本补充解析规则,而不是在第一版里过度推断。


1️⃣1️⃣ 进阶优化

11.1 温和并发

本文默认单线程。单线程的优点是稳、好排查、对站点压力小。

如果你确实需要提速,可以使用线程池,但要控制并发数,例如 2 到 4。示例思路:

from concurrent.futures import ThreadPoolExecutor, as_completed


def crawl_one(item):
    html = fetcher.get_text(item.history_url)
    records = parse_history_page(html, item.history_url, fallback_item=item)
    return filter_working_drafts(records)


with ThreadPoolExecutor(max_workers=3) as executor:
    futures = [executor.submit(crawl_one, item) for item in items]

    for future in as_completed(futures):
        records.extend(future.result())

但这里有一个坑:requests.Session 在多线程下要谨慎使用。更稳妥的做法是每个线程创建自己的 Fetcher,或者使用线程本地 session。

我个人建议:文档站点抓取,除非有明确授权和必要性,否则单线程 + 断点续跑就够了。

11.2 断点续跑

断点续跑的思路是把已抓过的 history URL 保存下来。下一次运行时先加载集合,遇到已抓 URL 就跳过。

可以新增一个 SQLite 表:

CREATE TABLE IF NOT EXISTS crawl_state (
    history_url TEXT PRIMARY KEY,
    crawled_at TEXT,
    ok INTEGER,
    error TEXT
);

每抓完一个 history 页面就写入状态。失败也写,方便后续只重试失败项。

伪代码:

if state_store.is_done(item.history_url):
    continue

try:
    records = crawl_history(item)
    state_store.mark_ok(item.history_url)
except Exception as exc:
    state_store.mark_failed(item.history_url, str(exc))

断点续跑比盲目并发更有价值。因为真实爬虫经常不是慢死的,而是跑到一半失败后不知道从哪里继续。

11.3 日志与监控

建议记录这些指标:

  • 列表页解析出多少规范条目。
  • history 页面请求成功数。
  • history 页面请求失败数。
  • 每个页面解析出多少历史版本。
  • Working Draft 记录数。
  • 空工作组数量。
  • 空链接数量。
  • 状态为 other 的数量。

日志示例:

logger.info("listing items: %s", len(items))
logger.info("records before dedupe: %s", len(records))
logger.info("records after dedupe: %s", len(deduped))

如果跑定时任务,可以把这些指标写入日志文件或监控系统。小项目用日志文件即可,大项目可以接 Prometheus、Grafana 或数据库指标表。

11.4 定时任务

Linux cron 示例:

0 3 * * 1 cd /opt/w3c_wd_archive && /opt/w3c_wd_archive/.venv/bin/python -m w3c_wd_archive.cli --limit 0 --sleep 2.0 >> logs/crawl.log 2>&1

这表示每周一凌晨 3 点运行一次。

如果你使用 Airflow,可以把流程拆成三个 task:

  1. 抓取 TR 列表页并生成 history URL 列表。
  2. 抓取 history 页面并解析记录。
  3. 导出 CSV/SQLite 或写入数据仓库。

Airflow 的好处是可视化、可重试、可追踪。缺点是部署成本高。个人项目没必要一开始就上 Airflow。

11.5 数据分析延伸

拿到数据后,可以继续做这些分析:

按年份统计 Working Draft 发布数量:

import pandas as pd

df = pd.read_csv("data/w3c_working_drafts.csv")
df["year"] = pd.to_datetime(df["publish_date"]).dt.year

yearly = (
    df.groupby("year")
      .size()
      .reset_index(name="total")
      .sort_values("year")
)

print(yearly.tail(20))

按工作组统计:

group_total = (
    df.groupby("working_group")
      .size()
      .reset_index(name="total")
      .sort_values("total", ascending=False)
)

print(group_total.head(20))

找出发布历史最长的规范:

df["date"] = pd.to_datetime(df["publish_date"], errors="coerce")

span = (
    df.dropna(subset=["date"])
      .groupby("title")
      .agg(
          first_date=("date", "min"),
          last_date=("date", "max"),
          total=("title", "size"),
      )
      .reset_index()
)

span["days"] = (span["last_date"] - span["first_date"]).dt.days

print(span.sort_values("days", ascending=False).head(20))

11.6 迁移到 Scrapy

当你需要更完整的爬虫工程能力时,可以迁移到 Scrapy:

  • Downloader Middleware 统一 headers、代理、重试。
  • Item Pipeline 负责清洗和存储。
  • DupeFilter 处理请求去重。
  • AutoThrottle 自动控制频率。
  • JOBDIR 支持断点续跑。

Scrapy 版本的结构大概是:

w3c_spider/
├── scrapy.cfg
└── w3c_spider/
    ├── items.py
    ├── pipelines.py
    ├── settings.py
    └── spiders/
        └── w3c_wd.py

但我仍然建议先用本文这种轻量版本跑通。很多时候,工具不是越重越专业,能把数据稳定拿回来才专业。

11.7 什么时候需要 Playwright?

满足以下条件时再考虑 Playwright:

  • 页面内容必须执行 JavaScript 才出现。
  • 数据接口有复杂动态参数。
  • 页面存在前端路由和异步加载。
  • 需要模拟用户点击、滚动、展开筛选项。

本文目标不需要 Playwright。用浏览器自动化抓静态文档页,通常是拿大锤敲钉子。


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

这篇文章完成了一套 W3C Working Draft 历史归档爬虫。它从 W3C TR 列表页出发,抽取规范标题、当前状态、发布日期、工作组和 history 链接,再进入每个 publication history 页面解析历史版本,最后把 Working DraftFirst Public Working DraftLast Call Working Draft 等状态归一为 working_draft,并导出 CSV、JSONL 和 SQLite。

复盘一下,我们完成了这些事情:

  1. 明确了目标字段:标题、状态、发布日期、工作组、链接。
  2. 设计了“采集 → 解析 → 清洗 → 存储”的流程。
  3. 实现了带 headers、timeout、robots 检查、重试退避和频率控制的 Fetcher。
  4. 实现了列表页解析和 history 页解析。
  5. 实现了状态字段归一。
  6. 实现了 URL 唯一去重和 SQLite 主键去重。
  7. 给出了运行命令、示例输出、排错方案和进阶优化方向。

这类爬虫最适合练工程意识。它没有复杂反爬,也没有炫目的浏览器自动化,但它把真实项目里最常见的问题都摆出来了:字段从哪里来、结构变了怎么办、状态怎么统一、失败怎么重试、数据怎么去重、输出怎么复用。

下一步可以继续做三件事。

第一,把数据分析部分补成一个 notebook,统计不同工作组的 Working Draft 发布频率。

第二,把断点续跑做完整,让全量抓取可以安全中断和恢复。

第三,迁移到 Scrapy,并加上 AutoThrottle、JOBDIR 和 pipeline,让它成为一个长期维护的小型标准追踪系统。

最后说一句个人感受:爬虫写久了会发现,真正拉开差距的不是“会不会请求页面”,而是能不能把数据解释清楚、把失败处理干净、把后续复查路径留好。W3C Working Draft 历史归档这个题目不花哨,但非常适合把这些基本功练扎实。

🌟 文末

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

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

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

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

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

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

✅ 互动征集

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

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


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


✅ 免责声明

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

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

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

更多推荐