动静合璧 · 双重礼遇

动态 IP每购 50GB,额外赠 10GB

静态 IP每购 30 天加赠 7 天使用期,每单再赠 500MB 流量体验包

立即购买 >

郑重声明近期有钓鱼网站 rola-ip.com 假冒我司旧版官网!ROLA IP 官方唯一网址为:rola-ip.co(请认准 .co 后缀),切勿在仿冒网站充值以防资产受损!

Expedia酒店数据抓取教程:用Python获取房价并导出CSV

Marcus Bennett

2026年10月15日 · 教程 · 27 分钟阅读

Expedia酒店数据抓取的关键,是把酒店名称、房型报价和入住条件准确关联。本文围绕web scraping Expedia这一需求,用Python搭建从目的地查询到CSV导出的完整流程,适用于酒店价格监控、库存研究和同条件报价对比。

这套方案通过Expedia Rapid获取结构化数据。没有API凭证也能先运行本地样例;需要比较不同地区的网页展示时,再单独配置Rola IP代理。

Expedia酒店抓取先明确什么?

先确定数据来源,再统一比价条件。 酒店名称、库存和房价分属不同接口;把这些字段正确关联,比单纯增加请求量更重要。

  • 先拿ID,再查名称和价格: Geography提供目的地对应的酒店ID,Content提供酒店详情,Shopping提供库存和报价。
  • 每行保留完整条件: 入住日期、人数、币种、退订条件和价格字段必须随报价一起保存。
  • 从小批量开始: 示例只处理1间房,API模式默认选择最多10家酒店。
  • 区分销售点与网络出口: Rapid的销售点参数和代理IP所在地是两个独立设置。
  • 以有效记录衡量结果: 保留错误日志和查询条件,不能只统计请求次数。

Expedia抓取结果:CSV里有哪些酒店数据?

一行数据代表某家酒店在指定住宿条件下的一条房价。 一家酒店可能对应多条房型报价,导出结果不代表已确认订单,也不是平均每晚价格。

酒店名称
酒店ID
住宿日期
入住人数
含税住宿总价
币种
可退订性
Example City Hotel
19248
2026-11-10至2026-11-12
2位成人、1间房
342.00
USD
可退订

这是演示数据,目的地、酒店名称和金额均用于说明流程。JSON和CSV还会保存房型ID、报价ID、其他总价字段、国家代码、语言、处理时间、数据来源和查询哈希。不可用酒店或不完整报价会单独写入错误文件。

Expedia数据采集选API还是浏览器自动化?

要搭建结构化酒店目录或房价查询流程,优先考虑有权限的Rapid API;要检查地区网页展示,则使用获得授权的浏览器自动化。 合作方API和消费者网页可能展示不同产品或报价,不能默认两者完全一致。

方式 适用场景 获取内容 前置条件
Expedia Rapid API 酒店目录、库存工具、合作方房价查询 结构化酒店详情、库存和报价 Rapid账户及已开通的API产品
授权数据源 按约定周期开展批量分析 协议约定的批量记录 覆盖所需字段的数据使用协议
浏览器自动化 检查特定地区或会话中的页面呈现 可见内容和显示行为 自动化访问许可、持续维护的选择器

本文采用Rapid,因为酒店ID、名称和价格字段之间有明确的关联关系。浏览器采集需要另写页面解析逻辑。访问方式与数据用途应符合合作协议及Expedia服务条款。

Rapid API如何关联酒店名称与房价?

用酒店ID作为关联键:Geography找酒店,Content补名称,Shopping查房价。 Content返回以酒店ID为键的对象,Shopping返回酒店结果数组,不能按两个响应的位置顺序拼接。

阶段 端点 本文使用的字段
查询目的地酒店目录 GET /v3/regions/{region_id} property_ids
获取酒店详情 GET /v3/properties/content property_id、name
查询库存与房价 GET /v3/properties/availability 房型、报价、可退订性、入住价格、不可用原因

字段结构见Rapid API规范。Shopping文档规定,单次请求最多查询250家酒店、最多8间房;Shopping不提供酒店名称、星级和地理信息等静态字段。

本文脚本只处理1间房。扩展到多间房时,即使两间房入住人数相同,也必须保留房间数量与对应关系。

Expedia酒店数据抓取流程:目的地、酒店ID、名称、房价与CSV导出

Python抓取Expedia酒店数据:完整操作步骤

先用本地样例验证字段映射和CSV导出,再配置Rapid账户查询目的地酒店。 示例使用Python3.12.10、HTTPX0.28.1和Windows10,本地数据与API响应共用同一套映射、校验和导出函数。

第1步:创建环境并安装HTTPX

创建项目目录和虚拟环境:

mkdir expedia-hotels
cd expedia-hotels
python -m venv .venv

PowerShell激活命令:

.\.venv\Scripts\Activate.ps1

macOS或Linux的Bash激活命令:

source .venv/bin/activate

激活后安装依赖:

python -m pip install httpx==0.28.1

第2步:保存目的地、酒店和房价样例

在项目目录中新建sample-data.json,填入以下内容。三个部分分别模拟Geography、Content和Shopping响应,包含2个酒店ID、酒店名称、1条可用报价和1个不可用酒店结果。

{
  "region": {
    "id": "SAMPLE-REGION",
    "name": "Sample destination",
    "property_ids": [
      "19248",
      "824739"
    ]
  },
  "content": {
    "19248": {
      "property_id": "19248",
      "name": "Example City Hotel"
    },
    "824739": {
      "property_id": "824739",
      "name": "Example Park Hotel"
    }
  },
  "shopping": [
    {
      "property_id": "19248",
      "status": "available",
      "rooms": [
        {
          "id": "ROOM-QUEEN",
          "room_name": "Example Queen Room",
          "rates": [
            {
              "id": "RATE-REFUNDABLE",
              "status": "available",
              "current_refundability": "refundable",
              "occupancy_pricing": {
                "2": {
                  "totals": {
                    "exclusive": {
                      "request_currency": {
                        "value": "310.00",
                        "currency": "USD"
                      }
                    },
                    "inclusive": {
                      "request_currency": {
                        "value": "342.00",
                        "currency": "USD"
                      }
                    },
                    "property_inclusive": {
                      "request_currency": {
                        "value": "350.00",
                        "currency": "USD"
                      }
                    }
                  }
                }
              }
            }
          ]
        }
      ]
    },
    {
      "property_id": "824739",
      "score": 12345,
      "unavailable_reason": {
        "code": "adults_exceed_threshold",
        "data": "2"
      }
    }
  ]
}

本地练习无需修改样例。接入API后,脚本会读取接口返回的酒店信息,不需要手动替换这个JSON文件。

第3步:保存完整Python脚本

在同一目录新建expedia_hotels.py。不加--api时,脚本读取本地样例;加上--api后,脚本按区域ID或传入的酒店ID获取名称与报价。

import argparse
import csv
import hashlib
import json
import os
import re
import time
from datetime import date, datetime, timezone
from decimal import Decimal, InvalidOperation
from pathlib import Path
from uuid import uuid4

import httpx

ROOT = Path(__file__).resolve().parent
SEARCH = {
    "checkin": "2026-11-10", "checkout": "2026-11-12",
    "currency": "USD", "country_code": "US", "language": "en-US",
    "occupancy": ["2"], "rate_plan_count": "1",
    "sales_channel": "website", "sales_environment": "hotel_only",
    "include": ["rooms.rates.current_refundability", "unavailable_reason"],
}


def nonempty(value, field):
    if not isinstance(value, str) or not value.strip():
        raise ValueError(f"missing or invalid {field}")
    return value


def property_ids(values):
    if not isinstance(values, list) or not values:
        raise ValueError("property_ids must be a non-empty list")
    return list(dict.fromkeys(nonempty(v, "property_id") for v in values))


def get_json(client, base, path, params):
    timestamp = int(time.time())
    key, secret = os.environ["RAPID_API_KEY"], os.environ["RAPID_SHARED_SECRET"]
    signature = hashlib.sha512(f"{key}{secret}{timestamp}".encode()).hexdigest()
    headers = {
        "Authorization": f"EAN APIKey={key},Signature={signature},timestamp={timestamp}",
        "Accept": "application/json", "Accept-Encoding": "gzip",
        "User-Agent": "HotelDataTutorial/2.0",
    }
    response = client.get(base + path, params=params, headers=headers)
    if response.status_code == 429:
        raise RuntimeError(f"Rate limited; Retry-After={response.headers.get('Retry-After', 'not supplied')}")
    if response.status_code in (401, 403):
        raise RuntimeError(f"Rapid access failed: HTTP {response.status_code}")
    response.raise_for_status()
    return response.json()


def load_data(args):
    search = json.loads(json.dumps(SEARCH))
    if len(search["occupancy"]) != 1:
        raise ValueError("this example maps one room per search")
    if not args.api:
        if args.property_ids or args.region_id:
            raise ValueError("use --api with --property-ids or --region-id")
        sample = json.loads((ROOT / "sample-data.json").read_text(encoding="utf-8"))
        ids = property_ids(sample["region"]["property_ids"])
        search["property_id"] = ids
        return sample["content"], sample["shopping"], search, "sample", len(ids)

    checkin, checkout = date.fromisoformat(search["checkin"]), date.fromisoformat(search["checkout"])
    if checkin <= date.today() or checkout <= checkin:
        raise ValueError("set future checkin and later checkout dates in SEARCH")
    base = os.environ.get("RAPID_BASE_URL", "https://test.ean.com/v3").rstrip("/")
    if base not in {"https://test.ean.com/v3", "https://api.ean.com/v3"}:
        raise ValueError("unsupported Rapid endpoint")
    with httpx.Client(timeout=httpx.Timeout(30, connect=10), trust_env=False) as client:
        if args.region_id:
            if not re.fullmatch(r"[0-9]+", args.region_id):
                raise ValueError("region-id must be a numeric Expedia Region ID")
            region = get_json(client, base, f"/regions/{args.region_id}", {
                "language": search["language"], "include": "property_ids",
            })
            ids = property_ids(region["property_ids"])
        elif args.property_ids:
            ids = property_ids([v.strip() for v in args.property_ids.split(",")])
        else:
            raise ValueError("API mode needs --region-id or --property-ids")
        total = len(ids)
        search["property_id"] = ids[:args.limit]
        content = get_json(client, base, "/properties/content", {
            "language": search["language"], "supply_source": "expedia",
            "property_id": search["property_id"],
        })
        shopping = get_json(client, base, "/properties/availability", search)
    source = "rapid_production" if base == "https://api.ean.com/v3" else "rapid_test_endpoint"
    return content, shopping, search, source, total


def money(node, currency):
    value = Decimal(str(node["request_currency"]["value"]))
    if not value.is_finite() or value <= 0:
        raise ValueError("price must be finite and positive")
    if node["request_currency"]["currency"] != currency:
        raise ValueError("response currency differs from search currency")
    return value


def map_hotels(content, shopping, search, source):
    if not isinstance(content, dict) or not isinstance(shopping, list):
        raise ValueError("expected Content object and Shopping array")
    occupancy, = search["occupancy"]
    context = json.dumps(search, sort_keys=True, separators=(",", ":"))
    query_hash = hashlib.sha256(context.encode()).hexdigest()
    captured = datetime.now(timezone.utc).isoformat()
    rows, errors, seen = [], [], set()
    expected = set(search["property_id"])
    for prop in shopping:
        pid = "unknown"
        try:
            if not isinstance(prop, dict):
                raise ValueError("property must be an object")
            pid = nonempty(prop.get("property_id"), "property_id")
            if pid not in expected:
                raise ValueError("response property was not requested")
            if pid in seen:
                raise ValueError("duplicate response property")
            seen.add(pid)
            if prop.get("status") != "available":
                reason = prop.get("unavailable_reason") or {}
                code = reason.get("code", "not provided") if isinstance(reason, dict) else "invalid reason"
                raise ValueError(f"unavailable: {code}")
            hotel = content[pid]
            if not isinstance(hotel, dict):
                raise ValueError("hotel content must be an object")
            name = nonempty(hotel.get("name"), "hotel name")
            if hotel.get("property_id") != pid:
                raise ValueError("Content property ID mismatch")
            rooms = prop.get("rooms")
            if not isinstance(rooms, list) or not rooms:
                raise ValueError("rooms must be a non-empty array")
        except (KeyError, TypeError, ValueError) as exc:
            errors.append({"property_id": pid, "error": str(exc)})
            continue
        for room in rooms:
            try:
                if not isinstance(room, dict):
                    raise ValueError("room must be an object")
                rid = nonempty(room.get("id"), "room_id")
                rates = room.get("rates")
                if not isinstance(rates, list) or not rates:
                    raise ValueError("rates must be a non-empty array")
            except ValueError as exc:
                errors.append({"property_id": pid, "error": str(exc)})
                continue
            for rate in rates:
                try:
                    if not isinstance(rate, dict):
                        raise ValueError("rate must be an object")
                    rate_id = nonempty(rate.get("id"), "rate_id")
                    if rate.get("status") != "available":
                        raise ValueError("rate unavailable")
                    totals = rate["occupancy_pricing"][occupancy]["totals"]
                    exclusive = money(totals["exclusive"], search["currency"])
                    inclusive = money(totals["inclusive"], search["currency"])
                    broader = money(totals["property_inclusive"], search["currency"]) if "property_inclusive" in totals else None
                    if inclusive < exclusive or (broader is not None and broader < inclusive):
                        raise ValueError("totals need review: inconsistent price order")
                    rows.append({
                        "property_id": pid, "hotel_name": name,
                        "room_id": rid, "rate_id": rate_id,
                        "refundability": rate.get("current_refundability", "unknown"),
                        "checkin": search["checkin"], "checkout": search["checkout"],
                        "occupancy": occupancy, "currency": search["currency"],
                        "exclusive_total": str(exclusive), "inclusive_total": str(inclusive),
                        "property_inclusive_total": str(broader) if broader is not None else None,
                        "country_code": search["country_code"], "language": search["language"],
                        "processed_at_utc": captured, "source": source,
                        "mapper_version": "2.0.0", "query_hash": query_hash,
                    })
                except (KeyError, TypeError, ValueError, InvalidOperation) as exc:
                    errors.append({"property_id": pid, "room_id": rid, "error": str(exc)})
    for pid in sorted(expected - seen):
        errors.append({"property_id": pid, "error": "property absent from Shopping response"})
    return rows, errors


def csv_value(value):
    # Keep spreadsheet applications from interpreting text fields as formulas.
    if isinstance(value, str) and value.lstrip().startswith(("=", "+", "-", "@")):
        return "'" + value
    return value


def export(rows, errors, search, source, available_ids):
    run_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S") + "-" + uuid4().hex[:8]
    output = ROOT / "output" / run_id
    output.mkdir(parents=True, exist_ok=False)
    for filename, data in [("hotels.json", rows), ("errors.json", errors)]:
        (output / filename).write_text(json.dumps(data, indent=2), encoding="utf-8")
    fields = list(rows[0]) if rows else ["property_id", "hotel_name", "rate_id", "currency"]
    with (output / "hotels.csv").open("w", newline="", encoding="utf-8-sig") as stream:
        writer = csv.DictWriter(stream, fieldnames=fields)
        writer.writeheader()
        writer.writerows({k: csv_value(v) for k, v in row.items()} for row in rows)
    manifest = {"status": "complete", "source": source, "query": search,
                "catalog_id_count": available_ids, "selected_id_count": len(search["property_id"]),
                "valid_rates": len(rows), "issues": len(errors)}
    (output / "run.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
    return output


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--api", action="store_true")
    group = parser.add_mutually_exclusive_group()
    group.add_argument("--property-ids")
    group.add_argument("--region-id")
    parser.add_argument("--limit", type=int, default=10)
    args = parser.parse_args()
    if not 1 <= args.limit <= 250:
        parser.error("--limit must be between 1 and 250")
    content, shopping, search, source, total = load_data(args)
    rows, errors = map_hotels(content, shopping, search, source)
    output = export(rows, errors, search, source, total)
    print(f"Source: {source}")
    print(f"Selected properties: {len(search['property_id'])} of {total}")
    print(f"Valid hotel rates: {len(rows)}; issues: {len(errors)}")
    print(f"Output: {output}")


if __name__ == "__main__":
    main()

脚本会检查酒店、房型和报价ID是否为空,核对酒店名称是否属于所请求的酒店,并确认每个金额使用请求币种。价格总额顺序不合理时,记录会进入错误文件,留待复核;一条报价异常不会阻止其他有效报价导出。

第4步:运行样例,检查CSV与错误记录

python expedia_hotels.py

读取sample-data.json后,程序会输出以下结果,其中Source: sample表示数据来自本地固定样例:

Source: sample
Selected properties: 2 of 2
Valid hotel rates: 1; issues: 1

Python样例生成的酒店CSV:名称、住宿日期、总价、币种和可退订状态

最后一行会显示本次运行的输出目录。每次运行创建新目录,不覆盖历史结果。

文件 检查内容
hotels.csv 在表格软件中查看酒店名称、日期、价格、币种和可退订性
hotels.json 查看同一批记录的完整字段与文本值
errors.json 查看不可用酒店、缺失字段和无效价格记录
run.json 确认完成状态、数据来源、完整查询、酒店数量和记录统计

样例中的酒店824739会产生adults_exceed_threshold错误,表示该入住人数条件下库存不可用,不是可售酒店的解析失败。

分析时应选择包含run.json且其status为complete的目录;导出中断可能留下不完整目录。processed_at_utc记录的是响应处理时间,不代表样例数据在该时刻从接口采集。

第5步:配置Rapid凭证与查询条件

Rapid Lodging将API密钥、共享密钥和Unix时间戳按顺序拼接,再计算SHA-512摘要。认证头必须使用同一个时间戳,详见Rapid签名认证文档。

PowerShell设置方式:

$env:RAPID_API_KEY = 'your_api_key'
$env:RAPID_SHARED_SECRET = 'your_shared_secret'
$env:RAPID_BASE_URL = 'https://test.ean.com/v3'

macOS或Linux的Bash设置方式:

export RAPID_API_KEY='your_api_key'
export RAPID_SHARED_SECRET='your_shared_secret'
export RAPID_BASE_URL='https://test.ean.com/v3'

修改脚本中的SEARCH:设置未来的入住日期、更晚的离店日期,以及所需人数、币种、销售点国家代码和语言。凭证保存在环境变量中。API客户端使用trust_env=False,避免系统代理设置意外改变请求路径。

已有酒店ID时,替换下面的占位符,先请求1家酒店:

python expedia_hotels.py --api --property-ids YOUR_PROPERTY_ID --limit 1

该命令只请求Content和Shopping,不创建订单。脚本默认使用测试域名;已开通生产环境的集成可将RAPID_BASE_URL改为https://api.ean.com/v3。导出文件分别标记为rapid_test_endpoint或rapid_production,便于与本地样例区分。

如何确认Rapid查询与导出成功?

运行后检查以下结果,确认接口调用完成,并取得了可用于分析的报价:

检查项 判断方法
导出已完成 run.json中的status为complete;这只表示处理与导出完成,不代表所有酒店都有报价
数据来源正确 source与所选测试或生产环境一致,不能是sample
取得有效报价 valid_rates大于0,且hotels.csv包含酒店名称、房型、报价ID和价格;等于0时先查看errors.json
查询条件一致 核对run.json中的酒店ID、日期、入住人数和币种;脚本会检查返回的酒店ID是否属于请求集合,并按ID关联Content与Shopping

酒店不可售或某条报价字段异常时,问题会记录在errors.json。处理完成与取得有效房价是两个检查项,应分别确认。

本文使用端点的普通响应,不使用Shopping的预设test响应头。测试请求文档说明,预设响应是静态数据,可能返回与请求无关的酒店。这类数据适合检查接口行为,不能当作实际查询结果与另行取得的酒店目录直接关联。

第6步:从城市名找到区域ID,再查询酒店

城市名不是Shopping接受的酒店ID。需要先找到城市的区域ID(Region ID),再让脚本取得该区域对应的酒店ID(Property ID)。

在Geography目录中定位城市

打开Rapid API Explorer,或使用已认证的API客户端。账户、环境和签名方式应与上一步一致。

Rapid API Explorer中Geography Regions的参数设置界面

  1. 选择GET /v3/regions,设置language=en-US、include=standard,将country_code设为目的地的两位国家代码,并以limit=100开始查询。

  2. 发送请求。例如,美国目的地使用以下路径:

    /v3/regions?language=en-US&include=standard&country_code=US&limit=100
    
  3. 在返回对象中匹配城市name,同时核对name_full、country_code和type,区分同名城市、城市本身与周边区域。复制选中对象的id作为区域ID。

  4. 当前页没有目标城市时,继续访问响应Link头中标记为rel="next"的地址,不改动其查询参数。每一页都需要认证,直到找到城市或没有下一页。

只检索第一页不等于搜索完整目录。长期使用时,可在允许范围内保存区域目录,按城市名和国家进行本地匹配。该接口提供地理筛选,不支持随意添加city=Chicago这样的自由文本查询参数;已有城市与区域ID映射表时可直接复用。

用区域ID导出酒店房价

将YOUR_REGION_ID替换为选定的区域ID:

python expedia_hotels.py --api --region-id YOUR_REGION_ID --limit 10

API规范使用178309表示芝加哥及周边区域,例如:

python expedia_hotels.py --api --region-id 178309 --limit 10

脚本会携带include=property_ids请求区域详情,选取前10个酒店ID,再分别请求Content和Shopping,按ID拼接酒店名称与报价。

打开输出目录中的hotels.csv;用run.json确认查询参数和选中酒店,用errors.json查看未生成有效报价的原因。区域没有对应酒店时,应从目录中选择合适的邻近或更大区域。

--limit只控制请求数量,不代表酒店排名。run.json同时记录区域返回的ID总量和实际选中数量。大规模处理时,每批最多250家,保持比价条件一致;在协议允许范围内,将静态酒店详情与频繁变化的价格分开缓存。

第7步:设置入住人数并核对导出字段

每个occupancy值描述1间房的入住组合:

入住组合 参数值
2位成人 2
1位成人和1名8岁儿童 1-8
2位成人,以及9岁和3岁儿童各1名 2-9,3

最后一种情况应在SEARCH中设置"occupancy": ["2-9,3"]。脚本根据该值读取响应中的对应入住价格。当前映射逻辑只支持1间房,多间房需要补充房间分配与数量处理,不能简单复制导出行。

做价格监控时,只比较日期、入住人数、报价条件、币种和价格字段一致的记录。将run.json与CSV一起保留,便于还原每次查询。

Expedia房价比较:含税总价与酒店总价有什么区别?

Rapid的totals表示指定入住组合在整个住宿期间的总价。 同一报价可以有多个费用口径,不能把含税总价与含酒店现场收费的总价混在一起比较。

字段 费用口径 样例金额
exclusive 不含税费 310.00USD
inclusive 包含税费,但不包含度假费、酒店强制税费等由酒店收取的费用 342.00USD
property_inclusive 包含税费及由酒店收取的费用 350.00USD

以上定义来自Rapid规范的Totals结构。三个金额对应同一报价,只是包含项目不同。

比较时统一使用request_currency。酒店收取的费用可能采用不同的billable_currency,混用币种会造成错误结论。缺少property_inclusive时,导出字段保持为空,不应自动补成0或其他总价。

CSV保存的是一次查询结果。预订前仍需执行相应的Price Check价格校验流程,不能把历史CSV金额视为最终报价。

Expedia抓取什么时候需要代理IP?

只有当任务依赖网页访问的网络来源时,代理才是必要的研究变量。 普通Rapid集成通常可以直连:country_code指定旅客的销售点国家,language控制本地化内容,两者都不能靠更换代理IP替代。

任务 网络与会话方案 需要固定的条件
查询指定销售点的Rapid报价 通常直连即可,具体遵循网络要求 API参数与凭证
比较不同国家的授权网页展示 每个测试组使用目标国家代理 日期、人数、币种、语言和账户状态
先搜索,再查看酒店详情 复用粘性会话及浏览器上下文 出口IP、Cookie和查询条件
互不依赖的无状态检查 独立会话,按需要在独立任务间轮换 请求限制及比较条件

地区网页研究可参考Rola IP差旅票价代理方案,先用少量酒店试运行,再扩展规模。更换IP无法修复API凭证、产品权限或查询条件不一致的问题。

配置Rola IP地区与粘性会话

在控制台选择代理国家、可选的州或城市、会话时长及认证方式,再复制生成的配置。初次配置可参考住宅代理设置指南。

Rola IP控制台设置美国阿拉斯加出口、10分钟粘性会话及账号密码认证

Rola代理参数文档将会话ID放在账户名后的下划线之后。动态住宅代理的示例格式为:

ACCOUNT_hotel01-country-us-sessiontime-10
  • hotel01:本次会话标识。
  • country-us:请求美国出口。
  • sessiontime-10:请求10分钟会话。

相关请求应使用同一个完整用户名。粘性会话用于请求保留出口,不能视为出口绝对不变的保证,应在流程中验证。

文档规定会话时长为1至120分钟。动态住宅、动态数据中心和移动代理支持国家定向;州、城市参数适用于动态住宅代理。所选网络的账户标识和网关以控制台生成结果为准。

互不相关的任务如需每次请求切换出口,可使用住宅代理文档中的ACCOUNT-country-us-f-1。搜索与详情页属于同一流程时,不要在两步之间逐请求换IP。

用HTTPX检查代理出口

下面的脚本用于检查你当前配置的代理出口。先使用控制台提供的信息设置3个变量,其中ROLA_PROXY_USERNAME必须是包含全部参数的用户名。

PowerShell:

$env:ROLA_PROXY_SERVER = 'http://YOUR_GATEWAY:YOUR_HTTP_PORT'
$env:ROLA_PROXY_USERNAME = 'ACCOUNT_hotel01-country-us-sessiontime-10'
$env:ROLA_PROXY_PASSWORD = 'your_proxy_password'

Bash:

export ROLA_PROXY_SERVER='http://YOUR_GATEWAY:YOUR_HTTP_PORT'
export ROLA_PROXY_USERNAME='ACCOUNT_hotel01-country-us-sessiontime-10'
export ROLA_PROXY_PASSWORD='your_proxy_password'

新建check_rola.py,保存以下代码。代码通过HTTPX的proxy=参数配置代理,不把密码放入代理URL,也不在异常输出中打印凭证。根据HTTPX代理文档,HTTP代理可以通过隧道访问HTTPS目标,此时网关地址仍可使用http://。

import ipaddress
import os
import sys

import httpx


def check_route():
    proxy = httpx.Proxy(
        os.environ["ROLA_PROXY_SERVER"],
        auth=(os.environ["ROLA_PROXY_USERNAME"], os.environ["ROLA_PROXY_PASSWORD"]),
    )
    exits = []
    with httpx.Client(proxy=proxy, timeout=15, trust_env=False) as client:
        for _ in range(2):
            response = client.get("https://api.ipify.org?format=json")
            response.raise_for_status()
            exits.append(str(ipaddress.ip_address(response.json()["ip"])))
    print(f"Exit IP: {exits[0]}")
    if exits[0] != exits[1]:
        raise RuntimeError("exit changed between checks")
    print("Same exit observed in two requests.")
    return exits[0]


if __name__ == "__main__":
    try:
        check_route()
    except (httpx.HTTPError, KeyError, ValueError, RuntimeError) as exc:
        # Do not expose proxy credentials in exception output.
        print(f"Route check failed ({type(exc).__name__}). Check gateway, credentials, and session settings.")
        sys.exit(1)

运行检查:

python check_rola.py

运行时,脚本先连续查询两次出口IP;两次地址相同后才会打印Same exit observed in two requests.。再通过IP地理定位核对国家,记录出口和检查时间;较长流程应在结束时再次检查出口。

这个脚本只验证网络路径。实际页面采集客户端也要使用同一网关、完整用户名和会话设置,并在同一浏览器上下文保留Cookie。将地区网页测试与直连Rapid查询分开记录,才能区分API参数和网络来源各自产生的影响。

先做地区试验,再估算流量成本

选择2个国家测试组、一小批固定酒店、相同日期及同一入住组合。每个搜索与详情流程使用同一会话,记录:

  • 请求国家及实际出口位置;
  • 日期、入住人数、币种、语言和登录状态;
  • 价格口径、退订条件、费用和采集时间;
  • 有效记录数、错误、重试次数及账户账单流量。

每条有效记录的流量=计费流量÷有效记录数。 有有效记录后,再用该值乘以计划记录数,结合账户计费单位和单价估算下一批费用;有效记录为0时应先排查问题,不能据此计算单条成本。

运行前设定预算与错误阈值,达到任一阈值就停止该批次。API请求、浏览器资源及重试产生的流量不同,应以自己的实测为准。扩量前核对Rola IP住宅代理价格与账户计费条款,确认地区、会话稳定性、数据质量和成本都符合需求。

Expedia抓取报错、CSV为空怎么排查?

先区分API访问、代理连接和数据映射这三个环节,再针对错误处理。 不要用换IP解决权限问题,也不要把库存不可用误判为程序故障。

现象 优先检查 处理方式
Rapid返回401或403 密钥、签名时间戳、端点及产品权限 修正对应问题,再发起小请求
代理返回407 网关认证、完整参数化用户名 修正代理凭证,重新检查出口
返回429 Retry-After和账户请求限制 按指定间隔等待后重试,降低并发
超时或连接失败 失败发生于直连Rapid还是代理路径 分层测试,保留明确的超时限制
CSV没有数据行 errors.json、入住价格键、Content名称及报价状态 根据具体错误修复输入或选择其他库存
价格不同 日期、人数、币种、费用、退订条件、销售点和会话 固定其他条件,一次只改一个变量
出口IP意外变化 会话用户名、时长及是否使用-f-1 关联步骤复用会话设置,再次验证

示例在请求失败时直接停止,不会自动反复重试。定时任务可为临时连接故障或5xx错误设置有限重试次数;遇到429应遵循Retry-After,认证失败则不要循环请求。

要在本地验证坏数据隔离,可临时将样例中可售酒店的rooms数组改成数字1。预期结果为0条有效报价、2个问题,验证后恢复样例。缺失ID、NaN等非有限金额及异常价格顺序也会进入错误文件。

如何将酒店抓取结果用于价格监控?

先保证每次报价都能还原查询条件,再扩大采集范围。 完成样例验证后,按实际日期跑一小批Rapid请求,将CSV、运行清单与错误文件一起保存。

  1. 确认酒店名称与报价按property_id正确关联。
  2. 固定日期、入住人数、币种、价格口径及退订条件,建立可比较的数据集。
  3. 缓存协议允许保存的静态信息,按业务需求刷新房价。
  4. 地区网页研究另行记录代理国家、会话和实际出口。
  5. 根据有效记录、错误率和实际计费流量决定是否扩量。

常见问题