Python 实战:采集 PostgreSQL 文档多版本目录,做一个稳定可复用的版本归档器
㊗️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~
㊙️本期爬虫难度指数:⭐⭐☆☆☆(基础级)
🉐福利: 一次订阅后,专栏内的所有文章可永久免费看,持续更新中,保底1000+(篇)硬核实战内容。
全文目录:
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战,主理专栏👉 《Python爬虫实战》:从采集策略到反爬对抗,从数据清洗到分布式调度,持续输出可复用的方法论与可落地案例。内容主打一个“能跑、能用、能扩展”,让数据价值真正做到——抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间:如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅专栏👉《Python爬虫实战》👈,一次订阅后,专栏内的所有文章可永久免费阅读,持续更新中。
💕订阅后更新会优先推送,按目录学习更高效💯~
0️⃣ 前言(Preface)
这篇文章要爬取 PostgreSQL 官网文档的多版本目录,用 Python 的 requests + BeautifulSoup + lxml 完成采集、解析、清洗和 CSV 归档,最终产出一份包含“版本、发布日期、入口链接、是否当前版本”的文档版本清单。
读完这篇文章,你可以获得三件东西:
第一,掌握一个偏真实业务的“版本目录归档”爬虫写法,不只是简单 print(title) 那种演示代码。
第二,理解如何把官网文档页、归档页和版本策略页组合起来,整理成一个结构化数据表。
第三,得到一套可直接运行、方便扩展、带重试、容错、日志、去重和导出的 Python 小项目。
我个人比较喜欢这类小爬虫,因为它不追求“多快”,而是追求“稳、干净、后续好维护”。采集 PostgreSQL 文档目录也是一个很典型的场景:页面公开、结构清晰、字段有明确业务含义,适合用来练习严肃一点的采集流程。
1️⃣ 摘要(Abstract)
本文以 PostgreSQL 官方文档多版本目录为目标站点,使用 Python 的 requests、BeautifulSoup、lxml、csv 等工具,构建一个可复现的版本目录归档爬虫,最终导出字段包括:版本、发布日期、入口链接、是否当前版本。
读完后你可以获得:
- 一个完整的静态网页采集项目结构。
- 一套从请求、解析、清洗到存储的工程化实现。
- 一个可以继续扩展到 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 主版本,例如 18、17、9.6 |
| release_date | 主版本首次发布日期,例如 2025-09-25 |
| entry_url | 在线文档入口链接 |
| is_current | 是否当前版本,布尔值,true 或 false |
为了让结果更可用,我们在代码里还会保留几个内部辅助字段,比如来源页面、采集时间、是否归档,但最终展示时仍围绕用户要求的四个字段。
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爬虫实战》,再按目录大纲顺序学习,效率十倍上升~
✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】等写成某期实战?
评论区留言告诉我你的需求,我会优先安排实现(更新)哒~
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)
✅ 免责声明
本文爬虫思路、相关技术和代码仅用于学习参考,对阅读本文后的进行爬虫行为的用户本作者不承担任何法律责任。
使用或者参考本项目即表示您已阅读并同意以下条款:
- 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
- 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
- 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
- 使用或者参考本项目即视为同意上述条款,即 “谁使用,谁负责” 。如不同意,请立即停止使用并删除本项目。!!!
更多推荐
所有评论(0)