Python网页抓取的关键,是先分清XML和HTML的解析规则,再核对提取结果。HTML通常按页面元素和属性定位数据;XML则要同时处理层级、元素名称和命名空间。两者都需要字段校验,不能“解析成功就直接入库”。
本文用两份描述相同商品的本地样例,演示从解析到CSV导出的完整流程,再说明如何接入已获授权的数据源,以及何时需要配置Socks5.IO代理。完整脚本内置测试数据,无需额外下载代码包。
先记住三点:
- HTML选择器和XML路径负责“从哪里取值”,字段校验负责“取到的值能否使用”。
- 库存缺失应保留为未知,不能自动当成售罄或有货。
- 代理改变的是网络请求路径,不能修复选择器、命名空间或业务数据错误。
XML和HTML的区别:先看数据结构,再选解析方式
HTML主要组织面向用户的网页内容,XML则为不同业务定义结构化数据格式。两者都能承载数据,真正影响抓取实现的是解析规则、节点结构和字段含义。
HTML中的文本、属性、表格和嵌入式数据都可以成为提取目标。XML常见于数据导出、订阅源、站点地图和系统间交换文件,但扩展名是.xml,不代表里面一定是所需的数据。
| 对比项 | HTML | XML |
|---|---|---|
| 元素定义 | 使用标准HTML元素,并有规定的扩展机制 | 由具体应用定义元素名称与结构 |
| 常见来源 | 商品页、文章页、目录和表格 | 数据导出、订阅源、站点地图和系统交换文件 |
| 解析容错 | 解析器可以修复部分不规范标记;不同解析器可能生成不同的树 | 必须符合XML语法,严格解析会拒绝格式错误的输入 |
| 名称匹配 | 遵循HTML元素名称匹配规则 | 区分大小写,还要考虑命名空间 |
| 提取方式 | CSS选择器,或解析器支持的XPath | 带命名空间的路径、XPath或流式解析事件 |
| 动态内容 | 部分数据可能要执行JavaScript后才出现 | 获取XML文档本身不会执行客户端JavaScript |
| 数据校验 | 选中某个节点,不代表选中了正确商品或字段 | XML语法正确,不代表结构规范和业务数据都正确 |

HTML解析标准和W3C的XML规范分别规定了两类文档的解析要求。浏览器能显示一个不规范的页面,不代表提取器可以忽略错误;XML能够被解析,也不能证明价格可信、记录完整。
还要区分普通的text/html与按XML方式提供和解析的XHTML。应根据响应类型、文档结构和数据源约定选择解析器,而不是只看文件名。
Python网页抓取用什么工具?
本教程用Requests获取响应,用Beautiful Soup提取HTML,再用lxml解析XML。网络请求、文档解析和业务校验各有职责,不应混成一个步骤。
| 工具 | 适合承担的任务 | 使用边界 |
|---|---|---|
| Requests | 通过配置好的HTTP客户端获取授权数据源的响应 | 不执行JavaScript,也不负责解析文档 |
| Beautiful Soup | 遍历HTML结构,使用CSS选择器定位元素 | 生成的文档树取决于所选解析器 |
| lxml | 严格解析XML,处理命名空间、XPath,也可处理HTML | 需要明确设置解析选项,不能默认输入可信 |
| Scrapy | 管理较大采集任务的调度、数据管道和抓取流程 | 不会替代访问授权,也不能省掉字段校验 |
HTML部分显式使用Python的html.parser;XML部分使用配置过的lxml.etree.XMLParser。Beautiful Soup文档介绍了不同解析器和节点查找方式。
这两种解析方式都不会执行页面JavaScript。如果目标字段只存在于渲染后的页面中,应在授权范围内使用相应的渲染方案,或改用数据源提供的正式订阅源。仅修改CSS选择器,无法取出响应里根本没有的字段。
实操:用Python提取HTML和XML中的商品数据
同一组商品可以分别用HTML和XML表达,最终却应输出一致的业务字段。先用本地样例跑通解析与校验,再接入网络,才能准确判断错误发生在哪一层。
本教程包含两件虚构商品:
SKU-DEMO-01:Demo Coffee,价格为24.90美元,库存状态为in_stock。SKU-DEMO-02:Demo Tea,价格为18.50美元,刻意不提供库存状态。
第二件商品的库存会保留为unknown,并标记为需要复核。程序不会自行补成“有货”或“缺货”。
1. 先确定字段和校验规则
| 输出字段 | HTML来源 | XML来源 | 本教程的校验规则 |
|---|---|---|---|
sku |
article.product的data-sku属性 |
c:product的sku属性 |
必填,同一次响应内不能重复 |
name |
h2 a的文本 |
c:name的文本 |
必须是非空文本 |
amount |
.price的文本 |
c:price的文本 |
显式正数,不带货币符号 |
currency |
.price的data-currency属性 |
c:price的currency属性 |
按本例约定,必须是3个大写字母 |
url |
h2 a的href属性 |
c:path的文本 |
与已知基准URL合并,结果必须是无内嵌凭据的HTTPS地址 |
stock |
.stock的文本 |
c:stock的文本 |
缺失或无法识别时保留为unknown并标记复核 |
这里的c是程序为urn:demo:catalog命名空间指定的前缀,不必与XML原文使用的前缀相同。即使XML只声明了默认命名空间,没有写前缀,元素仍属于该命名空间,直接查找product也可能匹配不到。

2. 安装依赖并固定解释器
使用Python3.10或更高版本,并为本教程创建独立虚拟环境。
Windows的PowerShell命令:
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install "requests[socks]>=2.32,<3" "beautifulsoup4>=4.13,<5" "lxml>=5,<7"
后续Windows命令会直接调用虚拟环境中的解释器,因此不需要先激活环境。
macOS/Linux的安装命令:
python3 -m venv .venv
.venv/bin/python -m pip install "requests[socks]>=2.32,<3" "beautifulsoup4>=4.13,<5" "lxml>=5,<7"
版本范围用于限定依赖边界,不保证将来的每个版本都具有完全相同的行为。记录实际安装版本,升级后重新运行样例。
3. 看懂核心差异:CSS选择器与命名空间路径
下面只展示两种格式的提取方式,引用的是完整脚本中的HTML、XML和NS,不能作为独立脚本运行。
html_tree = BeautifulSoup(HTML, "html.parser")
html_card = html_tree.select_one("article.product")
html_name = html_card.select_one("h2 a").get_text(strip=True)
html_price = html_card.select_one(".price").get_text(strip=True)
xml_parser = etree.XMLParser(resolve_entities=False, load_dtd=False,
no_network=True, recover=False, huge_tree=False)
xml_tree = etree.fromstring(XML, parser=xml_parser)
xml_product = xml_tree.find("c:product", NS)
xml_name = xml_product.findtext("c:name", namespaces=NS)
xml_price = xml_product.findtext("c:price", namespaces=NS)
HTML通过article.product、h2 a和.price定位字段;XML通过带命名空间的c:product、c:name和c:price查找节点。
这段预览省略了字段校验。实际操作请复制下一节的完整程序,其结构依次为:内置样例→格式解析→字段规范化→网络检查→命令行与导出。

4. 保存完整Python脚本
将下面整段代码保存为markup_scraper.py。脚本默认读取本地内置样例:--format html或--format xml指定格式;只有同时提供--url和--approved,才会进入网络模式。
代码中的字段名、诊断代码和英文提示保留原样,便于与输出记录及官方文档对照。
import argparse
import csv
import ipaddress
import json
import os
import re
import sys
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path
from urllib.parse import quote, urljoin, urlsplit, urlunsplit
from uuid import uuid4
import requests
from bs4 import BeautifulSoup
from lxml import etree
MAX_BYTES = 2_000_000
NS = {"c": "urn:demo:catalog"}
BASE = "https://catalog.example.invalid/"
HTML = b'''<!doctype html><html><body><main id="catalog">
<article class="product" data-sku="SKU-DEMO-01">
<h2><a href="/p/coffee">Demo Coffee</a></h2>
<span class="price" data-currency="USD">24.90</span><p class="stock">in_stock</p></article>
<article class="product" data-sku="SKU-DEMO-02">
<h2><a href="/p/tea">Demo Tea</a></h2>
<span class="price" data-currency="USD">18.50</span></article>
</main></body></html>'''
XML = b'''<?xml version="1.0" encoding="UTF-8"?>
<catalog xmlns="urn:demo:catalog"><product sku="SKU-DEMO-01">
<name>Demo Coffee</name><price currency="USD">24.90</price>
<path>/p/coffee</path><stock>in_stock</stock></product>
<product sku="SKU-DEMO-02"><name>Demo Tea</name>
<price currency="USD">18.50</price><path>/p/tea</path></product></catalog>'''
def only(nodes):
if len(nodes) != 1:
raise ValueError("Expected exactly one required field or container")
return nodes[0]
def parse_html(data):
root = only(BeautifulSoup(data, "html.parser").select("main#catalog"))
cards = root.select("article.product")
records = []
for card in cards:
link = only(card.select("h2 a"))
price = only(card.select(".price"))
stock = card.select(".stock")
if len(stock) > 1:
raise ValueError("Ambiguous stock field")
records.append({"sku": card.get("data-sku", ""),
"name": link.get_text(" ", strip=True),
"amount": price.get_text(" ", strip=True),
"currency": price.get("data-currency", ""),
"path": link.get("href", ""),
"stock": stock[0].get_text(" ", strip=True) if stock else "unknown"})
return records
def parse_xml(data):
parser = etree.XMLParser(resolve_entities=False, load_dtd=False, no_network=True,
recover=False, huge_tree=False)
root = etree.fromstring(data, parser=parser)
if root.getroottree().docinfo.doctype:
raise ValueError("DTD-bearing XML is outside this tutorial contract")
if root.tag != "{urn:demo:catalog}catalog":
raise ValueError("Unexpected XML root or namespace")
def text(node):
return " ".join(part.strip() for part in node.itertext() if part.strip())
records = []
for product in root.findall("c:product", NS):
name = only(product.findall("c:name", NS))
price = only(product.findall("c:price", NS))
path = only(product.findall("c:path", NS))
stock = product.findall("c:stock", NS)
if len(stock) > 1:
raise ValueError("Ambiguous stock field")
records.append({"sku": product.get("sku", ""), "name": text(name),
"amount": text(price), "currency": price.get("currency", ""),
"path": text(path), "stock": text(stock[0]) if stock else "unknown"})
return records
def normalize(records, base_url, kind, fmt, run_id, stamp):
if not records:
raise ValueError("No product records; inspect the source instead of claiming sold out")
rows, seen = [], set()
for raw in records:
row = {key: str(value).strip() for key, value in raw.items()}
if not row["sku"] or not row["name"] or not row["path"] or row["sku"] in seen:
raise ValueError("Missing required identity/text or duplicate SKU")
seen.add(row["sku"])
if not re.fullmatch(r"\d+(?:\.\d+)?", row["amount"]) or Decimal(row["amount"]) <= 0:
raise ValueError("Require an explicit positive decimal amount")
if not re.fullmatch(r"[A-Z]{3}", row["currency"]):
raise ValueError("Require an explicit three-letter uppercase currency label")
target = urljoin(base_url, row.pop("path"))
parsed = urlsplit(target)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
raise ValueError("Expected HTTPS product URL without embedded credentials")
row["url"] = target
row["amount"] = str(Decimal(row["amount"]))
row["review_reason"] = ""
if row["stock"] not in {"in_stock", "out_of_stock"}:
row["stock"], row["review_reason"] = "unknown", "stock_unknown"
row.update(source_kind=kind, source_format=fmt, run_id=run_id, retrieved_at_utc=stamp)
rows.append(row)
return rows
def public_source(url):
p = urlsplit(url)
if p.scheme != "https" or not p.hostname or p.username or p.password:
raise ValueError("Use an approved public HTTPS source without credentials")
host = p.hostname.lower().rstrip(".")
if host == "localhost" or host.endswith(".localhost"):
raise ValueError("Use local fixtures, not network mode, for localhost")
try:
address = ipaddress.ip_address(host)
except ValueError:
address = None
if address is not None and not address.is_global:
raise ValueError("Private IP literals are outside network mode")
return p
def make_session(use_proxy):
session = requests.Session()
session.trust_env = False
session.headers["User-Agent"] = "AuthorizedMarkupTutorial/1.0"
if use_proxy:
host = os.environ["SOCKS5IO_HOST"]
port = int(os.environ["SOCKS5IO_PORT"])
if not re.fullmatch(r"[A-Za-z0-9.-]+", host) or not 1 <= port <= 65535:
session.close()
raise ValueError("Check issued proxy host and port")
user = quote(os.environ["SOCKS5IO_USER"], safe="")
password = quote(os.environ["SOCKS5IO_PASSWORD"], safe="")
proxy = f"socks5h://{user}:{password}@{host}:{port}"
session.proxies.update(http=proxy, https=proxy)
return session
def get_body(session, url, accepted):
with session.get(url, timeout=(5, 20), allow_redirects=False, stream=True) as response:
if response.status_code != 200:
raise ValueError(f"HTTP {response.status_code}: stop and review access/quota")
mime = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
if mime not in accepted:
raise ValueError("Unexpected response type; check for a changed source or challenge")
data = bytearray()
for chunk in response.iter_content(65536):
data.extend(chunk)
if len(data) > MAX_BYTES:
raise ValueError("Response exceeds tutorial limit")
return bytes(data)
def check_exit(session):
body = get_body(session, "https://api.ipify.org?format=json", {"application/json"})
address = ipaddress.ip_address(json.loads(body)["ip"])
if not address.is_global:
raise ValueError("Checker returned a non-public address")
return {"observed_ip": str(address), "checked_at_utc": datetime.now(timezone.utc).isoformat()}
def safe_diagnostic(error):
if isinstance(error, etree.XMLSyntaxError):
return "XML_SYNTAX: inspect XML well-formedness privately."
if isinstance(error, requests.RequestException):
return "NETWORK: inspect connection, proxy authentication and TLS privately."
if isinstance(error, OSError):
return "OUTPUT_IO: check destination and write permissions."
if isinstance(error, KeyError):
return "CONFIG_FIELD: check required configuration or checker fields."
message = str(error)
status = re.fullmatch(r"HTTP (\d{3}): stop and review access/quota", message)
if status:
return "HTTP_STATUS_" + status[1] + ": review authorization, quota or source status."
codes = {
"Unexpected response type; check for a changed source or challenge": "RESPONSE_MIME",
"Response exceeds tutorial limit": "INPUT_SIZE",
"Input exceeds tutorial limit": "INPUT_SIZE",
"Unexpected XML root or namespace": "XML_NAMESPACE",
"DTD-bearing XML is outside this tutorial contract": "XML_DTD",
"Expected exactly one required field or container": "FIELD_CARDINALITY",
"Ambiguous stock field": "FIELD_CARDINALITY",
"No product records; inspect the source instead of claiming sold out": "NO_RECORDS",
"Missing required identity/text or duplicate SKU": "FIELD_IDENTITY",
"Require an explicit positive decimal amount": "FIELD_AMOUNT",
"Require an explicit three-letter uppercase currency label": "FIELD_CURRENCY",
"Expected HTTPS product URL without embedded credentials": "FIELD_URL",
}
return codes.get(message, "INPUT_OR_CONFIG") + ": inspect the corresponding input or configuration privately."
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--format", choices=["html", "xml"], required=True)
parser.add_argument("--url")
parser.add_argument("--approved", action="store_true")
parser.add_argument("--proxy", action="store_true")
parser.add_argument("--out", type=Path, required=True)
args = parser.parse_args()
log_path = args.out.with_suffix(".run.json")
if args.out.exists() or log_path.exists():
parser.error("Use new output filenames")
run_id = uuid4().hex
log = {"run_id": run_id, "format": args.format, "proxy_requested": args.proxy}
if args.url:
p = public_source(args.url)
if not args.approved:
parser.error("Review source permission and request budget before --approved")
accepted = {"text/html"} if args.format == "html" else {"application/xml", "text/xml"}
with make_session(args.proxy) as session:
log["before"] = check_exit(session)
data = get_body(session, args.url, accepted)
stamp = datetime.now(timezone.utc).isoformat()
log["after"] = check_exit(session)
log["same_observed_ip"] = log["before"]["observed_ip"] == log["after"]["observed_ip"]
base, kind = args.url, "user_approved_source"
log["source"] = urlunsplit((p.scheme, p.netloc, p.path, "", ""))
else:
if args.proxy:
parser.error("A local fixture is not a proxy test")
data = HTML if args.format == "html" else XML
base, kind = BASE, "synthetic_fixture"
stamp = datetime.now(timezone.utc).isoformat()
log["source"] = "local:embedded-" + args.format
if len(data) > MAX_BYTES:
raise ValueError("Input exceeds tutorial limit")
records = parse_html(data) if args.format == "html" else parse_xml(data)
rows = normalize(records, base, kind, args.format, run_id, stamp)
log.update(source_kind=kind, records_exported=len(rows), retrieved_at_utc=stamp)
with log_path.open("x", encoding="utf-8") as handle:
json.dump(log, handle, indent=2)
with args.out.open("x", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
print(f"Saved {len(rows)} {args.format.upper()} records; inspect review_reason.")
if __name__ == "__main__":
try:
main()
except (ValueError, etree.XMLSyntaxError, KeyError, OSError, requests.RequestException) as error:
print("Stopped:", safe_diagnostic(error), file=sys.stderr)
sys.exit(1)
5. 运行两种格式的提取任务
Windows:
.\.venv\Scripts\python.exe markup_scraper.py --format html --out products-html.csv
.\.venv\Scripts\python.exe markup_scraper.py --format xml --out products-xml.csv
macOS/Linux:
.venv/bin/python markup_scraper.py --format html --out products-html.csv
.venv/bin/python markup_scraper.py --format xml --out products-xml.csv
两次本地运行应得到相同的业务数据:
SKU |
商品名 |
金额 |
币种 |
库存状态 |
复核原因 |
|---|---|---|---|---|---|
SKU-DEMO-01 |
Demo Coffee |
24.90 |
USD |
in_stock |
空 |
SKU-DEMO-02 |
Demo Tea |
18.50 |
USD |
unknown |
stock_unknown |
检查结果时,还要区分以下字段:
source_format:两份文件分别记录HTML和XML来源。run_id:每次运行都有独立标识,便于对应日志和结果。retrieved_at_utc:同一次响应中的记录共用一个时间戳。review_reason:为空只表示通过了本教程的检查,不能证明库存实时有效、价格已含税或跨来源商品完全等价。
商品链接会解析为虚构目录下的HTTPS地址。每个CSV旁边还会生成一个.run.json运行记录文件,程序不会覆盖已有文件。
这里没有实现两个文件的原子写入:如果写入过程失败,可能只留下其中一个文件。将不可信文本导入电子表格时,也应使用合适的文本导入设置,避免将内容作为公式执行。


6. 主动制造错误,确认程序会如何处理
接入网络前,先用副本修改内置样例,每次只改变一个条件,并使用新的输出文件名。
| 测试动作 | 应有表现 |
|---|---|
将HTML中的class="product"改为class="offer",不改解析器 |
不应误报所有商品无货,而应停止提取 |
将urn:demo:catalog改为其他命名空间 |
拒绝与预期结构不符的XML |
| 删除XML中必需的闭合标签 | 严格解析报错,不静默修复 |
| 将价格改为0,或删除币种属性 | 在输出文件生成前拒绝不合规数据 |
| 保留第二件商品的缺失库存 | 两种格式都输出unknown并标记复核 |
测试后恢复样例。当前程序规定同一响应中每个SKU只能出现一次,因此会拒绝重复SKU。如果业务允许同一商品出现多个规格、地区或卖家报价,就要重新定义唯一标识,而不是直接删除重复校验。
Python解析XML文件:命名空间、XPath与安全设置
XML查不到节点时,先检查命名空间和路径,再判断数据是否缺失。XML能够正常解析,只能证明语法成立,不能替代安全检查和业务校验。
命名空间看的是URI,不是前缀字母
root.findall("c:product", NS)会把c展开为NS中指定的命名空间URI。只要URI相同,XML原文换一个前缀,仍可能表示同一个元素名称;如果URI变了,就不是简单的前缀改名。
本例中的sku和currency属性没有命名空间限定,元素名称则使用默认命名空间。处理RSS、Atom或站点地图时,应分别检查实际根元素、层级和字段映射,不能照搬本例的结构。
findall并不等于完整XPath
ElementTree风格的findall支持有限的路径表达式。需要更复杂的查询时,可以使用lxml提供的XPath接口,但查询能力更强也不代表可以省略结果验证。
不可信XML必须限制解析行为
Python的XML安全说明介绍了资源耗尽等风险;lxml解析文档则说明了相关配置选项。本例采用以下限制:
resolve_entities=False:不执行实体替换。load_dtd=False:不主动加载外部DTD。no_network=True:禁止解析器为外部文档发起网络访问。recover=False:不尝试修复格式错误的XML。huge_tree=False:不关闭解析器针对超深或超大内容的相关限制。- 发现DOCTYPE后拒绝输入,不执行XInclude或XSLT。
网络响应还受到2_000_000字节大小限制。这些措施只是小规模教程的安全基线,不是完整沙箱;生产环境仍需更新依赖,并限制进程资源和总运行时间。
程序只验证预期根元素和必要字段,没有使用XSD或DTD进行结构规范验证。对于体积很大的授权XML文档,应考虑iterparse等流式方案,并及时清理元素、限制输出规模。本例会在内存中构建小型文档树,没有演示恒定内存处理,也没有声称XML比HTML解析更快。
如何把本地样例改成真实数据源?
接入真实数据源时,必须先适配它的结构和字段规则,再替换请求地址。示例里的选择器、命名空间和商品字段,不是通用网站解析器。
建议按以下顺序操作:
- 确认采集授权和请求预算,保存一份允许用于测试的响应。
- 记录格式、字符编码、根元素或容器、商品标识、必填字段及缺失值处理方式。
- 修改对应的HTML或XML解析函数,先对保存的响应测试。
- 确认字段含义与输出正确后,再替换URL并运行网络模式。
以下地址故意使用不可工作的.invalid域名,只展示命令形式。必须先完成适配,再换成已获授权的真实地址。
Windows:
.\.venv\Scripts\python.exe markup_scraper.py --format html --url "https://catalog.example.invalid/approved-products" --approved --out approved-html.csv
.\.venv\Scripts\python.exe markup_scraper.py --format xml --url "https://catalog.example.invalid/approved-export.xml" --approved --out approved-xml.csv
macOS/Linux:
.venv/bin/python markup_scraper.py --format html --url "https://catalog.example.invalid/approved-products" --approved --out approved-html.csv
.venv/bin/python markup_scraper.py --format xml --url "https://catalog.example.invalid/approved-export.xml" --approved --out approved-xml.csv
网络模式只接受声明过的HTML或XML响应类型,而且不会自动跟随重定向。application/xhtml+xml、RSS专用MIME类型或另一种XML结构,都需要明确的适配决定,不能遇到类型不符就忽略检查。
还应明确当前程序的边界:
- 只请求一份源文档,不抓取商品链接、不处理分页,也不执行JavaScript。
- 排除localhost和私有IP字面量,但不构成完整的SSRF或DNS重绑定防护。
- 相对商品路径基于来源URL解析,未实现HTML的
base元素逻辑。 - 格式正确的XML可能只是错误文档,仍要检查根元素和必需字段。
--approved是显式操作确认,不会替代数据源实际授予的访问权限。
Python爬虫SOCKS5代理配置:Socks5.IO应该接在哪一步?
代理应该配置在网络请求层,而不是解析器里。只有任务确实需要特定出口地区或网络路由时,才有必要接入Socks5.IO;小规模授权采集也可能直接连接即可完成。
如果工作流需要住宅网络出口或指定市场的观察视角,可以评估Socks5.IO动态住宅代理。使用前确认当前产品支持的地区、协议、鉴权方式、会话行为、流量有效期和购买条件。
IP池规模不能证明某个城市始终有可用出口,代理也不会增加目标站允许的请求额度。
1. 从后台获取代理参数
参考Socks5.IO用户名密码鉴权文档,按实际后台界面操作:
- 打开
Rotating Residential IP → Proxy Setup。 - 选择账号鉴权和对应代理用户;需要时先通过
Sub Account Management创建代理用户。 - 确认地区与会话需求,选择产品支持的协议,再生成连接信息。
- 复制网关地址、端口、代理用户名和代理密码。
**代理鉴权用户名不是后台登录用户名。**以下环境变量需要填写生成的代理参数,不能直接套用网站账号密码。

2. 设置凭据并启用代理
在独立的PowerShell进程中输入参数:
$env:SOCKS5IO_HOST = Read-Host 'Issued proxy host'
$env:SOCKS5IO_PORT = Read-Host 'Issued proxy port'
$env:SOCKS5IO_USER = Read-Host 'Issued proxy username'
$markupProxySecret = Read-Host 'Issued proxy password' -AsSecureString
$env:SOCKS5IO_PASSWORD = [System.Net.NetworkCredential]::new('', $markupProxySecret).Password
确认解析器已适配真实数据源后,增加--proxy参数运行:
.\.venv\Scripts\python.exe markup_scraper.py --format xml --url "https://catalog.example.invalid/approved-export.xml" --approved --proxy --out proxy-xml.csv
macOS/Linux可在Bash中交互输入凭据,避免将密码直接写入命令历史,再调用虚拟环境解释器:
read -r -p 'Issued proxy host: ' SOCKS5IO_HOST
read -r -p 'Issued proxy port: ' SOCKS5IO_PORT
read -r -p 'Issued proxy username: ' SOCKS5IO_USER
read -r -s -p 'Issued proxy password: ' SOCKS5IO_PASSWORD; printf '\n'
export SOCKS5IO_HOST SOCKS5IO_PORT SOCKS5IO_USER SOCKS5IO_PASSWORD
.venv/bin/python markup_scraper.py --format xml --url "https://catalog.example.invalid/approved-export.xml" --approved --proxy --out proxy-xml.csv
unset SOCKS5IO_PASSWORD
上述命令块使用Bash的read参数;如果终端默认运行其他Shell,应先切换到Bash执行。
脚本会对用户名和密码进行百分号编码,并使用socks5h,让目标域名在代理端解析。Requests的SOCKS文档说明了这一行为。客户端仍要解析并连接代理网关,不能把“目标域名远端解析”理解为“本机完全不涉及DNS”。
连接配置还保留以下边界:
- TLS证书校验保持开启。
trust_env=False使程序忽略环境中的默认代理和相关证书包配置。- 代理失败后不会自动回退到直连。
- 企业若需要自定义CA,应明确配置获准使用的证书包。
SOCKS5不天然比HTTP代理更快、更匿名,也不是XML专用协议。选择应取决于产品和客户端支持情况,可参考SOCKS5与HTTP代理的区别。浏览器集成需要单独验证,不能把Requests中的鉴权写法原样套给Chromium。
3. 用同一个采集客户端验证出口
网络模式会在同一个Session中依次请求ipify公共JSON检测服务、授权数据源,再次请求ipify。使用前应考虑该第三方服务的条款和隐私影响;检测失败时,程序会停止本次采集。
检查.run.json中的before、after和same_observed_ip,必要时与获准的直连基线对照,并单独核实地区信息。
**两次检测的IP相同,不足以证明中间目标请求使用了同一出口。**轮换代理可能按请求分配IP,requests.Session()也不会自动固定出口。如果业务要求连续性,必须配置产品支持的固定或粘性会话策略,并另行验证。
如需更换IP检测服务,应按新服务实际端点和字段改写代码,不能假定存在某个Socks5.IO专用检测API。
4. 清理凭据并保护导出内容
CSV会保留数据源提供的商品链接;运行日志中的来源URL会删除查询参数和片段。如果复现任务需要完整URL,应在受限记录中按run_id关联保存,不应公开发布。
不要把预订令牌、账户链接、个人数据或代理凭据写入公开导出文件。PowerShell任务结束后清理密码:
Remove-Item Env:SOCKS5IO_PASSWORD
Remove-Variable markupProxySecret
抓取失败怎么排查?先区分故障层,再考虑扩容
先判断失败发生在权限、网络、响应格式、解析还是字段校验,再采取对应措施。程序只输出预定义诊断代码,不把原始异常、完整URL或凭据写进公开日志。
例如,HTTP_STATUS_403和HTTP_STATUS_429分别提示访问拒绝与配额相关响应,XML_NAMESPACE则指向XML根元素或命名空间不匹配。详细源文档应在受控环境中检查。

| 诊断代码或现象 | 优先检查什么 |
|---|---|
HTTP_STATUS_401、HTTP_STATUS_403 |
停止任务,检查授权与访问条件 |
HTTP_STATUS_429 |
停止任务,检查配额、Retry-After及允许的重试规则 |
RESPONSE_MIME |
是否返回了同意页面、验证页面、错误页面或重定向,而非预期文档 |
INPUT_SIZE |
在数据源允许范围内缩小文档,或改用有资源约束的处理方案 |
XML_NAMESPACE、XML_SYNTAX、XML_DTD |
XML根元素、命名空间、语法以及是否包含被本例拒绝的DTD |
FIELD_AMOUNT、FIELD_CURRENCY |
价格或币种是否缺失、格式错误或不满足本例约定 |
FIELD_IDENTITY、FIELD_CARDINALITY |
必填文本、SKU唯一性、匹配节点数量是否正确 |
NETWORK |
网络连接、代理鉴权和TLS配置 |
OUTPUT_IO |
输出位置、已有文件及写入权限 |
CONFIG_FIELD |
必需的配置或出口检测返回字段 |
| 没有匹配元素 | 选择器、路径、命名空间和源结构;不要直接判定库存为0 |
| 内存或耗时持续增长 | 文档规模、流式解析策略和进程资源限制 |
不应为了处理403或429而自动切换IP,也不应无限增加并发。所有工作线程应共用按数据源制定的请求预算,只在允许时缓存,并对并发、重试次数和总耗时设置上限。
代码里的timeout=(5, 20)分别设置连接与读取超时,不是整个任务的总运行时限。
扩容前看哪些指标?
将“有效响应”和“通过业务校验的记录”分开统计。一份响应可能包含很多商品,因此“记录数÷请求数”不是请求成功率。
建议跟踪:
- 结构变化导致的解析失败次数。
- 字段缺失或需要复核的记录比例。
- 重复记录与商品标识冲突。
- 数据观测时间与当前时间的差距。
- 运行耗时、可归属流量及维护成本。
需要进一步选择网络方案时,可参考网页抓取代理选型指南。代理成本和路由只是采集成本的一部分,解析质量仍需独立测试。
业务场景:对照商品网页与供应商XML导出
跨来源比价应先统一商品标识、币种和价格含义,再判断差异是否值得处理。某个来源返回空结果,不一定意味着商品下架或库存耗尽。
假设一家零售商获准对照自己的HTML商品目录与供应商的XML导出,每天从两边各检查20个已经对应好的SKU。
这代表计划进行40次“来源—SKU”核对,不代表一定发送40个请求,也不保证得到40条匹配记录。一个响应可能包含多个SKU,不同来源也可能使用不同规格或价格口径。
可按以下流程落地:
- 为HTML目录和XML导出分别维护解析逻辑,统一输出字段。
- 对齐商品与规格标识,确认币种、含税条件和价格含义。
- 将缺失库存送入复核队列,不直接触发缺货提醒。
- 将命名空间变化视为解析适配问题,不误判为供应商清空商品。
- 即使HTML请求成功,也要确认返回的不是同意页面或其他非商品内容。
- 只有采集确实需要地区化路由时,才加入代理并单独验证。
扩大任务前,先评估有效记录覆盖率与维护成本。这里是规划示例,不是客户案例、实测节省金额或来源访问成功率。

总结:先验证数据,再配置网络路由
可靠的Python网页抓取,首先要明确源结构和字段规则,再把解析、校验与导出串成可验证的流程。代理应服务于已确认的网络需求,而不是作为所有抓取问题的默认答案。
先运行本地HTML和XML样例,核对两份CSV的业务字段与复核标记,再适配已获授权的真实来源。需要特定出口时,使用Socks5.IO生成的代理参数配置客户端,并把路由验证与数据校验分开完成。
只有访问条件、记录质量、资源上限和成本都满足要求,才扩大采集规模。






