周一至周五 09:00 - 18:00(UTC+08:00) 
©2026 NST LABS TECH LTD. 保留所有权利。 Python网页爬虫项目:一个可靠的数据管道Lena ZhouGrowth & Integration Specialist
Python网页爬虫项目:构建一个可靠的管道
TL;DR
- 一个有用的 Python 网络抓取项目应该生成可重复、经过验证的记录,而不是只在打印 HTML 后停止。
- 以下教程从《Books to Scrape》收集第一个目录页面,将每本书标准化为稳定的模式,拒绝不完整的记录,并将接受的行更新到 SQLite。
- 超时、状态检查、有界重试、稳定的 ID 和幂等存储使演示脚本与可维护管道之间有所区别。
- 静态 HTML 非常适合 Requests 和 Beautiful Soup; JavaScript 渲染的页面需要浏览器或管理渲染层。
- 只收集授权或公开的数据,遵守适用条款和隐私义务,并保持请求量的有界。
Python 网络抓取项目:您将构建的内容
该 Python 网络抓取项目从 HTTP 响应构建了一个小而面向生产的管道,最终生成经过验证的 SQLite 行。它使用专门构建的公共练习网站《Books to Scrape》,处理一个目录页面,并存储标题、价格、可用性、详细 URL、源页面、检索时间和稳定的记录 ID。
管道有五个阶段:获取、解析、标准化、验证和更新。每个阶段都有明确的输入和输出,因此可以在不重新运行无关工作时诊断失败。项目故意仅限于一个页面,并且不收集个人或敏感信息。
当相同的工作流程必须渲染 JavaScript、抓取有限网站或返回页面工件而不操作浏览器工作者和提取基础设施时,Nstproxy Crawl 是管理的替代方案。对于静态教程 HTML,本地 Python 堆栈仍然是学习机制的最清晰方式。
管道概览
该项目将一个公共目录 URL 转换为幂等的本地数据集。
| 阶段 | 输入 | 操作 | 输出 | 失败边界 |
|---|
| 获取 | URL | 带有超时和重试策略的 GET | HTTP 响应 | 网络、状态、内容类型 |
| 解析 | HTML | 选择产品卡片 | 原始字段候选 | 标记更改 |
| 标准化 | 候选字段 | 解析 URL、解析小数、清理文本 | 类型记录 | 字段格式无效 |
| 验证 | 类型记录 | 强制执行必需字段和域规则 | 接受记录 | 缺失或不合理的值 |
| 存储 | 接受记录 | 根据稳定 ID 进行 SQLite 更新 | 可重复的表格 | 架构或磁盘错误 |
这种分离反映了 Nstproxy 为 抓取与爬虫 提供的更广泛区分:提取过程处理页面内容,而爬虫发现并调度页面。
先决条件
该项目需要 Python 3、requests 包、Beautiful Soup 4 和对 https://books.toscrape.com/ 的网络访问。使用虚拟环境,以便依赖项的更改保持在项目内。
python3 -m venv .venv
source .venv/bin/activate
python -m pip install requests beautifulsoup4
详细教程
详细教程组装了一个可执行脚本,然后验证其数据库输出。
方法 1:构建一个静态 HTML 抓取管道
Requests 加上 Beautiful Soup 是当所需数据存在于初始 HTML 响应中时,最简单可靠的方法。
步骤 1:定义模式和稳定 ID
在解析开始之前,模式应该是明确的。稳定的 ID 防止在每次运行时相同的源项目创建重复项。该项目对规范的详细 URL 进行哈希,该 URL 在每次执行时保持确定性。
必需字段包括 record_id、title、price_gbp、in_stock、detail_url、source_url 和 retrieved_at。检索时间戳在每次运行时都会改变;而稳定的记录 ID 不会。
步骤 2:以超时和有界重试进行获取
没有超时的请求可能会无限期挂起。重试策略应覆盖瞬态连接失败和选定的服务器响应,而不是无效的 URL 或永久的客户端错误。该脚本使用了一个小的重试预算,并通过 urllib3 的适配器履行标准重试行为。
在解析之前,响应必须通过三个检查:成功状态、预期的 HTML 内容类型以及非空正文。包含错误页面的 200 响应仍需在稍后进行语义检查。
步骤 3:解析持久卡片结构
教程目标将产品卡片暴露为 article.product_pod。在每个卡片中,标题放在链接的图像标题中,价格使用 .price_color,可用性使用 .availability,相对详细 URL 来自 h3 a。
选择器应该表达页面的含义,而不是偶然的视觉位置。Nstproxy 的 Beautiful Soup 术语表 提供了有关解析器的额外背景。即使是耐用的选择器也可能会变化,因此管道会计算被拒绝的记录并在未找到产品卡时失败。
第 4 步:规范化和验证值
规范化将相对链接转换为绝对 URL,折叠空白,并将价格文本解析为 Decimal。验证会拒绝标题为空、价格为非正数、详细 URL 超出预期主机或其他缺失必填字段的记录。
验证保护下游存储不受语法解析但语义错误的数据影响。选择器可以匹配错误的元素,但仍然返回字符串;类型转换和域规则捕捉到部分失败类。
第 5 步:插入或更新到 SQLite
SQLite 为教程提供了一个耐用的输出,无需外部服务。该表将 record_id 用作主键,插入语句在冲突时更新现有行。SQLite UPSERT 文档 定义了这一行为。
幂等性使得重新运行变得安全:相同目录页面的行数保持稳定,而可变字段和检索时间可以更新。
.node.mid { top: 85px; left: 260px; }
.node.left { top: 114px; left: 76px; }
.node.center { top: 114px; left: 260px; }
.node.right { top: 114px; left: 444px; }
.format-card {
position: absolute;
top: 127px;
z-index: 2;
width: 162px;
height: 136px;
padding: 15px;
border: 1px solid #d5d9e0;
border-radius: 11px;
background: rgba(255,255,255,.91);
box-shadow: 0 1px 1px rgba(20,30,50,.02);
}
.format-card.markdown { left: 0; }
.format-card.json { left: 184px; }
.format-card.screenshot { left: 368px; }
.card-title { height: 28px; white-space: nowrap; font-size: 16px; font-weight: 530; line-height: 28px; }
.mini-icon {
display: inline-block; width: 28px; height: 28px; margin-right: 8px; border: 1.5px solid #5a86ff;
border-radius: 6px; color: #1c5eff; font-size: 13px; font-weight: 700; line-height: 25px; text-align: center; vertical-align: top;
}
.image-icon { position: relative; }
.image-icon:before { content: ""; position: absolute; left: 7px; top: 14px; width: 13px; height: 8px; background: #3a70ff; clip-path: polygon(0 100%,35% 35%,55% 65%,72% 45%,100% 100%); }
.image-icon:after { content: ""; position: absolute; right: 6px; top: 6px; width: 4px; height: 4px; border-radius: 50%; background: #3a70ff; }
.line { height: 7px; margin-top: 10px; border-radius: 4px; background: #e6e8ec; }
.line.short { width: 65%; }
.line.tiny { width: 45%; }
.code-copy { margin-top: 8px; font-family: "SFMono-Regular", Consolas, monospace; color: #858b97; font-size: 12px; line-height: 1.45; }
.shot-window { margin-top: 12px; height: 62px; overflow: hidden; border: 1px solid #c8cdd6; border-radius: 6px; background: #f6f7f9; }
.shot-top { height: 13px; border-bottom: 1px solid #d7dbe1; background: #fff; }
.dots { display: inline-block; width: 4px; height: 4px; margin: 4px 0 0 5px; border-radius: 50%; background: #ccd0d7; box-shadow: 7px 0 #ccd0d7, 14px 0 #ccd0d7; }
.shot-hero { float: left; width: 68px; height: 30px; margin: 10px 8px; border-radius: 3px; background: #d2d5db; }
.shot-copy { margin: 10px 8px 0 86px; height: 6px; border-radius: 3px; background: #d5d8dd; box-shadow: 0 12px #e0e2e6, -12px 24px #d5d8dd; }
@media only screen and (max-width: 650px) {
.canvas { padding: 12px; }
.copy-cell, .visual-cell { display: block; width: 100%; }
.copy-cell { padding: 32px 24px 16px; text-align: center; }
.visual-cell { padding: 16px 12px 28px; }
.description br { display: none; }
.cta { margin-top: 22px; }
.crawl-visual { transform-origin: top center; transform: scale(.88); margin: 0 auto -31px; }
}
@media only screen and (max-width: 520px) {
h1 { font-size: 22px; }
.description { font-size: 14px; }
.crawl-visual { left: 50%; margin-left: -265px; transform: scale(.62); margin-bottom: -99px; }
}
</style>
<main class="canvas">
<section class="feature" aria-label="Nstproxy抓取概览">
<table class="layout" role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td class="copy-cell">
<h1>超越静态HTML的规模</h1>
<p class="description">使用Nstproxy Crawl进行托管渲染、有界发现和结构化页面输出。</p>
<a class="cta" href="https://app.nstproxy.com/auth/login?utm_source=official&utm_medium=blog&utm_campaign=/python-web-scraping-project/" target="_blank" rel="noopener">探索Nstproxy Crawl</a>
</td>
<td class="visual-cell">
<div class="crawl-visual" aria-label="将URL转换为Markdown、JSON和屏幕截图的图示">
<div class="url-bar">
<svg class="url-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M10.6 13.4a1 1 0 0 0 1.4 1.4l3.5-3.5a3 3 0 0 0-4.2-4.2L9.5 8.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M13.4 10.6a1 1 0 0 0-1.4-1.4l-3.5 3.5a3 3 0 0 0 4.2 4.2l1.8-1.8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
<span class="url-text">https://example.com/article</span>
<span class="crawl-button">抓取</span>
</div>
<span class="stem"></span><span class="branch"></span><span class="branch-cap-left"></span><span class="branch-cap-right"></span>
<span class="drop one"></span><span class="drop two"></span><span class="drop three"></span>
<span class="node top"></span><span class="node mid"></span><span class="node left"></span><span class="node center"></span><span class="node right"></span>
<div class="format-card markdown">
<div class="card-title"><span class="mini-icon">M↵</span>Markdown</div>
<div class="line"></div><div class="line"></div><div class="line short"></div><div class="line tiny"></div>
</div>
<div class="format-card json">
<div class="card-title"><span class="mini-icon">{}</span>JSON</div>
<div class="code-copy">{<br> "title": "...",<br> "url": "..."<br>}</div>
</div>
<div class="format-card screenshot">
<div class="card-title"><span class="mini-icon image-icon"></span>截图</div>
<div class="shot-window"><div class="shot-top"><span class="dots"></span></div><div class="shot-hero"></div><div class="shot-copy"></div></div>
</div>
</div>
</td>
</tr>
</table>
</section>
</main>
#### 第六步:运行完整项目
以下脚本包含完整的有界流水线。
```python
from __future__ import annotations
import hashlib
import re
import sqlite3
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
from urllib.parse import urljoin, urlparse
import requests
from bs4 import BeautifulSoup
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
SOURCE_URL = "https://books.toscrape.com/catalogue/page-1.html"
ALLOWED_HOST = "books.toscrape.com"
DB_PATH = "books.db"
@dataclass(frozen=True)
class BookRecord:
record_id: str
title: str
price_gbp: str
in_stock: bool
detail_url: str
source_url: str
retrieved_at: str
def build_session() -> requests.Session:
retry = Retry(
total=3,
backoff_factor=0.5,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset({"GET"}),
respect_retry_after_header=True,
)
session = requests.Session()
session.headers["User-Agent"] = "NstproxyTutorialBot/1.0 (bounded demo)"
session.mount("https://", HTTPAdapter(max_retries=retry))
return session
def fetch_html(session: requests.Session, url: str) -> str:
response = session.get(url, timeout=(5, 20))
response.raise_for_status()
content_type = response.headers.get("Content-Type", "").lower()
if "text/html" not in content_type:
raise ValueError(f"unexpected content type: {content_type}")
if not response.text.strip():
raise ValueError("empty HTML response")
return response.text
def parse_price(text: str) -> Decimal:
match = re.search(r"\d+(?:\.\d+)?", text.replace(",", ""))
if not match:
raise ValueError(f"invalid price: {text!r}")
try:
value = Decimal(match.group(0))
except InvalidOperation as exc:
raise ValueError(f"invalid price: {text!r}") from exc
if value <= 0:
raise ValueError(f"nonpositive price: {value}")
return value
def parse_books(html: str, source_url: str) -> tuple[list[BookRecord], int]:
soup = BeautifulSoup(html, "html.parser")
cards = soup.select("article.product_pod")
if not cards:
raise ValueError("no product cards found; markup may have changed")
retrieved_at = datetime.now(timezone.utc).isoformat()
accepted: list[BookRecord] = []
rejected = 0
for card in cards:
try:
link = card.select_one("h3 a")
price_node = card.select_one(".price_color")
availability_node = card.select_one(".availability")
if not link or not price_node or not availability_node:
raise ValueError("missing required node")
title = (link.get("title") or link.get_text(" ", strip=True)).strip()
detail_url = urljoin(source_url, link.get("href", ""))
if not title or urlparse(detail_url).hostname != ALLOWED_HOST:
raise ValueError("invalid title or detail URL")
price = parse_price(price_node.get_text(" ", strip=True))
stable_id = hashlib.sha256(detail_url.encode("utf-8")).hexdigest()
accepted.append(
BookRecord(
record_id=stable_id,
title=title,
price_gbp=f"{price:.2f}",
in_stock="in stock" in availability_node.get_text(" ", strip=True).lower(),
detail_url=detail_url,
source_url=source_url,
retrieved_at=retrieved_at,
)
)
except (AttributeError, TypeError, ValueError):
rejected += 1
return accepted, rejected
def store_books(records: list[BookRecord], db_path: str) -> int:
with sqlite3.connect(db_path) as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS books (
record_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
price_gbp TEXT NOT NULL,
in_stock INTEGER NOT NULL,
detail_url TEXT NOT NULL,
source_url TEXT NOT NULL,
retrieved_at TEXT NOT NULL
)
"""
)
connection.executemany(
"""
INSERT INTO books VALUES (
:record_id, :title, :price_gbp, :in_stock,
:detail_url, :source_url, :retrieved_at
)
ON CONFLICT(record_id) DO UPDATE SET
title = excluded.title,
price_gbp = excluded.price_gbp,
in_stock = excluded.in_stock,
source_url = excluded.source_url,
retrieved_at = excluded.retrieved_at
""",
[asdict(record) for record in records],
)
return connection.execute("SELECT COUNT(*) FROM books").fetchone()[0]
def main() -> None:
with build_session() as session:
html = fetch_html(session, SOURCE_URL)
records, rejected = parse_books(html, SOURCE_URL)
if not records:
raise RuntimeError("没有有效记录被接受")
total_rows = store_books(records, DB_PATH)
print(f"接受={len(records)} 拒绝={rejected} 总行数={total_rows}")
print(f"样本={records[0].title!r} 价格_gbp={records[0].price_gbp}")
if name == "main":
main()
将文件保存为 `scrape_books.py`,运行两次,并确认数据库行数没有翻倍。
```bash
python scrape_books.py
python scrape_books.py
sqlite3 books.db 'SELECT COUNT(*), MIN(price_gbp), MAX(price_gbp) FROM books;'
本文的验证运行从第一页接受了 20 条记录,拒绝了 0 条,并在第二次执行后保持了 20 条总行数。这些计数属于在验证时专门构建的测试页面;生产目标需要自己的断言。
方法 2: 使用托管爬虫进行渲染或多页目标爬取
当输入跨越一个网站、需要 JavaScript 渲染或需要操作任务状态和工件存储时,托管爬虫是合适的。Nstproxy Crawl 支持页面和有限网站工作流,而其实时定价页面描述了每个 URL 的使用情况和单独记账的代理流量,无需本文冻结数值价格。
使用显式深度、页面计数、包含和排除边界。仅请求管道消耗的输出格式。检查响应体的成功和任务状态,而不是假设接受的 HTTP 请求意味着每个页面都成功。
Nstproxy 的 Crawl 启动概述 提供了产品背景,但当前的产品和 API 页面应控制实现决策。托管层减少了爬虫操作;它并不能替代特定业务的验证、标准化、存储或法律审核。
测试和接受检查
测试应该证明管道返回预期的记录,处理变化,并安全地重新运行。
- 样本测试: 保存一个允许的样本页面并断言选择器计数和代表性的解析值。
- 模式测试: 要求每个接受的记录匹配字段类型和不变性。
- 语义测试: 验证样本标题、URL 和价格与渲染页面相匹配。
- 幂等性测试: 运行两次并断言稳定的行不重复。
- 失败测试: 模拟超时、非 HTML 响应、空页面、缺失选择器和速率限制。
- 可观察性测试: 确认日志包含 URL、状态、记录计数、消耗时间和非机密相关 ID。
脚本的 accepted、rejected 和 total_rows 值很小但有用的操作信号。在规模较大时,添加页面检查点、内容指纹、运行 ID 和终端状态类别。
常见故障模式
常见故障应导致有限的恢复,而不是沉默地产生错误数据。
选择器返回零卡片
零卡片结果通常意味着标记发生了变化,服务器返回了不同的页面,或者 JavaScript 在稍后创建内容。捕获响应状态和允许的诊断样本,然后检查页面以便在更改选择器之前进行分析。不要仅仅因为没有领域特定的原因而将零行视为成功的空数据集。
请求超时或收到限速
使用连接和读取超时,尊重 Retry-After,并应用有限的指数退避和抖动。Nstproxy 对 速率退避算法 的术语条目解释了这一概念。在增加重试之前减少并发性。
文本包含意外字符
检查响应编码,规范化空格,并在失去无损恢复时保留原始文本。不仅仅为了让解析成功而去除字符。
出现重复记录
从规范源标识符或规范 URL 构建稳定 ID,而不是检索时间戳。使用数据库唯一性约束作为最后的保护,而不是唯一的去重策略。
页面需要 JavaScript
请求无法执行JavaScript。当所需内容在初始HTML中缺失时,请使用Playwright或托管渲染和抓取层。不要仅仅因为网站看起来现代而添加浏览器;首先验证响应。
负责任的使用
负责任的网络抓取需要授权、有限范围、数据最小化以及对适用规则的尊重。在收集数据之前,请审查网站条款、机器人政策、版权、隐私职责和特定于管辖区的要求。机器人排除协议标准定义了当前的robots.txt协议,但机器人规则并不是完整的法律或授权决策。
避免身份验证绕过、付费墙规避、私人数据收集、凭证捕获和会损害服务的高频行为。仅存储出于声明目的所需的字段,定义保留策略,保护日志,并在记录影响人时增加人工审核。
结论
强大的Python网络抓取项目是一个具有明确合同的数据管道,而不是一组选择器。从允许的测试表面开始,检查HTTP响应,归一化为类型化模式,拒绝无效记录,并通过稳定的ID进行插入或更新。仅在目标行为需要时添加浏览器渲染或托管抓取。
运行包含的项目两次并检查数据库,然后再将其调整为另一个授权源。如果下一个目标涉及JavaScript渲染的页面或有限的网站,请评估Nstproxy Crawl;如果操作问题是轮换和观察多个代理源,请将Nstproxy Proxy Manager作为一个单独的能力进行评估。
体验Nstproxy — 今天开始您的免费试用
常见问题
是的。Python拥有成熟的HTTP、解析、浏览器自动化、数据验证和存储库,这使其适合从小型静态页面收集器到更大的数据管道。
网络抓取的合法性取决于授权、来源条款、数据类型、管辖区、方法和用途。仅收集授权或公共数据,并对敏感或影响较大的项目进行适当的法律审查。
问:我应该使用Beautiful Soup、Scrapy还是Playwright?
对于小型静态HTML作业,请使用Requests和Beautiful Soup;对于需要管道的定时多页抓取器,请使用Scrapy;当所需内容依赖于浏览器执行时,请使用Playwright。选择最简单的工具,以满足经过验证的目标行为。
从规范源标识符或规范URL创建稳定ID,强制唯一数据库约束,并使用插入或更新操作。不要在稳定ID中包含检索时间。
抓取项目应记录运行ID、允许的URL、响应状态、经过时间、接受和拒绝的计数、重试结果和非秘密错误类别。切勿记录凭证、身份验证Cookie或敏感头信息。
问:何时使用Nstproxy Crawl而不是本地Python代码?
当托管JavaScript渲染、代理路由、有限的网站发现、任务跟踪或多个输出工件减少的操作工作超过本地解析器时,请使用Nstproxy Crawl。将特定于域的验证和存储逻辑保留在您的应用中。
Aug. 17th 2026
立即访问住宅、数据中心、IPv6 与 ISP 高质量代理池。