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

全文目录:

🌟 开篇语

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

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

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

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

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

0️⃣ 前言(Preface)

这篇文章要爬取 PostgreSQL 官网文档的多版本目录,用 Python 的 requests + BeautifulSoup + lxml 完成采集、解析、清洗和 CSV 归档,最终产出一份包含“版本、发布日期、入口链接、是否当前版本”的文档版本清单。

读完这篇文章,你可以获得三件东西:

第一,掌握一个偏真实业务的“版本目录归档”爬虫写法,不只是简单 print(title) 那种演示代码。

第二,理解如何把官网文档页、归档页和版本策略页组合起来,整理成一个结构化数据表。

第三,得到一套可直接运行、方便扩展、带重试、容错、日志、去重和导出的 Python 小项目。

我个人比较喜欢这类小爬虫,因为它不追求“多快”,而是追求“稳、干净、后续好维护”。采集 PostgreSQL 文档目录也是一个很典型的场景:页面公开、结构清晰、字段有明确业务含义,适合用来练习严肃一点的采集流程。

1️⃣ 摘要(Abstract)

本文以 PostgreSQL 官方文档多版本目录为目标站点,使用 Python 的 requestsBeautifulSouplxmlcsv 等工具,构建一个可复现的版本目录归档爬虫,最终导出字段包括:版本、发布日期、入口链接、是否当前版本。

读完后你可以获得:

  1. 一个完整的静态网页采集项目结构。
  2. 一套从请求、解析、清洗到存储的工程化实现。
  3. 一个可以继续扩展到 SQLite、定时任务、监控告警的版本归档基础框架。

这篇文章的重点不是“绕过什么限制”,而是用合规、温和、可维护的方式,把公开页面整理成结构化数据。技术上看,它属于静态网页采集;工程上看,它是一个轻量级信息聚合任务;业务上看,它可以用于文档版本追踪、技术栈盘点、版本生命周期分析和内部知识库同步。

2️⃣ 背景与需求(Why)

很多技术团队内部都会维护数据库、中间件、编程语言或框架的版本清单。例如 PostgreSQL 当前有哪些主版本仍在维护?文档入口在哪里?某个老版本是不是已经进入归档?如果团队里有多套系统还跑在不同数据库版本上,这类信息就不只是“看看官网”那么简单,而是会进入巡检、升级计划、合规台账甚至知识库维护流程。

PostgreSQL 的官方文档页本身已经提供了多版本入口。新版本、当前版本、旧版本、归档版本分散在不同页面中。如果人工整理,第一次可能不难,但每隔一段时间更新一次就容易出错。版本升级、文档入口变化、归档页调整,这些都适合交给脚本做。

所以这次需求很清楚:我们不抓取文档正文,不下载 PDF,不做大规模扫描,只采集 PostgreSQL 文档多版本目录和版本策略信息,整理成一个稳定的数据表。

2.1 为什么要爬这个数据?

主要有三个原因。

2.1.1 数据分析

如果你维护多个 PostgreSQL 实例,可以把版本目录和内部资产数据关联起来。例如内部某台数据库是 PostgreSQL 13,而官方版本策略里 PostgreSQL 13 已经不再支持,那么就能自动标出风险项。

2.1.2 信息聚合

官网页面适合人阅读,但不一定适合程序消费。爬虫可以把分散在文档页、归档页、版本策略页的信息合并为统一结构,供后续 BI、日报、知识库或监控系统使用。

2.1.3 自动化归档

文档版本目录变化频率不高,但长期看会持续变化。用脚本定时采集,可以形成历史快照。以后你不仅知道“现在有哪些版本”,还知道“什么时候某个版本从支持页进入了归档页”。

2.2 目标站点

本次主要用到三个公开页面:

https://www.postgresql.org/docs/
https://www.postgresql.org/docs/manuals/archive/
https://www.postgresql.org/support/versioning/

它们分别承担不同角色。

/docs/:当前支持版本和当前文档入口。

/docs/manuals/archive/:不再支持的老版本文档入口。

/support/versioning/:版本策略、当前小版本、主版本首次发布日期、最终支持日期等信息。

本篇文章只输出核心字段,不额外采集 PDF 链接。如果后续要扩展,也可以把 A4 PDF、US PDF、支持状态、最终支持日期作为附加字段。

2.3 目标字段清单

本次输出字段如下:

字段名 说明
version PostgreSQL 主版本,例如 18179.6
release_date 主版本首次发布日期,例如 2025-09-25
entry_url 在线文档入口链接
is_current 是否当前版本,布尔值,truefalse

为了让结果更可用,我们在代码里还会保留几个内部辅助字段,比如来源页面、采集时间、是否归档,但最终展示时仍围绕用户要求的四个字段。


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

爬虫不是“能抓就抓”。越是公开页面,越要注意基本边界。一个好的采集脚本应该像一个有礼貌的访问者,而不是把自己伪装成压力测试工具。

3.1 robots.txt 基本说明

robots.txt 是站点给爬虫看的访问规则文件,一般放在网站根目录下。它不是身份验证系统,也不是权限系统,但它表达了网站对自动化访问的基本态度。做爬虫之前,应该先检查它。

对于本次任务,我们访问的是 PostgreSQL 官网公开的文档页、归档页和版本策略页。按照当前规则,普通文档页面并不在禁止路径中,但 /docs/devel//search//admin//account/ 等路径被禁止或不适合作为采集目标。

在代码里,我会加入一个 robots.txt 检查函数。它不会让项目变得复杂,但能提醒我们:采集前先确认目标 URL 是否允许被当前 User-Agent 访问。

3.2 频率控制

这个任务只需要请求三四个页面,没有必要上并发,也没有必要不断循环刷新。建议:

单次任务请求数量:3~5 个页面
请求间隔:0.5~2 秒
超时时间:10~20 秒
失败重试:2~3 次

对于这种低频、低量、公开目录页采集,我个人不建议一上来就写异步并发。代码越猛,不代表越专业。很多时候,克制才是工程经验。

3.3 不要攻击式并发

攻击式并发通常表现为:

短时间大量请求
没有 timeout
没有重试退避
反复请求同一 URL
忽略 robots.txt
伪造异常复杂的请求头

这些行为既没有必要,也容易给对方服务造成压力。本文示例会采用串行请求,并在每次请求之间加入轻微 sleep。对于版本目录这种数据,哪怕慢一秒也没有任何损失。

3.4 不采集敏感信息

本次目标是公开文档目录,不涉及用户数据、账号信息、个人信息、登录态信息、付费内容或后台接口。代码也不会尝试绕过登录、验证码、访问限制或付费墙。

3.5 中性表达与技术边界

本文所有内容仅用于技术学习、信息聚合和自动化归档。采集逻辑保持温和,不讨论绕过限制,不鼓励规避安全策略,也不把爬虫当作对抗工具。真正可长期运行的爬虫,首先应该是可解释、可控、可停止的。


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

4.1 静态 vs 动态 vs API

爬一个站点前,我习惯先判断它属于哪种类型:

类型 特点 常用工具
静态页面 HTML 源码里直接包含目标数据 requests、BeautifulSoup、lxml
动态渲染页面 数据由 JavaScript 渲染 Playwright、Selenium、接口分析
API 数据 页面通过接口返回 JSON requests、httpx

PostgreSQL 文档目录页属于静态页面。我们请求页面后,HTML 中已经包含版本链接和表格数据,不需要启动浏览器,也不需要 Playwright。

这类页面用 requests + BeautifulSoup 足够。lxml 作为解析器,速度和容错都不错。Scrapy 当然也可以,但本篇是小型归档器,用 Scrapy 反而稍重。

4.2 整体流程

流程可以概括为:

采集 Fetch
  ↓
解析 Parse
  ↓
清洗 Clean
  ↓
合并 Merge
  ↓
去重 Deduplicate
  ↓
存储 Storage

更具体一点:

1. 请求 robots.txt,检查目标 URL 是否允许采集
2. 请求 /docs/,解析当前支持版本文档入口
3. 请求 /docs/manuals/archive/,解析归档版本文档入口
4. 请求 /support/versioning/,解析版本发布日期
5. 按 version 合并文档入口和发布日期
6. 标记 is_current
7. URL 去重,版本去重
8. 导出 CSV 和 JSON

4.3 为什么不用 Playwright?

Playwright 很强,但它适合动态页面、复杂交互、前端渲染或需要浏览器上下文的场景。PostgreSQL 文档目录是传统 HTML 页面,用浏览器自动化会增加运行成本,也让部署更麻烦。

这里选 requests 的理由很直接:

页面静态
请求量小
结构稳定
部署简单
无浏览器依赖

4.4 为什么不用 Scrapy?

Scrapy 更适合中大型采集项目,比如成千上万个列表页、详情页、队列调度、去重过滤、自动限速、管道存储等。本文只有少量页面,使用 Scrapy 可以,但不划算。

我会用普通 Python 项目结构写,而不是一个单文件脚本。这样既保留轻量,又能体现工程边界。


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

5.1 Python 版本

建议使用:

Python 3.10+

我推荐 3.11 或 3.12。老版本 Python 不是不能用,但类型标注、标准库体验和运行性能会差一些。

5.2 创建虚拟环境

Linux 或 macOS:

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

Windows PowerShell:

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

5.3 安装依赖

创建 requirements.txt

requests==2.32.3
beautifulsoup4==4.12.3
lxml==5.3.0
python-dateutil==2.9.0.post0

安装:

pip install -r requirements.txt

这里没有使用太多依赖。requests 负责 HTTP 请求,beautifulsoup4 负责解析,lxml 作为 HTML parser,python-dateutil 用于日期解析。

5.4 推荐项目结构

postgres_docs_archive/
├── README.md
├── requirements.txt
├── data/
│   ├── raw/
│   └── output/
├── logs/
├── src/
│   └── postgres_docs_archive/
│       ├── __init__.py
│       ├── config.py
│       ├── models.py
│       ├── fetcher.py
│       ├── parser.py
│       ├── cleaner.py
│       ├── storage.py
│       └── main.py
└── tests/
    └── test_parser.py

如果只是学习,单文件也能跑。但我更建议拆开,因为请求、解析、清洗、存储本来就是不同职责。代码一旦要定时运行,拆分后的项目更容易排错。


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

请求层要解决几个问题:

用什么 headers
timeout 设置多少
是否需要 session
失败怎么重试
请求间隔怎么控制
robots.txt 怎么检查

6.1 headers 设计

很多入门爬虫只写一个 User-Agent,甚至直接复制浏览器请求头。其实没有必要。我们要做的是温和采集,不是伪装成真实用户。

一个合适的请求头可以这样写:

DEFAULT_HEADERS = {
    "User-Agent": (
        "postgres-docs-archive-bot/1.0 "
        "(learning project; contact: example@example.com)"
    ),
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "Accept-Language": "en-US,en;q=0.9",
    "Referer": "https://www.postgresql.org/docs/",
}

这里的 User-Agent 保持透明。实际公司项目中,可以放团队邮箱或服务名。个人学习时也可以写得简单一点,但不建议假装成浏览器。

6.2 timeout 设置

requests.get() 如果不设置 timeout,网络卡住时程序可能一直挂着。生产代码里我几乎一定会加 timeout。

timeout=(5, 15)

含义是:

连接超时:5 秒
读取超时:15 秒

对本文这种小任务来说已经够用。

6.3 session/cookie 是否需要

本次采集不需要登录,也不需要 cookie。但 requests.Session() 仍然有价值:

复用 TCP 连接
统一 headers
统一 retry 逻辑
方便后续扩展

所以我们使用 session,但不保存 cookie,也不携带登录态。

6.4 失败处理:重试和退避

网络请求失败很正常。常见情况包括:

连接超时
读取超时
临时 502/503
偶发 DNS 问题
对方服务短暂波动

重试思路如下:

最多重试 3 次
每次失败后 sleep
sleep 时间逐渐增加
4xx 错误一般不盲目重试
429 需要明显降速

下面开始写请求层代码。

6.5 config.py

# src/postgres_docs_archive/config.py

from pathlib import Path

BASE_URL = "https://www.postgresql.org"

DOCS_URL = f"{BASE_URL}/docs/"
ARCHIVE_URL = f"{BASE_URL}/docs/manuals/archive/"
VERSIONING_URL = f"{BASE_URL}/support/versioning/"
ROBOTS_URL = f"{BASE_URL}/robots.txt"

PROJECT_ROOT = Path(__file__).resolve().parents[2]
DATA_DIR = PROJECT_ROOT / "data"
RAW_DIR = DATA_DIR / "raw"
OUTPUT_DIR = DATA_DIR / "output"
LOG_DIR = PROJECT_ROOT / "logs"

for directory in (RAW_DIR, OUTPUT_DIR, LOG_DIR):
    directory.mkdir(parents=True, exist_ok=True)

DEFAULT_HEADERS = {
    "User-Agent": (
        "postgres-docs-archive-bot/1.0 "
        "(technical learning project; respectful crawling)"
    ),
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "Accept-Language": "en-US,en;q=0.9",
    "Referer": DOCS_URL,
}

REQUEST_TIMEOUT = (5, 15)
MAX_RETRIES = 3
BACKOFF_FACTOR = 1.5
REQUEST_INTERVAL_SECONDS = 1.0

这里把 URL、目录、headers、timeout 全部集中管理。后续如果站点路径变了,只需要改配置。

6.6 fetcher.py

# src/postgres_docs_archive/fetcher.py

from __future__ import annotations

import logging
import time
import urllib.robotparser
from dataclasses import dataclass
from typing import Optional

import requests
from requests import Response
from requests.exceptions import RequestException

from .config import (
    DEFAULT_HEADERS,
    REQUEST_TIMEOUT,
    MAX_RETRIES,
    BACKOFF_FACTOR,
    REQUEST_INTERVAL_SECONDS,
    ROBOTS_URL,
)

logger = logging.getLogger(__name__)


class FetchError(RuntimeError):
    """Raised when fetching a URL fails after retries."""


@dataclass
class FetchResult:
    url: str
    status_code: int
    text: str
    final_url: str


class RobotsChecker:
    """
    Lightweight robots.txt checker.

    It is intentionally simple:
    - loads robots.txt once
    - checks URL by configured User-Agent
    - fails open only when robots.txt cannot be fetched

    For strict enterprise usage, you may choose fail-closed instead.
    """

    def __init__(self, robots_url: str = ROBOTS_URL, user_agent: str = "*") -> None:
        self.robots_url = robots_url
        self.user_agent = user_agent
        self._parser: Optional[urllib.robotparser.RobotFileParser] = None

    def load(self) -> None:
        parser = urllib.robotparser.RobotFileParser()
        parser.set_url(self.robots_url)
        try:
            parser.read()
            self._parser = parser
            logger.info("robots.txt loaded: %s", self.robots_url)
        except Exception as exc:
            logger.warning("failed to load robots.txt: %s; reason=%s", self.robots_url, exc)
            self._parser = None

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

        if self._parser is None:
            logger.warning("robots.txt unavailable, continue with conservative request: %s", url)
            return True

        allowed = self._parser.can_fetch(self.user_agent, url)
        if not allowed:
            logger.warning("robots.txt disallows url: %s", url)
        return allowed


class Fetcher:
    def __init__(
        self,
        headers: Optional[dict[str, str]] = None,
        timeout: tuple[int, int] = REQUEST_TIMEOUT,
        max_retries: int = MAX_RETRIES,
        backoff_factor: float = BACKOFF_FACTOR,
        request_interval: float = REQUEST_INTERVAL_SECONDS,
        robots_checker: Optional[RobotsChecker] = None,
    ) -> None:
        self.session = requests.Session()
        self.session.headers.update(headers or DEFAULT_HEADERS)
        self.timeout = timeout
        self.max_retries = max_retries
        self.backoff_factor = backoff_factor
        self.request_interval = request_interval
        self.robots_checker = robots_checker

    def get(self, url: str) -> FetchResult:
        if self.robots_checker and not self.robots_checker.can_fetch(url):
            raise FetchError(f"Blocked by robots.txt: {url}")

        last_error: Optional[BaseException] = None

        for attempt in range(1, self.max_retries + 1):
            try:
                logger.info("fetching url=%s attempt=%s", url, attempt)
                response = self.session.get(url, timeout=self.timeout)
                self._raise_for_bad_status(response)

                # A small delay keeps the crawler gentle.
                time.sleep(self.request_interval)

                return FetchResult(
                    url=url,
                    status_code=response.status_code,
                    text=response.text,
                    final_url=response.url,
                )

            except RequestException as exc:
                last_error = exc
                sleep_seconds = self.backoff_factor ** attempt
                logger.warning(
                    "request failed url=%s attempt=%s sleep=%.2fs reason=%s",
                    url,
                    attempt,
                    sleep_seconds,
                    exc,
                )
                time.sleep(sleep_seconds)

        raise FetchError(f"Failed to fetch {url} after {self.max_retries} attempts") from last_error

    @staticmethod
    def _raise_for_bad_status(response: Response) -> None:
        """
        2xx: accept
        3xx: requests follows redirects by default
        4xx/5xx: raise, but with clearer logs
        """
        if response.status_code == 429:
            raise requests.HTTPError(
                f"429 Too Many Requests; slow down before retrying: {response.url}",
                response=response,
            )

        if 400 <= response.status_code:
            raise requests.HTTPError(
                f"HTTP {response.status_code} for {response.url}",
                response=response,
            )

这段代码不复杂,但它比随手写的 requests.get(url).text 可靠很多。爬虫里的请求层一定要尽量收口,不要让业务解析函数到处直接发 HTTP 请求,否则出了问题很难统一处理。


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

解析层要做三件事:

从文档页拿当前支持版本入口
从归档页拿旧版本入口
从版本策略页拿发布日期

7.1 解析方式选择

本文使用:

BeautifulSoup + CSS selector

不用 XPath 的原因不是 XPath 不好,而是这个页面结构比较简单,CSS selector 可读性更高。如果你习惯 XPath,也完全可以换成 lxml.html

7.2 页面结构观察

文档首页有一个 Manuals 表格,大致包含:

Online Version | PDF Version
19 beta        | A4 PDF ...
18 / Current   | A4 PDF ...
17             | A4 PDF ...
16             | A4 PDF ...
...

归档页也有类似结构:

Online Version | PDF Version
13             | A4 PDF ...
12             | A4 PDF ...
11             | A4 PDF ...
...

版本策略页有一个 Releases 表格:

Version | Current minor | Supported | First Release | Final Release
18      | 18.4          | Yes       | September 25, 2025 | November 14, 2030
17      | 17.10         | Yes       | September 26, 2024 | November 8, 2029
...

我们最终需要把这些信息按 version 合并。

7.3 缺失字段怎么办

真实网页不是数据库表,解析时要允许字段缺失。例如:

beta 版本可能没有正式发布日期
某些非常旧的版本可能没有 PDF
页面结构变化时某一行解析失败
某个版本在文档页出现,但版本策略页暂时没有

处理策略:

版本字段缺失:跳过该行
入口链接缺失:跳过该行
发布日期缺失:保留为空字符串
是否当前版本缺失:默认为 false

7.4 models.py

先定义数据模型。

# src/postgres_docs_archive/models.py

from __future__ import annotations

from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from typing import Optional


@dataclass(frozen=True)
class DocsVersionItem:
    version: str
    release_date: str
    entry_url: str
    is_current: bool
    source: str
    is_archived: bool
    scraped_at: str

    def to_dict(self) -> dict[str, object]:
        return asdict(self)


@dataclass(frozen=True)
class DocsLink:
    version: str
    entry_url: str
    is_current: bool
    is_archived: bool
    source: str


@dataclass(frozen=True)
class VersionPolicy:
    version: str
    current_minor: Optional[str]
    supported: Optional[bool]
    first_release: str
    final_release: str


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

这里把文档链接和版本策略分开。解析阶段先产出中间结构,最后再合并为 DocsVersionItem

7.5 parser.py

# src/postgres_docs_archive/parser.py

from __future__ import annotations

import logging
import re
from datetime import datetime
from typing import Iterable
from urllib.parse import urljoin

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

from .config import BASE_URL, DOCS_URL, ARCHIVE_URL, VERSIONING_URL
from .models import DocsLink, VersionPolicy

logger = logging.getLogger(__name__)

VERSION_PATTERN = re.compile(r"^(?:\d+(?:\.\d+)?)(?:\s+beta)?$", re.IGNORECASE)


def make_soup(html: str) -> BeautifulSoup:
    return BeautifulSoup(html, "lxml")


def normalize_text(text: str) -> str:
    return " ".join(text.replace("\xa0", " ").split()).strip()


def normalize_version(raw: str) -> str:
    """
    Examples:
    - "18" -> "18"
    - "18 / Current" -> "18"
    - "19 beta" -> "19 beta"
    - "9.6" -> "9.6"
    """
    text = normalize_text(raw)
    text = text.replace("/ Current", "")
    text = text.replace("Current", "")
    return normalize_text(text)


def normalize_date(raw: str) -> str:
    """
    Convert dates like "September 25, 2025" to "2025-09-25".
    Return empty string if parsing fails.
    """
    text = normalize_text(raw)
    if not text:
        return ""

    try:
        dt = date_parser.parse(text, fuzzy=True)
        return dt.date().isoformat()
    except (ValueError, TypeError, OverflowError) as exc:
        logger.warning("failed to parse date raw=%r reason=%s", raw, exc)
        return ""


def extract_first_link(cell: Tag) -> tuple[str, str]:
    """
    Return (text, href) for first anchor in a table cell.
    """
    a = cell.find("a", href=True)
    if not a:
        return "", ""

    text = normalize_text(a.get_text(" ", strip=True))
    href = urljoin(BASE_URL, a["href"])
    return text, href


def parse_docs_links(html: str, source_url: str, is_archived: bool) -> list[DocsLink]:
    """
    Parse documentation version links from:
    - https://www.postgresql.org/docs/
    - https://www.postgresql.org/docs/manuals/archive/

    The target table is simple, but we avoid relying on exact table class names.
    We scan all table rows and read the first cell's first link.
    """
    soup = make_soup(html)
    result: list[DocsLink] = []

    rows = soup.select("table tr")
    if not rows:
        logger.warning("no table rows found for source=%s", source_url)

    for row in rows:
        cells = row.find_all(["td", "th"])
        if len(cells) < 1:
            continue

        first_cell = cells[0]
        link_text, href = extract_first_link(first_cell)
        if not link_text or not href:
            continue

        full_cell_text = normalize_text(first_cell.get_text(" ", strip=True))
        version = normalize_version(full_cell_text)

        # Header rows or unexpected rows should be ignored.
        if not version or not VERSION_PATTERN.match(version):
            continue

        is_current = "current" in full_cell_text.lower()

        result.append(
            DocsLink(
                version=version,
                entry_url=href,
                is_current=is_current,
                is_archived=is_archived,
                source=source_url,
            )
        )

    logger.info("parsed docs links count=%s source=%s", len(result), source_url)
    return result


def parse_version_policies(html: str) -> dict[str, VersionPolicy]:
    """
    Parse version release information from versioning policy page.

    Expected columns:
    Version | Current minor | Supported | First Release | Final Release
    """
    soup = make_soup(html)
    policies: dict[str, VersionPolicy] = {}

    for row in soup.select("table tr"):
        cells = [normalize_text(cell.get_text(" ", strip=True)) for cell in row.find_all(["td", "th"])]
        if len(cells) < 5:
            continue

        if cells[0].lower() == "version":
            continue

        version = cells[0]
        if not version or not re.match(r"^\d+(?:\.\d+)?$", version):
            continue

        current_minor = cells[1] or None
        supported_text = cells[2].lower()
        supported = None
        if supported_text in {"yes", "no"}:
            supported = supported_text == "yes"

        first_release = normalize_date(cells[3])
        final_release = normalize_date(cells[4])

        policies[version] = VersionPolicy(
            version=version,
            current_minor=current_minor,
            supported=supported,
            first_release=first_release,
            final_release=final_release,
        )

    logger.info("parsed version policies count=%s", len(policies))
    return policies


def merge_docs_and_policies(
    docs_links: Iterable[DocsLink],
    policies: dict[str, VersionPolicy],
):
    """
    This function is implemented in cleaner.py in the final project.
    It is left here only as a reminder of the data flow.
    """
    raise NotImplementedError

这段解析代码有几个细节值得注意:

第一,不强依赖表格 class。官网如果只是改了样式 class,代码仍然能跑。

第二,版本识别用了正则。这样可以过滤掉表头、PDF 链接和其他无关行。

第三,发布日期解析统一转成 ISO 格式,也就是 YYYY-MM-DD。这个格式更适合 CSV、JSON 和数据库。

第四,归档页和当前页共用同一个 parse_docs_links(),只是通过 is_archived 参数区分来源。


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

这篇先选 CSV 和 JSON 起步。CSV 方便人工查看,也方便导入 Excel、数据库或 BI 工具。JSON 方便程序消费,也适合做中间结果。

8.1 字段映射表

字段名 类型 示例值 说明
version string 18 PostgreSQL 主版本
release_date string 2025-09-25 主版本首次发布日期
entry_url string https://www.postgresql.org/docs/18/ 在线文档入口
is_current boolean true 是否当前版本
source string https://www.postgresql.org/docs/ 数据来源页面
is_archived boolean false 是否来自归档页
scraped_at string 2026-06-08T12:00:00+00:00 采集时间

用户要求的核心字段是前四个。后面几个属于工程辅助字段,建议保留。

8.2 去重策略

本项目可以采用双重去重:

第一层:entry_url 去重
第二层:version 去重

如果同一个版本同时出现在当前页和归档页,以当前页为准。正常情况下不会出现这种冲突,但代码要考虑。

去重优先级建议:

当前版本优先
非归档版本优先
有发布日期优先
入口 URL 更标准的优先

8.3 cleaner.py

# src/postgres_docs_archive/cleaner.py

from __future__ import annotations

import logging
from collections import OrderedDict
from typing import Iterable

from .models import DocsLink, DocsVersionItem, VersionPolicy, utc_now_iso

logger = logging.getLogger(__name__)


def build_items(
    docs_links: Iterable[DocsLink],
    policies: dict[str, VersionPolicy],
) -> list[DocsVersionItem]:
    scraped_at = utc_now_iso()
    items: list[DocsVersionItem] = []

    for link in docs_links:
        policy = policies.get(link.version)
        release_date = policy.first_release if policy else ""

        item = DocsVersionItem(
            version=link.version,
            release_date=release_date,
            entry_url=link.entry_url,
            is_current=link.is_current,
            source=link.source,
            is_archived=link.is_archived,
            scraped_at=scraped_at,
        )
        items.append(item)

    return deduplicate_items(items)


def deduplicate_items(items: Iterable[DocsVersionItem]) -> list[DocsVersionItem]:
    """
    Deduplicate by version.

    Priority:
    1. current version wins
    2. non-archived version wins
    3. item with release_date wins
    """
    selected: OrderedDict[str, DocsVersionItem] = OrderedDict()

    for item in items:
        existing = selected.get(item.version)
        if existing is None:
            selected[item.version] = item
            continue

        if should_replace(existing, item):
            selected[item.version] = item

    result = list(selected.values())
    result.sort(key=version_sort_key, reverse=True)

    logger.info("deduplicated items count=%s", len(result))
    return result


def should_replace(old: DocsVersionItem, new: DocsVersionItem) -> bool:
    if new.is_current and not old.is_current:
        return True

    if old.is_archived and not new.is_archived:
        return True

    if not old.release_date and new.release_date:
        return True

    return False


def version_sort_key(item: DocsVersionItem) -> tuple[int, int, int]:
    """
    Sort versions approximately.

    Examples:
    - "18" -> (18, 0, 0)
    - "9.6" -> (9, 6, 0)
    - "19 beta" -> (19, 0, -1)
    """
    text = item.version.lower().replace("beta", "").strip()
    parts = text.split(".")

    major = safe_int(parts[0]) if len(parts) >= 1 else 0
    minor = safe_int(parts[1]) if len(parts) >= 2 else 0
    beta_flag = -1 if "beta" in item.version.lower() else 0

    return major, minor, beta_flag


def safe_int(value: str) -> int:
    try:
        return int(value)
    except ValueError:
        return 0

8.4 storage.py

# src/postgres_docs_archive/storage.py

from __future__ import annotations

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

from .models import DocsVersionItem

logger = logging.getLogger(__name__)

CORE_FIELDS = ["version", "release_date", "entry_url", "is_current"]
FULL_FIELDS = [
    "version",
    "release_date",
    "entry_url",
    "is_current",
    "source",
    "is_archived",
    "scraped_at",
]


def save_csv(items: Iterable[DocsVersionItem], path: Path, full: bool = True) -> None:
    rows = [item.to_dict() for item in items]
    fields = FULL_FIELDS if full else CORE_FIELDS

    path.parent.mkdir(parents=True, exist_ok=True)

    with path.open("w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
        writer.writeheader()
        writer.writerows(rows)

    logger.info("csv saved path=%s rows=%s", path, len(rows))


def save_json(items: Iterable[DocsVersionItem], path: Path, full: bool = True) -> None:
    rows = [item.to_dict() for item in items]

    if not full:
        rows = [
            {
                "version": row["version"],
                "release_date": row["release_date"],
                "entry_url": row["entry_url"],
                "is_current": row["is_current"],
            }
            for row in rows
        ]

    path.parent.mkdir(parents=True, exist_ok=True)

    with path.open("w", encoding="utf-8") as f:
        json.dump(rows, f, ensure_ascii=False, indent=2)

    logger.info("json saved path=%s rows=%s", path, len(rows))


def print_preview(items: Iterable[DocsVersionItem], limit: int = 5) -> None:
    rows = list(items)[:limit]

    if not rows:
        print("No data parsed.")
        return

    print("-" * 100)
    print(f"{'version':<10} {'release_date':<12} {'is_current':<10} entry_url")
    print("-" * 100)

    for item in rows:
        print(
            f"{item.version:<10} "
            f"{item.release_date:<12} "
            f"{str(item.is_current):<10} "
            f"{item.entry_url}"
        )

    print("-" * 100)

这里 CSV 使用 utf-8-sig,主要是照顾 Excel。很多中文 Windows 环境直接双击 CSV,如果没有 BOM,Excel 可能会显示乱码。虽然本文字段大多是英文,但养成这个习惯没坏处。


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

9.1 main.py

# src/postgres_docs_archive/main.py

from __future__ import annotations

import argparse
import logging
from pathlib import Path

from .cleaner import build_items
from .config import DOCS_URL, ARCHIVE_URL, VERSIONING_URL, OUTPUT_DIR, LOG_DIR
from .fetcher import Fetcher, RobotsChecker
from .parser import parse_docs_links, parse_version_policies
from .storage import save_csv, save_json, print_preview


def setup_logging(verbose: bool = False) -> None:
    LOG_DIR.mkdir(parents=True, exist_ok=True)
    log_file = LOG_DIR / "postgres_docs_archive.log"

    level = logging.DEBUG if verbose else logging.INFO

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


def run(output_dir: Path = OUTPUT_DIR, full_fields: bool = True) -> None:
    robots_checker = RobotsChecker(user_agent="postgres-docs-archive-bot")
    fetcher = Fetcher(robots_checker=robots_checker)

    docs_html = fetcher.get(DOCS_URL).text
    archive_html = fetcher.get(ARCHIVE_URL).text
    versioning_html = fetcher.get(VERSIONING_URL).text

    docs_links = parse_docs_links(
        html=docs_html,
        source_url=DOCS_URL,
        is_archived=False,
    )
    archive_links = parse_docs_links(
        html=archive_html,
        source_url=ARCHIVE_URL,
        is_archived=True,
    )
    policies = parse_version_policies(versioning_html)

    items = build_items(
        docs_links=[*docs_links, *archive_links],
        policies=policies,
    )

    csv_path = output_dir / "postgresql_docs_versions.csv"
    json_path = output_dir / "postgresql_docs_versions.json"

    save_csv(items, csv_path, full=full_fields)
    save_json(items, json_path, full=full_fields)

    print_preview(items, limit=5)
    print(f"CSV output:  {csv_path}")
    print(f"JSON output: {json_path}")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Archive PostgreSQL documentation version directory."
    )
    parser.add_argument(
        "--output-dir",
        default=str(OUTPUT_DIR),
        help="Output directory for CSV and JSON files.",
    )
    parser.add_argument(
        "--core-fields-only",
        action="store_true",
        help="Only export required fields: version, release_date, entry_url, is_current.",
    )
    parser.add_argument(
        "--verbose",
        action="store_true",
        help="Enable debug logs.",
    )
    return parser.parse_args()


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

    run(
        output_dir=Path(args.output_dir),
        full_fields=not args.core_fields_only,
    )


if __name__ == "__main__":
    main()

9.2 入口文件运行

如果在项目根目录,可以用模块方式运行:

python -m src.postgres_docs_archive.main

不过更推荐把 src 加到 Python path,或者创建一个更标准的包。为了学习阶段简单,可以这样运行:

Linux/macOS:

PYTHONPATH=. python -m src.postgres_docs_archive.main

Windows PowerShell:

$env:PYTHONPATH="."
python -m src.postgres_docs_archive.main

只导出核心字段:

PYTHONPATH=. python -m src.postgres_docs_archive.main --core-fields-only

输出到指定目录:

PYTHONPATH=. python -m src.postgres_docs_archive.main --output-dir data/output

打开详细日志:

PYTHONPATH=. python -m src.postgres_docs_archive.main --verbose

9.3 输出文件在哪里?

默认输出:

data/output/postgresql_docs_versions.csv
data/output/postgresql_docs_versions.json

日志文件:

logs/postgres_docs_archive.log

9.4 示例结果

示例 CSV 可能长这样:

version,release_date,entry_url,is_current
19 beta,,https://www.postgresql.org/docs/19/,false
18,2025-09-25,https://www.postgresql.org/docs/18/,true
17,2024-09-26,https://www.postgresql.org/docs/17/,false
16,2023-09-14,https://www.postgresql.org/docs/16/,false
15,2022-10-13,https://www.postgresql.org/docs/15/,false

如果导出完整字段,可能类似:

version,release_date,entry_url,is_current,source,is_archived,scraped_at
19 beta,,https://www.postgresql.org/docs/19/,false,https://www.postgresql.org/docs/,false,2026-06-08T12:00:00+00:00
18,2025-09-25,https://www.postgresql.org/docs/18/,true,https://www.postgresql.org/docs/,false,2026-06-08T12:00:00+00:00
17,2024-09-26,https://www.postgresql.org/docs/17/,false,https://www.postgresql.org/docs/,false,2026-06-08T12:00:00+00:00
16,2023-09-14,https://www.postgresql.org/docs/16/,false,https://www.postgresql.org/docs/,false,2026-06-08T12:00:00+00:00
15,2022-10-13,https://www.postgresql.org/docs/15/,false,https://www.postgresql.org/docs/,false,2026-06-08T12:00:00+00:00

这里 19 beta 没有正式发布日期,是可以接受的。它不是正式稳定主版本,在 versioning policy 表里不一定作为正式主版本出现。我们不强行补一个日期,而是保留为空。数据宁可空,也不要猜。


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

爬虫最烦人的地方不是“第一次跑通”,而是“过几个月还能不能跑”。下面列一些很常见的问题。

10.1 遇到 403 怎么办?

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

User-Agent 过于异常
请求头太少
访问路径不适合爬取
IP 或网络环境被限制

处理建议:

第一,确认目标页面在浏览器中是否可公开访问。

第二,检查 robots.txt,不访问被禁止路径。

第三,设置清晰、透明的 User-Agent。

第四,降低请求频率。

第五,不要把代理当成默认解决方案。代理可以用于网络连通性问题,但不应该用于绕过站点规则。

本文这种任务如果遇到 403,优先检查 headers 和路径,而不是马上换代理。

10.2 遇到 429 怎么办?

429 表示请求过多。对于本文任务,如果只请求几个页面,正常不应该遇到 429。遇到后说明可能有以下问题:

脚本被频繁定时触发
异常重试没有限制
多人共用出口 IP
循环逻辑写错

解决方式:

加大 REQUEST_INTERVAL_SECONDS
降低 MAX_RETRIES
检查定时任务
增加缓存
失败后停止而不是无限重试

例如把间隔改成:

REQUEST_INTERVAL_SECONDS = 5.0

并把重试改成:

MAX_RETRIES = 2

10.3 HTML 抓到空壳怎么办?

如果你请求页面后发现 HTML 里没有目标数据,只看到一堆 JavaScript,很可能是动态渲染页面。处理思路有两个:

第一,打开浏览器开发者工具,查看 Network 里是否有 JSON 接口。

第二,如果确实必须渲染,再考虑 Playwright。

本文目标页面是静态 HTML,不需要 Playwright。但如果未来 PostgreSQL 官网改版,变成前端渲染,那就要重新评估。

10.4 解析报错怎么办?

常见解析报错包括:

NoneType has no attribute get_text
list index out of range
日期解析失败
版本号提取错误

解决方式:

第一,不要假设每一行都有 <td>

第二,不要假设每个 <td> 里都有 <a>

第三,解析前先判断长度。

第四,字段缺失时记录 warning,而不是直接崩溃。

本文代码里这些点已经做了基础处理。

10.5 页面结构变化怎么办?

如果官网表格结构变化,选择器可能失效。建议:

日志里记录解析到的行数
结果为空时主动报错
保留 raw HTML 方便排查
写 parser 单元测试

你可以增加保存原始 HTML 的逻辑:

from pathlib import Path

def save_raw_html(name: str, html: str, raw_dir: Path) -> None:
    path = raw_dir / name
    path.write_text(html, encoding="utf-8")

然后在 main.py 里保存:

from .config import RAW_DIR

(RAW_DIR / "docs.html").write_text(docs_html, encoding="utf-8")
(RAW_DIR / "archive.html").write_text(archive_html, encoding="utf-8")
(RAW_DIR / "versioning.html").write_text(versioning_html, encoding="utf-8")

调试时非常有用。

10.6 编码或乱码怎么处理?

requests 会根据响应头猜测编码,但不一定百分百准确。PostgreSQL 官网通常没有这个问题。如果遇到乱码,可以这样处理:

response.encoding = response.apparent_encoding

但不要一上来就强制设置。更稳妥的方式是在发现异常时打印:

print(response.encoding)
print(response.apparent_encoding)

CSV 乱码主要发生在 Excel 打开时,所以本文保存 CSV 使用:

encoding="utf-8-sig"

10.7 日期解析失败怎么办?

版本策略页中的日期是英文格式,例如:

September 25, 2025

python-dateutil 可以解析。解析失败时,本文代码返回空字符串,并记录 warning。这比程序直接崩掉更合适。

如果你想更严格,可以改成失败即抛异常:

def normalize_date(raw: str) -> str:
    dt = date_parser.parse(raw, fuzzy=True)
    return dt.date().isoformat()

但归档任务一般更适合“部分失败可输出”。

10.8 当前版本识别错了怎么办?

当前版本来自文档页里 Current 文本。解析逻辑是:

is_current = "current" in full_cell_text.lower()

如果官网未来不再用 Current 这个词,而改成徽章、图标或其他结构,这里就需要调整。可以增加第二种策略:如果某个链接指向 /docs/current/,也标记为当前版本。

增强版写法:

is_current = (
    "current" in full_cell_text.lower()
    or href.rstrip("/").endswith("/docs/current")
)

不过当前输出希望入口链接是具体版本路径,比如 /docs/18/,所以可以把 Current 当作标记,而不是入口链接。


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

11.1 并发优化

本项目请求量很少,不需要并发。但如果你扩展到采集每个版本的文档章节目录,就可能出现几十到几百个页面。那时可以考虑:

ThreadPoolExecutor
asyncio + httpx
Scrapy

不过并发不是越高越好。对于文档站,我个人会把并发控制在很低:

并发数:2~5
请求间隔:1~3 秒
失败退避:指数退避

用线程池的示例:

from concurrent.futures import ThreadPoolExecutor, as_completed

def fetch_many(fetcher, urls: list[str], max_workers: int = 3) -> dict[str, str]:
    result = {}

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        future_map = {
            executor.submit(fetcher.get, url): url
            for url in urls
        }

        for future in as_completed(future_map):
            url = future_map[future]
            try:
                result[url] = future.result().text
            except Exception as exc:
                result[url] = ""
                print(f"failed: {url}, reason={exc}")

    return result

但再次强调,本文主任务不需要并发。

11.2 断点续跑

如果任务扩展到很多页面,就需要断点续跑。基本思路:

已完成 URL 写入 seen_urls.txt
每次启动先加载 seen_urls
成功后追加写入
失败 URL 写入 failed_urls.txt

示例:

from pathlib import Path

class SeenStore:
    def __init__(self, path: Path) -> None:
        self.path = path
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self.seen = self._load()

    def _load(self) -> set[str]:
        if not self.path.exists():
            return set()
        return {
            line.strip()
            for line in self.path.read_text(encoding="utf-8").splitlines()
            if line.strip()
        }

    def contains(self, url: str) -> bool:
        return url in self.seen

    def add(self, url: str) -> None:
        if url in self.seen:
            return
        with self.path.open("a", encoding="utf-8") as f:
            f.write(url + "\n")
        self.seen.add(url)

11.3 日志与监控

一个定时运行的爬虫至少要知道:

本次请求了多少 URL
成功多少
失败多少
解析出多少版本
输出多少行
是否出现空结果

最简单的监控就是日志。更进一步,可以把结果写入 SQLite,再统计每次采集行数。

示例日志:

2026-06-08 09:30:00 | INFO | fetching url=https://www.postgresql.org/docs/
2026-06-08 09:30:01 | INFO | parsed docs links count=7
2026-06-08 09:30:02 | INFO | parsed version policies count=24
2026-06-08 09:30:03 | INFO | csv saved rows=25

如果某次 parsed docs links count=0,就应该重点排查。

11.4 定时任务

Linux cron 示例:

0 9 * * 1 cd /opt/postgres_docs_archive && /opt/postgres_docs_archive/.venv/bin/python -m src.postgres_docs_archive.main >> logs/cron.log 2>&1

含义是每周一上午 9 点运行一次。

对于版本目录这种数据,一周一次甚至一个月一次都足够。不要用分钟级频率跑。

11.5 SQLite 存储

如果你想保留历史快照,可以用 SQLite。表结构示例:

CREATE TABLE IF NOT EXISTS postgresql_docs_versions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    version TEXT NOT NULL,
    release_date TEXT,
    entry_url TEXT NOT NULL,
    is_current INTEGER NOT NULL,
    source TEXT,
    is_archived INTEGER NOT NULL,
    scraped_at TEXT NOT NULL,
    UNIQUE(version, scraped_at)
);

Python 写入示例:

import sqlite3
from pathlib import Path

from .models import DocsVersionItem

def save_sqlite(items: list[DocsVersionItem], db_path: Path) -> None:
    db_path.parent.mkdir(parents=True, exist_ok=True)

    conn = sqlite3.connect(db_path)
    try:
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS postgresql_docs_versions (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                version TEXT NOT NULL,
                release_date TEXT,
                entry_url TEXT NOT NULL,
                is_current INTEGER NOT NULL,
                source TEXT,
                is_archived INTEGER NOT NULL,
                scraped_at TEXT NOT NULL,
                UNIQUE(version, scraped_at)
            )
            """
        )

        conn.executemany(
            """
            INSERT OR IGNORE INTO postgresql_docs_versions (
                version,
                release_date,
                entry_url,
                is_current,
                source,
                is_archived,
                scraped_at
            )
            VALUES (?, ?, ?, ?, ?, ?, ?)
            """,
            [
                (
                    item.version,
                    item.release_date,
                    item.entry_url,
                    int(item.is_current),
                    item.source,
                    int(item.is_archived),
                    item.scraped_at,
                )
                for item in items
            ],
        )

        conn.commit()
    finally:
        conn.close()

如果你要做历史分析,SQLite 比 CSV 更合适。比如查询某个版本什么时候从当前支持页消失,或者每次采集当前版本是否发生变化。

11.6 内容 hash 去重

本文按 version 去重就够了。如果要更通用,可以增加内容 hash:

import hashlib

def make_item_hash(version: str, entry_url: str, release_date: str) -> str:
    raw = f"{version}|{entry_url}|{release_date}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()

内容 hash 适合判断“这一行数据是否变化”。如果某个版本的入口 URL 或发布日期变化,hash 就会变化。

11.7 告警机制

如果你把它放进团队任务里,可以设置简单告警:

结果为空:告警
当前版本数量不是 1:告警
核心字段缺失率过高:告警
请求连续失败:告警

伪代码:

def validate_items(items):
    if not items:
        raise RuntimeError("No items parsed. The page structure may have changed.")

    current_items = [item for item in items if item.is_current]
    if len(current_items) != 1:
        raise RuntimeError(f"Expected exactly one current version, got {len(current_items)}.")

    missing_url = [item for item in items if not item.entry_url]
    if missing_url:
        raise RuntimeError(f"Some items have empty entry_url: {missing_url}")

这个校验很实用。很多爬虫不是因为请求失败而坏掉,而是页面结构变了,但程序还“正常输出了空数据”。这种情况最危险。


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

这篇文章围绕 PostgreSQL 文档多版本目录,完成了一个完整的静态页面采集项目。我们没有追求花哨技术,而是把一个小需求做完整:

明确目标字段
检查合规边界
选择轻量技术栈
封装请求层
编写解析层
清洗和合并数据
按版本去重
导出 CSV 和 JSON
补充排错和扩展方向

最终产出的核心字段是:

版本
发布日期
入口链接
是否当前版本

这个项目看起来小,但它覆盖了很多真实采集任务都会遇到的问题:页面来源不止一个、字段需要合并、日期需要标准化、缺失值不能乱填、请求要有 timeout、失败要有重试、输出要能被人和程序同时使用。

我个人觉得,爬虫写到最后,拼的不是“能不能抓到”,而是“过一段时间还能不能稳定抓到”。尤其是文档版本目录这种信息,更新频率低、价值持续存在,最适合用稳妥的方式做自动化归档。

下一步可以继续扩展:

把 CSV 换成 SQLite,保留历史快照
加入版本支持状态和最终支持日期
采集每个版本的章节目录
把结果同步到内部知识库
使用 GitHub Actions 定时运行
使用 Scrapy 管理更大的文档采集任务
遇到动态页面时再引入 Playwright

如果你正在练习 Python 爬虫,我建议不要一开始就找复杂站点。像 PostgreSQL 文档目录这种公开、规范、结构清晰的页面,反而更能训练工程能力。把请求、解析、清洗、存储这些基础环节写稳,后面做更复杂的采集项目会轻松很多。

🌟 文末

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

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

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

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

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

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

✅ 互动征集

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

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


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


✅ 免责声明

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

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

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

更多推荐