酒店数据采集的关键,是把房价和入住条件一起保存。用Python爬取酒店数据时,必须核对日期、入住人数、房型、餐食、取消政策及税费,才能开展可靠的酒店价格监控。
本教程使用Requests和Beautiful Soup,演示如何提取酒店报价、校验字段、标记不可直接比较的记录,并导出CSV。需要指定网络出口或维持短期会话时,可以接入Socks5.IO代理。
示例范围: 下文使用虚构酒店和本地HTML样例,不代表已完成Booking.com、Expedia或Agoda实站采集,也不代表真实代理性能测试。真实来源需要根据页面结构重新适配,并单独验证运行结果。
酒店数据采集应该采集哪些字段?
酒店资料和客房报价必须分开存储。酒店资料描述“这是一家什么酒店”,客房报价描述“在什么入住条件下,页面显示了什么可售方案”。
酒店出现在目的地列表中,不代表指定日期一定有房。页面没有显示报价,也不能直接判定酒店售罄。
| 记录类型 | 建议字段 | 不能据此推断 |
|---|---|---|
| 酒店资料 | 来源酒店ID、名称、地址、经纬度、设施 | 酒店介绍页不能证明当前库存 |
| 客房报价 | 入住日期、人数、房型、价格方案、计价单位、税费、餐食、取消政策 | 最低显示价格不一定是同等条件下最便宜的报价 |
| 评分摘要 | 评分、满分值、评价数量、观察时间 | 4.5/5和8.7/10不一定采用相同评价方法 |
| 采集记录 | 数据来源、UTC获取时间、请求市场、提取逻辑版本、状态 | 缺少房源卡片不等于售罄 |
从能回答业务问题的最小数据集开始。酒店价格监控通常不需要下载酒店图片或收集评论者个人资料。对于允许保存的内容,应保留页面显示的价格文本和政策原文,方便后续复核。
数据源怎么选:酒店官网、OTA还是授权API?
酒店官网适合核对直销房价与房型说明,OTA适合观察同一搜索条件下的多家酒店,授权API更适合需要稳定库存接口、标准标识符或预订流程的应用。
三种数据源的接入条件不同,不能把网页展示信息等同于可用于交易的库存接口。
例如,Expedia Rapid的Shopping文档介绍了合作伙伴查询住宿可用性和房价的接口。它与从网页中提取可见文本不是同一种接入方式,实际使用范围取决于合作资格、接口条款和合同。
采集前应确认:
- 网站条款与数据使用条件;
- robots规则;
- 获准的请求频率和总量;
- 数据保存、分析及再分发范围。
robots规则本身不构成数据使用许可。除非另有授权接口,不应进入旅客账号、私人订单、支付资料或预订提交流程。代理提供网络路径,不改变这些边界。
写爬虫之前,先统一酒店房价的比较条件
只有入住条件、产品条件和价格口径一致,房价才有比较价值。应先建立搜索条件记录,再发送请求。
本教程固定以下条件:
- 入住:2026年11月10日;
- 退房:2026年11月12日,共2晚;
- 入住人数:2名成人、0名儿童;
- 房间数量:1间;
- 币种:USD;
- 页面语言区域:
en-US。
| 比较维度 | 必须记录并保持一致的条件 |
|---|---|
| 入住日期 | 入住、退房、晚数,按酒店所在地日期理解 |
| 入住人数 | 成人、房间数、儿童人数;有儿童时还需年龄 |
| 酒店与产品 | 来源酒店ID、跨来源酒店匹配结果、房型、价格方案 |
| 包含项目 | 餐食、税费、度假村费或目的地费、付款条件 |
| 购买资格 | 公开价、会员价、移动端价、套餐价等 |
| 取消政策 | 是否可退款、取消截止时间及对应时区 |
| 展示条件 | 币种、语言区域、请求市场、登录与Cookie状态 |
| 观察时间 | 获取时间、来源提供的更新时间、可售状态原文 |
每晚180美元的报价,不能直接与含税的2晚420美元总价排序。即便把180美元乘以2,也可能仍有税费或购买资格差异。
同样,页面显示美元不代表访问IP位于美国;两地展示价格不同,也不能仅凭这一点归因于IP位置。

如何用Python爬取酒店数据?
先用可控HTML验证提取与校验逻辑,再适配获准采集的真实来源。不要只替换URL,就把示例当成可直接运行的OTA爬虫。
示例包含同一家虚构酒店的两种报价:
- 420美元:2晚总价、含税、灵活取消方案;
- 360美元:2晚总价、不含税、不可退款。
第二个数字更低,但两个方案并非同等产品。程序会保留这些差异,而不是直接输出“最低价”。
1. 准备Python环境
使用Python3.10或更高版本。在空文件夹中创建虚拟环境并安装依赖:
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install "requests[socks]>=2.32,<3" "beautifulsoup4>=4.13,<5"
macOS或Linux使用:
python3 -m venv .venv
.venv/bin/python -m pip install "requests[socks]>=2.32,<3" "beautifulsoup4>=4.13,<5"
后续命令应使用同一个虚拟环境中的解释器。下文以Windows路径为例,macOS或Linux将其替换为.venv/bin/python。
2. 保存完整采集程序
将以下代码保存为hotel_scraper.py。代码包含:
- 两种本地HTML样例;
- 字段提取与入住条件校验;
- 可选SOCKS5代理配置;
- 请求前后的出口IP检查;
- CSV导出和路由日志。
代码保留原始字段名、虚构数据及异常消息,便于对照输出。

import argparse
import csv
import json
import ipaddress
from contextlib import nullcontext
from uuid import uuid4
import os
import re
import sys
from datetime import date, datetime, timezone
from decimal import Decimal
from pathlib import Path
from urllib.parse import quote, urlsplit, urlunsplit
import requests
from bs4 import BeautifulSoup
EXPECTED = dict(checkin="2026-11-10", checkout="2026-11-12",
adults="2", children="0", rooms="1", currency="USD", locale="en-US")
DEMO = '''<section id="results" data-checkin="2026-11-10" data-checkout="2026-11-12"
data-adults="2" data-children="0" data-rooms="1" data-currency="USD" data-locale="en-US">
<article class="hotel-offer" data-property="DEMO-101" data-room="Standard Double"
data-rate="Flexible" data-meal="Room only" data-cancel="Free until stated deadline"
data-basis="stay_total" data-tax="included" data-eligibility="public" data-availability="available">
<h2>Fictional Harbor Hotel</h2><span class="price" data-amount="420.00">USD 420 total; taxes included</span>
</article>
<article class="hotel-offer" data-property="DEMO-101" data-room="Standard Double"
data-rate="Non-refundable" data-meal="Room only" data-cancel="Non-refundable"
data-basis="stay_total" data-tax="excluded" data-eligibility="public" data-availability="available">
<h2>Fictional Harbor Hotel</h2><span class="price" data-amount="360.00">USD 360 total; taxes excluded</span>
</article></section>'''
def one(node, selector):
found = node.select(selector)
if len(found) != 1:
raise ValueError("Expected exactly one matching node")
return found[0]
def required(node, name):
value = node.get("data-" + name, "").strip()
if not value:
raise ValueError("Missing required offer or context attribute")
return value
def extract(html, source, kind, requested_market, retrieved_at_utc=None, run_id=None):
retrieved_at_utc = retrieved_at_utc or datetime.now(timezone.utc).isoformat()
run_id = run_id or uuid4().hex
root = one(BeautifulSoup(html, "html.parser"), "#results")
context = {key: required(root, key) for key in EXPECTED}
if context != EXPECTED:
raise ValueError("Returned stay context does not match the requested context")
nights = (date.fromisoformat(context["checkout"]) - date.fromisoformat(context["checkin"])).days
if nights <= 0 or context["children"] != "0":
raise ValueError("Check dates; add an explicit child-age adapter before including children")
cards = root.select("article.hotel-offer")
if not cards:
raise ValueError("No offer cards; do not interpret this as sold out")
rows, seen = [], set()
for card in cards:
price = one(card, ".price")
raw = price.get("data-amount", "")
if not re.fullmatch(r"\d+(?:\.\d+)?", raw) or Decimal(raw) <= 0:
raise ValueError("Expected an explicit positive numeric amount")
row = {"source_property_id": required(card, "property"),
"property_name": one(card, "h2").get_text(" ", strip=True),
**context, "nights": nights, "displayed_price": price.get_text(" ", strip=True),
"amount": str(Decimal(raw)), "room": required(card, "room"),
"rate": required(card, "rate"), "meal": required(card, "meal"),
"cancellation_text": required(card, "cancel"), "price_basis": required(card, "basis"),
"tax_status": required(card, "tax"), "eligibility": required(card, "eligibility"),
"availability": required(card, "availability")}
if not row["property_name"] or not row["displayed_price"]:
raise ValueError("Missing displayed text")
key = (row["source_property_id"], row["room"], row["rate"], row["meal"],
row["cancellation_text"], row["eligibility"], row["price_basis"], row["tax_status"])
if key in seen:
raise ValueError("Duplicate offer key; inspect pagination or source identifiers")
seen.add(key)
reasons = [label for label, ok in (
("basis_not_stay_total", row["price_basis"] == "stay_total"),
("taxes_not_confirmed_included", row["tax_status"] == "included"),
("restricted_eligibility", row["eligibility"] == "public"),
("availability_not_confirmed", row["availability"] == "available"),
("cancellation_unknown", row["cancellation_text"] != "unknown"),
("meal_unknown", row["meal"] != "unknown")) if not ok]
row.update(review_reason=";".join(reasons), requested_market=requested_market,
retrieved_at_utc=retrieved_at_utc, run_id=run_id,
source_url=source, source_kind=kind)
rows.append(row)
return rows
# A second page layout owned by this tutorial, not an OTA response.
OWNED_DEMO = '''<main class="rate-search">
<dl class="stay-summary"><dd class="arrival">2026-11-10</dd>
<dd class="departure">2026-11-12</dd><dd class="adults">2</dd>
<dd class="children">0</dd><dd class="rooms">1</dd>
<dd class="currency">USD</dd><dd class="locale">en-US</dd></dl>
<section class="room-card" data-listing-id="OWNED-201">
<h3>Fictional Garden Hotel</h3><p class="room-name">Garden Double</p>
<p class="rate-name">Public flexible</p><p class="meal">Room only</p>
<p class="price-value">440.00</p><p class="price-label">USD 440 for the stay</p>
<p class="basis">stay_total</p><p class="eligibility">public</p>
<p class="availability">available</p></section></main>'''
def observed(node, selector, fallback=None):
matches = node.select(selector)
if len(matches) == 0 and fallback is not None:
return fallback
return one(node, selector).get_text(" ", strip=True)
def adapt_owned(html):
source = BeautifulSoup(html, "html.parser")
summary = one(source, ".stay-summary")
target = BeautifulSoup('<section id="results"></section>', "html.parser")
root = target.section
mapping = {"checkin": "arrival", "checkout": "departure",
"adults": "adults", "children": "children", "rooms": "rooms",
"currency": "currency", "locale": "locale"}
for field, css in mapping.items():
root["data-" + field] = observed(summary, "." + css)
cards = source.select("section.room-card")
if not cards:
raise ValueError("No owned-layout cards; inspect source")
for card in cards:
identity = card.get("data-listing-id", "").strip()
if not identity:
raise ValueError("Missing source property identifier")
out = target.new_tag("article", attrs={"class": "hotel-offer"})
fields = {"property": identity,
"room": observed(card, ".room-name"),
"rate": observed(card, ".rate-name"),
"meal": observed(card, ".meal", "unknown"),
"cancel": observed(card, ".cancellation", "unknown"),
"basis": observed(card, ".basis", "unknown"),
"tax": {"Taxes included": "included", "Taxes excluded": "excluded"}.get(
observed(card, ".tax-note", "unknown"), "unknown"),
"eligibility": observed(card, ".eligibility", "unknown"),
"availability": observed(card, ".availability", "unknown")}
for field, value in fields.items():
out["data-" + field] = value
name = target.new_tag("h2")
name.string = observed(card, "h3")
price = target.new_tag("span", attrs={"class": "price",
"data-amount": observed(card, ".price-value")})
price.string = observed(card, ".price-label")
out.extend([name, price])
root.append(out)
return str(target)
def make_session(use_proxy):
session = requests.Session()
session.trust_env = False
session.headers["User-Agent"] = "HotelResearchTutorial/1.0"
if use_proxy:
gateway = os.environ["SOCKS5IO_HOST"]
port = int(os.environ["SOCKS5IO_PORT"])
if not re.fullmatch(r"[A-Za-z0-9.-]+", gateway) or not 1 <= port <= 65535:
session.close()
raise ValueError("Invalid proxy host or port")
user = quote(os.environ["SOCKS5IO_USER"], safe="")
password = quote(os.environ["SOCKS5IO_PASSWORD"], safe="")
proxy = f"socks5h://{user}:{password}@{gateway}:{port}"
session.proxies.update(http=proxy, https=proxy)
return session
def validate_url(url):
parsed = urlsplit(url)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
raise ValueError("Use an approved public HTTPS URL without embedded credentials")
host = parsed.hostname.lower().rstrip(".")
if host == "localhost" or host.endswith(".localhost"):
raise ValueError("Local source is not supported in network mode")
try:
address = ipaddress.ip_address(host)
except ValueError:
address = None
if address is not None and not address.is_global:
raise ValueError("Use a public remote source")
return parsed
def check_exit(session, url):
validate_url(url)
response = session.get(url, timeout=(5, 20), allow_redirects=False)
if response.status_code != 200:
raise ValueError("Exit-check endpoint did not return HTTP 200")
# ipify's documented JSON contract; adapt this if using your own endpoint.
address = ipaddress.ip_address(response.json()["ip"])
if not address.is_global:
raise ValueError("Exit checker returned a non-public address")
return {"observed_ip": str(address),
"checked_at_utc": datetime.now(timezone.utc).isoformat()}
def fetch(url, use_proxy, session=None):
validate_url(url)
manager = nullcontext(session) if session is not None else make_session(use_proxy)
with manager as active:
response = active.get(url, timeout=(5, 20), allow_redirects=False)
if response.status_code != 200:
raise ValueError(f"HTTP {response.status_code}: stop and review source access or quota")
if "text/html" not in response.headers.get("Content-Type", "").lower():
raise ValueError("Expected HTML; use a separate adapter for a documented JSON interface")
return response.content
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--url")
parser.add_argument("--adapter-demo", action="store_true")
parser.add_argument("--layout", choices=["canonical", "owned"], default="canonical")
parser.add_argument("--check-url", default="https://api.ipify.org?format=json")
parser.add_argument("--route-label", default="private-run")
parser.add_argument("--approved", action="store_true")
parser.add_argument("--proxy", action="store_true")
parser.add_argument("--market", default="US")
parser.add_argument("--out", type=Path, required=True)
args = parser.parse_args()
run_id = uuid4().hex
route_path = args.out.with_suffix(".route.json")
if args.out.exists() or (args.url and route_path.exists()):
parser.error("Choose unused CSV and route-log filenames")
if args.url and args.adapter_demo:
parser.error("Do not mix a network source with --adapter-demo")
manifest = None
if args.url:
if not args.approved:
parser.error("Review permission, context and rate limits before --approved")
# Same Session and proxy settings for both exit checks and the source.
with make_session(args.proxy) as session:
before = check_exit(session, args.check_url)
html = fetch(args.url, args.proxy, session=session)
retrieved_at_utc = datetime.now(timezone.utc).isoformat()
after = check_exit(session, args.check_url)
manifest = {"run_id": run_id, "route_label": args.route_label,
"proxy_requested": args.proxy, "before": before, "after": after,
"same_observed_ip": before["observed_ip"] == after["observed_ip"]}
# Save privately even if the later page validation fails.
with route_path.open("x", encoding="utf-8") as log:
json.dump(manifest, log, indent=2)
if args.layout == "owned":
html = adapt_owned(html)
p = urlsplit(args.url)
source = urlunsplit((p.scheme, p.netloc, p.path, "", ""))
kind = "user_approved_html"
else:
if args.proxy:
parser.error("The synthetic demo is not a proxy test")
html = adapt_owned(OWNED_DEMO) if args.adapter_demo else DEMO
source = "local:owned-layout-demo" if args.adapter_demo else "local:embedded-demo"
kind = "synthetic_fixture"
retrieved_at_utc = datetime.now(timezone.utc).isoformat()
rows = extract(html, source, kind, args.market, retrieved_at_utc, run_id)
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)} observations; inspect review_reason and offer conditions.")
if __name__ == "__main__":
try:
main()
except ValueError as error:
print("Stopped:", error, file=sys.stderr)
sys.exit(1)
except (KeyError, OSError, requests.RequestException):
print("Stopped: check endpoint fields, source access or a new output filename.", file=sys.stderr)
sys.exit(1)
3. 运行样例,检查两条报价
在程序所在文件夹执行:
.\.venv\Scripts\python.exe hotel_scraper.py --out hotel-demo.csv
预期关键字段如下:
价格方案 |
金额 |
计价口径 |
税费 |
待复核原因 |
|---|---|---|---|---|
Flexible |
420.00 |
整段住宿总价 |
已包含 |
空 |
Non-refundable |
360.00 |
整段住宿总价 |
未包含 |
taxes_not_confirmed_included |
两条记录都会保留房型、取消政策原文和2晚入住条件。

review_reason为空,只代表通过了程序中的这些检查,不代表不同来源的报价已经完成同房型匹配,也不证明报价仍然有效或可以预订。
两条记录共用一个响应级UTC时间戳和run_id。时间戳随实际运行时间变化。重复运行时使用新文件名,程序会拒绝覆盖已有CSV。
由于第二条报价没有给出税额,程序不会替它补算含税总价。没有明确计价口径时,也不能将住宿总价除以晚数后当成来源提供的“每晚报价”。如需计算平均每晚成本,应单独建立分析字段并注明计算假设。
4. 先测试失败行为,再连接网络
可以通过修改币种验证程序是否会拒绝错误条件:
- 只把
DEMO字符串中的data-currency="USD"改为data-currency="EUR"。 - 保持
EXPECTED中的USD不变。 - 使用新的输出文件名运行。
- 预期出现入住条件不匹配错误,不生成新的CSV。
- 测试后恢复原始样例。
以下情况也会阻止导出:
- 找不到结果容器;
- 找不到报价卡片;
- 金额不是明确的正数;
- 出现重复报价键;
- 缺少必填属性或展示文本。
程序先解析全部记录,再创建CSV。因此,解析阶段失败不会留下部分导出文件。但这些只是结构检查,真实酒店匹配、取消截止时间、字段含义和数据新鲜度仍需按来源单独验证。
5. 为获准采集的页面编写适配器
不同网站的HTML结构不同。适配器负责把来源字段转换为统一结构,核心校验逻辑再判断数据是否完整。
示例中的第二种布局采用以下映射:
| 来源字段或选择器 | 统一字段 | 处理规则 |
|---|---|---|
.stay-summary中的入住摘要 |
日期、人数、房间数、币种、语言区域 | 缺失即停止,并与EXPECTED核对 |
data-listing-id、h3 |
酒店ID、酒店名称 | 必须存在 |
.room-name、.rate-name |
房型、价格方案 | 缺失即停止 |
.price-value、.price-label |
金额、显示价格 | 金额必须为明确正数,并保留来源标签 |
.basis、.tax-note |
计价口径、税费状态 | 无法确认时保留unknown,不能默认含税 |
.cancellation、.meal |
取消政策、餐食 | 缺失时标记为unknown并进入复核 |
.eligibility、.availability |
购买资格、可售状态 | 缺失时保留未知并标记 |
无需额外创建HTML文件,直接运行第二种布局:
.\.venv\Scripts\python.exe hotel_scraper.py --adapter-demo --out hotel-adapter.csv
预期导出虚构酒店Fictional Garden Hotel的一条440.00美元报价。样例故意省略税费和取消说明,因此应得到:
tax_status=unknown
cancellation_text=unknown
review_reason=taxes_not_confirmed_included;cancellation_unknown

如果获准采集的来源恰好采用第二种布局,可在网络命令中添加--layout owned。对于其他网站,应根据已检查的响应修改选择器和映射,不能只换URL。
扩展税费映射前,应记录来源实际使用的税费表述和计价单位。未识别的说明继续保留为unknown。当前示例只保存取消政策文本,不解析取消截止时间及其时区。
修改选择器和EXPECTED后,可使用下面的直连模板:
.\.venv\Scripts\python.exe hotel_scraper.py --url "https://hotel.example.invalid/approved-search" --approved --market US --out hotel-direct.csv
其中.invalid是刻意不可用的占位地址,需要替换为获准访问的实际来源。--approved只是程序中的操作确认标记,不会自动取得网站授权。
还需要注意:
--market US只记录请求市场,不会自动改变页面语言、币种或出口位置。- 实际搜索参数与代理地区需要分别配置。
- 样例只支持0名儿童;增加儿童入住前,必须适配儿童年龄及来源规则。
- 只使用公开搜索URL,不传入私人订单链接、账号令牌或旅客资料。
- CSV中的
source_url会移除查询参数和片段,入住条件则单独保存。 - 如需保留完整公开搜索URL,应存入受控运行记录,通过
run_id关联。 - 程序中的URL检查不构成完整的DNS重绑定或SSRF防护。
酒店搜索结果由JavaScript加载怎么办?
Requests只能获取HTTP响应内容,Beautiful Soup不会执行JavaScript。如果房价依赖脚本加载,需要另行适配获准使用的浏览器流程或文档化接口。
在浏览器中看到XHR或GraphQL请求,不代表可以重放私有接口或复制会话凭据。
对于允许浏览器采集的来源,可按以下顺序处理:
- 等待实际结果容器出现;
- 核对页面上的入住摘要;
- 获取
page.content()并解析; - 对分页或滚动设置明确停止条件。
分页至少应记录页码或游标,并设置:
- 不再出现新的已验证报价ID时停止;
- 最大页数;
- 最长运行时间。
没有终止条件的无限滚动,容易产生重复数据和失控请求。
Playwright网络文档介绍了HTTP(S)和SOCKSv5代理配置,以及HTTP(S)代理的用户名、密码选项。Requests能使用带账号密码的SOCKS5地址,不代表Chromium支持相同方式。
需要浏览器代理认证时,应使用服务商实际提供的HTTP(S)端点及受支持的认证方式,或另行验证的网关。本教程的完整程序仅实现Requests流程,浏览器接入需要单独配置和验证。
不要在未验证前大范围屏蔽图片、脚本或样式。部分资源影响布局,部分应用请求直接提供价格或政策。只有确认必要字段和入住条件仍然完整,才适合优化资源加载。
酒店数据采集如何选择Socks5.IO代理?
IP类型、出口地区和会话持续时间是三个独立的选择。先明确业务需要,再决定是否使用代理及采用哪种配置。
Socks5.IO的动态住宅代理可用于需要住宅出口或指定市场路由的授权采集,但并非所有酒店页面都需要住宅代理。
| 业务需求 | 可评估的连接方式 | 验收重点 |
|---|---|---|
| 无地区要求的小规模授权任务 | 直连 | 在配额内获得正确入住条件和有效报价 |
| 需要稳定出口或白名单接入 | 真正固定的端点,包括适用的静态住宅代理 | 租用条件、替换政策、实际出口连续性 |
| 短时间多步骤搜索或分页 | 粘性会话 | 会话到期、空闲超时、分配出口的可用性 |
| 独立的多市场观察 | 指定地区的端点,配合适当轮换 | 出口国家或地区,以及保持一致的入住和展示条件 |
| 来源接受机房网络流量 | 数据中心代理 | 实际来源上的有效报价、耗时和可归属成本 |
住宅代理会话控制文档区分了粘性与轮换配置。粘性会话表示有条件的出口连续性,不是永久占用某个IP。
应核对所选产品当前的会话到期和空闲规则,重新连接后再次检查出口。即使代理会话未变,目标网站自身的应用会话也可能独立过期。
在Python程序中配置代理端点
按照账号密码认证指南,获取实际分配的网关、端口和代理用户凭据。
地区、会话等用户名语法必须以对应产品生成的配置为准。不要自行猜测端口,也不要把控制台登录账号当成代理认证账号。

在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'
$hotelProxySecret = Read-Host 'Issued proxy password' -AsSecureString
$env:SOCKS5IO_PASSWORD = [System.Net.NetworkCredential]::new('', $hotelProxySecret).Password
然后对已适配的来源启用代理:

.\.venv\Scripts\python.exe hotel_scraper.py --url "https://hotel.example.invalid/approved-search" --approved --proxy --market US --out hotel-proxy.csv
程序会对账号密码进行百分号编码,并使用socks5h将DNS解析交给代理端,具体行为见Requests的SOCKS文档。
当前配置还有几个重要边界:
- 代理出错后不会自动回退到直连;
- TLS证书校验保持开启;
trust_env=False会忽略环境中的代理和证书信任包设置;需要企业CA时应显式配置获准的证书;(5, 20)分别设置连接与读取超时,不是整个任务的总时限;- 生产任务仍需另外限制总运行时间。
用同一个Python会话检查出口
网络模式会在同一个Requests Session中执行:
检查出口→请求一次获准的HTML页面→再次检查出口。
它是单响应采集器,不包含完整分页器或预订流程。扩展时需要保留应用会话状态,并遵守来源明确的请求预算。

默认检查地址为:
https://api.ipify.org?format=json
这是ipify提供的公共JSON服务,不是Socks5.IO接口。预期响应格式为:
{"ip":"<observed-public-IP>"}
也可以通过--check-url指定自己获准使用的HTTPS检查端点,只要它返回相同的ip字段。
验证步骤如下:
- 不加
--proxy,使用--route-label direct-US和新的CSV文件名运行一次。 - 对相同来源添加
--proxy --route-label residential-US,使用另一组文件名运行。 - 在受控环境中查看各自的
.route.json,比较before.observed_ip、after.observed_ip、UTC时间和same_observed_ip。 - 通过
run_id关联CSV与路由日志。 - 将出口结果与所购产品配置核对;有地区要求时,另行验证位置。
- 如果业务要求出口稳定,而前后IP不同,应拒绝该观察窗口并检查会话配置。
直连和代理出口不同,只能支持“路由发生变化”的判断,不能直接证明出口国家。地址相同也不应直接判定成功,需要继续排查。
前后两次检查夹住酒店请求,但无法证明独立轮换的中间请求一定使用了哪个出口。若必须确认这一点,应采用经过验证的稳定会话策略。
当前程序会记录出口差异,但不会自动强制固定出口。出口检查失败会停止采集,来源拒绝访问也不会自动重试。页面解析失败时,可能留下路由日志而没有CSV;这种记录属于诊断证据,不属于成功数据集。
路由日志包含公网IP和自定义标签,应限制文件访问并在分享前脱敏。环境变量也不是加密密钥库,不要打印变量或发布完整代理URL。使用结束后可移除密码变量:
Remove-Item Env:SOCKS5IO_PASSWORD
Remove-Variable hotelProxySecret
浏览器中的IP检测或服务商独立测试页,不能证明这个Python客户端使用了预期出口。应从实际采集客户端完成验证。
如何排查采集失败,并衡量有效酒店数据?
HTTP请求成功和报价可用是两种结果,必须分别统计。排错应先看响应与业务字段,不要把所有失败归因于IP。
| 现象 | 建议处理 |
|---|---|
| HTTP401或403 | 停止当前运行,核对来源访问条件,不自动换IP规避拒绝 |
| HTTP429 | 查看Retry-After和来源配额,按获准的策略恢复 |
| HTTP200但没有结果 | 检查是否返回同意页、验证页或默认搜索页,不能批量标记售罄 |
| 酒店正确,日期或人数错误 | 拒绝比较结果,修正请求参数与入住条件映射 |
| 房型相同,但税费、取消或购买资格不同 | 分为不同报价组,或进入人工复核 |
| 代理认证或连接失败 | 先检查实际分配的字段、产品状态,再决定是否重新请求来源 |
不存在“请求10次就一定封禁”的通用阈值。应按来源设置覆盖所有工作进程和出口的请求预算。先串行验证数据,再在许可范围内增加有上限的并发。
更多IP不会自动增加应用获准使用的请求配额。
建议分开记录两类指标:
| 指标 | 计算方式 |
|---|---|
| 请求成功率 | 有效搜索响应数÷搜索请求次数 |
| 报价合格率 | 通过结构、酒店匹配、入住条件、政策及新鲜度检查的报价数÷提取报价总数 |
一次搜索可能返回多条报价,因此“报价数÷请求数”不能当成成功率。
同时记录:
- 耗时中位数和p95;
- 失败类型与数量;
- 重复记录比例;
- 数据距当前时间的间隔;
- 服务商账单中的实际用量。
计费流量应以服务商记录为准,不能默认等于响应正文大小。只有掌握实际成本和合格记录数,才能计算:
单条合格记录成本=可归属的采集成本÷合格记录数。
如果没有合格记录,这个指标无定义,不能填成0。更多路由与成本考虑可参考价格监控代理指南,酒店场景仍需额外核对入住与报价条件。
酒店数据采集服务怎么选:自建、外包还是API?
选择依据应是交付内容、维护责任和数据使用权,而不是单纯比较抓取数量。代理、代采服务和授权API解决的是不同问题。
| 方案 | 提供什么 | 团队仍需负责什么 |
|---|---|---|
| 自建采集器+代理 | 自定义请求、解析与路由 | 来源权限、维护、匹配、监控和故障处理 |
| 酒店数据采集服务 | 合同约定的采集或标准化结果 | 验收标准、使用范围、新鲜度及供应商管理 |
| 酒店或OTA授权API | 合同范围内的接口与文档化接入 | 接入资格、字段含义、合同范围及下游用途 |
评估酒店数据采集服务时,应索取与实际入住场景一致的样本,重点检查:
- 酒店与房型如何匹配;
- 税费和取消政策如何处理;
- 时间戳如何记录;
- 字段缺失时如何标记;
- 页面变化后由谁维护选择器;
- 合同是否覆盖采集及再分发。
不能假设代采服务保证来源始终可访问,也不能假设每家服务商都使用某一种代理网络。
本教程中的Socks5.IO承担网络路由与IP资源角色,不提供酒店数据集,也不是酒店数据代采服务。判断自建是否更便宜,应合并计算研发、来源维护和合格记录成本,不能套用统一的数据量门槛。
示例:两个市场的酒店房价核查
跨市场房价分析要先保持入住和展示条件一致,再观察差异。出口地区只是记录中的一个变量,不能单独解释价格变化。
假设某酒店集团的研究团队获得授权,需要比较:
- 6家酒店;
- 3组未来入住日期;
- 美国和英国两个观察市场;
- 2名成人、1间客房;
- 已核对的房型对应关系;
- 可比较的公开餐食与取消条件。
这是拟议流程,不是实际客户成果。
团队在约定的短时间窗口内执行两地观察,固定币种、页面语言、入住条件和Cookie/登录状态。每个短搜索流程使用已确认的粘性会话,并从同一客户端检查出口。
如果会话发生变化,或页面搜索摘要被重置,应重新验证,而不是继续沿用旧条件。
计划包含:
6家酒店×3组日期×2个市场=36个“酒店—入住日期—市场”观察单元。
这不代表一定获得36条合格报价,也不代表只需36次HTTP请求。部分搜索需要分页,部分观察单元可能没有可比较的报价。
复核标记应区分:
- 未含税;
- 购买资格受限;
- 来源请求失败;
- 房型无法匹配;
- 没有可用的同条件报价。
完成这些检查后,才能分析地区展示差异。不能看到价格变化,就直接认定为地域差别定价或可套利机会。
下一步:先验证报价,再扩大采集
可靠的酒店数据采集,应从一条条件完整、可以复核的报价开始。扩大请求量之前,先确认解析结果和比较规则成立。
先运行虚构样例,检查复核标记,再为获准来源修改选择器和入住条件。只有任务确实需要固定出口、短期粘性会话或指定地区路由时,再配置Socks5.IO,并从实际Python客户端验证。
扩展规模时,优先比较合格报价数、数据新鲜度和可归属成本。酒店库存、预订权限、数据使用权与网络路由应始终分别核对。






