feat: add date-based OHIP data reporting alongside XML
This commit is contained in:
1 parent
5ecd571e3b
commit
ad2c59256f
259 files changed
+37326
-126
No files matched your search
@@ -0,0 +1,418 @@
|
||||
"""Acquire ARR candidate data with two read-only OHIP operations.
|
||||
|
||||
This utility does not produce a report, select/prioritize rows, compute prices,
|
||||
or submit anything to ARR/Finance. A complete capture is not report equivalence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timezone
|
||||
from email.utils import parsedate_to_datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
from typing import Callable
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
SERVICE = "https://ohip.nianxx.cn"
|
||||
APPLICATION = "caller_zloxalzsQ9pYsztt"
|
||||
SEARCH = "searchHotelReservations"
|
||||
DETAIL = "getReservation"
|
||||
FETCH = ("Reservation", "Comments", "Traces", "DailySummary", "RateInfoDetails",
|
||||
"Packages", "InventoryItems", "Shares")
|
||||
MAX_RESPONSE_BYTES = 8 * 1024 * 1024
|
||||
MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024
|
||||
MAX_MANIFEST_BYTES = 32 * 1024 * 1024
|
||||
# Leave room for the v2 assessment footer even when a response exceeds budget.
|
||||
MAX_FINAL_METADATA_BYTES = MAX_RESPONSE_BYTES + 1
|
||||
MANIFEST_SUMMARY_RESERVE = 64 * 1024
|
||||
MANIFEST_FOOTER_ENTRY_RESERVE = 512
|
||||
RETRY_STATUSES = {429, 500, 502, 503, 504}
|
||||
|
||||
|
||||
class CollectionError(Exception):
|
||||
"""Only static, non-sensitive error codes may be raised through this class."""
|
||||
|
||||
|
||||
def require(condition: bool, code: str) -> None:
|
||||
if not condition:
|
||||
raise CollectionError(code)
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def json_bytes(value: object) -> bytes:
|
||||
return (json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False) + "\n").encode()
|
||||
|
||||
|
||||
def strict_json(raw: bytes) -> dict:
|
||||
def unique(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
require(key not in result, "duplicate_json_key")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
def invalid_constant(_):
|
||||
raise CollectionError("invalid_json_number")
|
||||
|
||||
try:
|
||||
value = json.loads(raw, object_pairs_hook=unique, parse_constant=invalid_constant)
|
||||
except (ValueError, UnicodeError, RecursionError):
|
||||
raise CollectionError("invalid_json") from None
|
||||
require(isinstance(value, dict), "json_not_object")
|
||||
return value
|
||||
|
||||
|
||||
def load_key(path: Path) -> str:
|
||||
"""Read a regular, owner-only credential without exposing its contents."""
|
||||
try:
|
||||
parent = path.parent.lstat()
|
||||
require(stat.S_ISDIR(parent.st_mode) and parent.st_uid == os.getuid()
|
||||
and stat.S_IMODE(parent.st_mode) == 0o700, "credential_directory_permissions")
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
with os.fdopen(fd, "rb") as source:
|
||||
info = os.fstat(source.fileno())
|
||||
require(stat.S_ISREG(info.st_mode) and info.st_uid == os.getuid()
|
||||
and stat.S_IMODE(info.st_mode) == 0o600, "credential_file_permissions")
|
||||
raw = source.read(65537)
|
||||
require(len(raw) <= 65536, "credential_too_large")
|
||||
document = strict_json(raw)
|
||||
require(document.get("version") == "ohip.application-credential/v1"
|
||||
and document.get("status") == "ready"
|
||||
and document.get("service_url", "").rstrip("/") == SERVICE
|
||||
and document.get("application_id") == APPLICATION, "credential_identity_mismatch")
|
||||
key = document.get("value")
|
||||
require(isinstance(key, str) and bool(re.fullmatch(r"[\x21-\x7e]{1,4096}", key)),
|
||||
"invalid_credential_value")
|
||||
return key
|
||||
except (OSError, TypeError, AttributeError):
|
||||
raise CollectionError("credential_unreadable") from None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Options:
|
||||
arrival_date: str
|
||||
hotel_id: str
|
||||
page_size: int = 100
|
||||
max_pages: int = 100
|
||||
max_records: int = 10000
|
||||
|
||||
def validate(self) -> None:
|
||||
try:
|
||||
require(date.fromisoformat(self.arrival_date).isoformat() == self.arrival_date,
|
||||
"invalid_arrival_date")
|
||||
except (TypeError, ValueError):
|
||||
raise CollectionError("invalid_arrival_date") from None
|
||||
require(isinstance(self.hotel_id, str) and bool(re.fullmatch(r"[A-Za-z0-9_-]{1,64}", self.hotel_id)),
|
||||
"invalid_hotel_id")
|
||||
for value, maximum in ((self.page_size, 100), (self.max_pages, 100), (self.max_records, 10000)):
|
||||
require(type(value) is int and 1 <= value <= maximum, "invalid_collection_limit")
|
||||
|
||||
|
||||
class Archive:
|
||||
"""New private directory; exclusive file creation prevents accidental reuse."""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
self.files: list[dict] = []
|
||||
self.inventory_bytes = 0
|
||||
self.manifest_inventory_bytes = 0
|
||||
path.mkdir(mode=0o700, parents=False, exist_ok=False)
|
||||
os.chmod(path, 0o700)
|
||||
|
||||
def write(self, name: str, value: object) -> None:
|
||||
require(bool(re.fullmatch(r"[a-z0-9_.-]+", name)), "invalid_archive_name")
|
||||
raw = value if isinstance(value, bytes) else json_bytes(value)
|
||||
entry = {"name": name, "bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest()}
|
||||
# An inventory entry gains extra indentation at result.files depth.
|
||||
# This conservative bound avoids repeatedly serializing the entire list.
|
||||
entry_budget = len(json_bytes(entry)) + 64
|
||||
if name == "result.json":
|
||||
# The separately pinned manifest is outside its own file inventory.
|
||||
require(len(raw) <= MAX_MANIFEST_BYTES, "archive_manifest_too_large")
|
||||
elif name == "rate-assessments.json":
|
||||
require(len(raw) <= MAX_FINAL_METADATA_BYTES, "archive_footer_too_large")
|
||||
require(self.inventory_bytes + len(raw) <= MAX_ARCHIVE_BYTES, "archive_byte_budget_exceeded")
|
||||
else:
|
||||
require(self.inventory_bytes + len(raw) <= MAX_ARCHIVE_BYTES - MAX_FINAL_METADATA_BYTES,
|
||||
"archive_byte_budget_exceeded")
|
||||
if name != "result.json":
|
||||
reserve = MANIFEST_SUMMARY_RESERVE + (0 if name == "rate-assessments.json" else MANIFEST_FOOTER_ENTRY_RESERVE)
|
||||
require(self.manifest_inventory_bytes + entry_budget <= MAX_MANIFEST_BYTES - reserve,
|
||||
"archive_manifest_budget_exceeded")
|
||||
fd = os.open(self.path / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, "wb") as output:
|
||||
output.write(raw)
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
self.files.append(entry)
|
||||
if name != "result.json":
|
||||
self.inventory_bytes += len(raw)
|
||||
self.manifest_inventory_bytes += entry_budget
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
raise CollectionError("redirect_refused")
|
||||
|
||||
|
||||
@dataclass
|
||||
class HTTPTransport:
|
||||
key: str = field(repr=False)
|
||||
timeout: float = 25
|
||||
opener: object = field(default_factory=lambda: urllib.request.build_opener(NoRedirect()), repr=False)
|
||||
|
||||
def __call__(self, method: str, path: str, body: bytes | None):
|
||||
request = urllib.request.Request(SERVICE + path, data=body, method=method, headers={
|
||||
"X-API-Key": self.key, "Accept": "application/json", "Content-Type": "application/json",
|
||||
})
|
||||
try:
|
||||
response = self.opener.open(request, timeout=self.timeout)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
return response.code, dict(response.headers), response.read(MAX_RESPONSE_BYTES + 1)
|
||||
|
||||
|
||||
def retry_delay(headers: dict, attempt: int) -> float:
|
||||
value = headers.get("retry-after")
|
||||
if value is None:
|
||||
return float(2 ** (attempt - 1))
|
||||
try:
|
||||
if re.fullmatch(r"[0-9]+", value):
|
||||
delay = float(value)
|
||||
else:
|
||||
when = parsedate_to_datetime(value)
|
||||
require(when.tzinfo is not None, "invalid_retry_after")
|
||||
delay = max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
|
||||
# Stop instead of retrying sooner than the server permits.
|
||||
require(0 <= delay <= 30, "retry_after_exceeds_budget")
|
||||
return delay
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
raise CollectionError("invalid_retry_after") from None
|
||||
|
||||
|
||||
def check_warnings(value: object) -> None:
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
require(key not in {"warnings", "errors"} or not child, "upstream_warning_or_error")
|
||||
check_warnings(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
check_warnings(child)
|
||||
|
||||
|
||||
class Reader:
|
||||
def __init__(self, archive: Archive, hotel_id: str, transport: Callable,
|
||||
*, key: str = "", sleep: Callable = time.sleep):
|
||||
self.archive, self.hotel_id, self.transport = archive, hotel_id, transport
|
||||
self._key, self.sleep = key, sleep
|
||||
self.request_count = 0
|
||||
|
||||
def read(self, operation: str, *, body: dict | None = None, identity: str | None = None) -> dict:
|
||||
if operation == SEARCH:
|
||||
require(isinstance(body, dict) and identity is None, "invalid_search_request")
|
||||
method, path, payload = "POST", "/api/v1/reservations/searches", json_bytes(body)
|
||||
elif operation == DETAIL:
|
||||
require(body is None and isinstance(identity, str)
|
||||
and bool(re.fullmatch(r"[A-Za-z0-9_-]{1,128}", identity)), "invalid_reservation_id")
|
||||
query = urllib.parse.urlencode([("fetchInstructions", value) for value in FETCH])
|
||||
method, path, payload = "GET", "/api/v1/reservations/" + identity + "?" + query, None
|
||||
else:
|
||||
raise CollectionError("operation_not_allowed")
|
||||
|
||||
for attempt in range(1, 4):
|
||||
self.request_count += 1
|
||||
label = f"request-{self.request_count:06d}"
|
||||
self.archive.write(label + ".json", {"operation_id": operation, "method": method,
|
||||
"path": path, "body": body, "attempt": attempt, "started_at": utc_now()})
|
||||
try:
|
||||
status, headers, raw = self.transport(method, path, payload)
|
||||
except (urllib.error.URLError, TimeoutError, ConnectionError):
|
||||
self.archive.write(label + ".meta.json", {"error": "transport_failure", "ended_at": utc_now()})
|
||||
if attempt == 3:
|
||||
raise CollectionError("transport_retry_exhausted") from None
|
||||
self.sleep(float(2 ** (attempt - 1)))
|
||||
continue
|
||||
headers = {k.lower(): v for k, v in headers.items()}
|
||||
require(not self._key or self._key.encode() not in raw, "secret_in_response")
|
||||
self.archive.write(label + ".response.bin", raw)
|
||||
self.archive.write(label + ".meta.json", {"http_status": status, "ended_at": utc_now(),
|
||||
"oversized": len(raw) > MAX_RESPONSE_BYTES})
|
||||
require(len(raw) <= MAX_RESPONSE_BYTES, "response_too_large")
|
||||
if status in RETRY_STATUSES and attempt < 3:
|
||||
self.sleep(retry_delay(headers, attempt))
|
||||
continue
|
||||
require(status == 200, "http_failure")
|
||||
document = strict_json(raw)
|
||||
require(document.get("operation_id") == operation, "operation_mismatch")
|
||||
require(document.get("hotel_id") == self.hotel_id, "hotel_mismatch")
|
||||
require(isinstance(document.get("data"), dict), "invalid_data_envelope")
|
||||
check_warnings(document)
|
||||
return document["data"]
|
||||
raise CollectionError("retry_exhausted")
|
||||
|
||||
|
||||
def reservation_id(row: dict) -> str:
|
||||
require(isinstance(row, dict), "invalid_reservation")
|
||||
values = row.get("reservationIdList")
|
||||
require(isinstance(values, list) and all(isinstance(value, dict) for value in values), "invalid_identity_list")
|
||||
identities = [value.get("id") for value in values if value.get("type") == "Reservation"]
|
||||
require(len(identities) == 1 and isinstance(identities[0], str)
|
||||
and bool(re.fullmatch(r"[A-Za-z0-9_-]{1,128}", identities[0])), "ambiguous_reservation_identity")
|
||||
return identities[0]
|
||||
|
||||
|
||||
def validate_row(row: dict, options: Options) -> str:
|
||||
identity = reservation_id(row)
|
||||
require(row.get("hotelId") == options.hotel_id, "record_hotel_mismatch")
|
||||
stay = row.get("roomStay")
|
||||
require(isinstance(stay, dict) and stay.get("arrivalDate") == options.arrival_date,
|
||||
"record_arrival_mismatch")
|
||||
return identity
|
||||
|
||||
|
||||
def integer(value: object, code: str) -> int:
|
||||
require(type(value) is int and value >= 0, code)
|
||||
return value
|
||||
|
||||
|
||||
def validate_note_counts(search: dict, detail: dict) -> None:
|
||||
"""Check explicit source indicators; absence is not inferred to mean zero."""
|
||||
indicators = search.get("reservationIndicators", [])
|
||||
require(isinstance(indicators, list) and all(isinstance(item, dict) for item in indicators),
|
||||
"invalid_reservation_indicators")
|
||||
for name, block in (("COMMENT", "comments"), ("TRACE", "traces")):
|
||||
values = detail.get(block, [])
|
||||
require(isinstance(values, list) and all(isinstance(item, dict) for item in values),
|
||||
"invalid_note_or_trace_array")
|
||||
expected = [item.get("count") for item in indicators if item.get("indicatorName") == name]
|
||||
require(len(expected) <= 1, "ambiguous_reservation_indicator")
|
||||
if expected:
|
||||
require(integer(expected[0], "invalid_indicator_count") == len(values), "note_or_trace_count_mismatch")
|
||||
|
||||
|
||||
def search_day(reader: Reader, options: Options) -> list[dict]:
|
||||
records, seen = [], set()
|
||||
expected_counts = None
|
||||
for page_number in range(options.max_pages):
|
||||
offset = page_number * options.page_size
|
||||
data = reader.read(SEARCH, body={"arrivalStartDate": options.arrival_date,
|
||||
"arrivalEndDate": options.arrival_date, "limit": options.page_size, "offset": offset,
|
||||
"orderBy": ["ConfirmationNo"], "sortOrder": ["Asc"]})
|
||||
page = data.get("reservations")
|
||||
require(isinstance(page, dict), "invalid_search_page")
|
||||
rows = page.get("reservationInfo")
|
||||
require(isinstance(rows, list) and len(rows) <= options.page_size, "invalid_search_rows")
|
||||
has_more = page.get("hasMore", False) # Oracle documents absence as all rows fetched.
|
||||
require(type(has_more) is bool, "invalid_has_more")
|
||||
for name, expected in (("offset", offset + options.page_size), ("limit", options.page_size), ("count", len(rows))):
|
||||
if name in page:
|
||||
require(integer(page[name], "invalid_pagination_integer") == expected, "pagination_mismatch")
|
||||
counts = {name: integer(page[name], "invalid_total") for name in ("totalResults", "totalPages") if name in page}
|
||||
if expected_counts is None:
|
||||
expected_counts = counts
|
||||
require(counts == expected_counts, "pagination_totals_changed")
|
||||
if "totalResults" in counts:
|
||||
require(counts["totalResults"] <= options.max_records, "record_limit_exceeded")
|
||||
for row in rows:
|
||||
identity = validate_row(row, options)
|
||||
require(identity not in seen, "duplicate_reservation")
|
||||
seen.add(identity)
|
||||
records.append(row)
|
||||
require(len(records) <= options.max_records, "record_limit_exceeded")
|
||||
if not has_more:
|
||||
if "totalResults" in counts:
|
||||
require(counts["totalResults"] == len(records), "incomplete_search_total")
|
||||
if "totalPages" in counts and records:
|
||||
require(counts["totalPages"] == page_number + 1, "incomplete_search_pages")
|
||||
require(bool(records), "empty_source_requires_review")
|
||||
return records
|
||||
require(len(rows) == options.page_size, "short_nonfinal_page")
|
||||
if "totalResults" in counts:
|
||||
require(len(records) < counts["totalResults"], "has_more_contradicts_total")
|
||||
raise CollectionError("page_limit_exceeded")
|
||||
|
||||
|
||||
def collect(options: Options, archive: Archive, reader: Reader) -> dict:
|
||||
"""Fail closed; absence of a successful result.json also means incomplete."""
|
||||
options.validate()
|
||||
require(reader.archive is archive and reader.hotel_id == options.hotel_id, "reader_context_mismatch")
|
||||
result = {"version": "arr-api-capture/v1", "status": "failed", "candidate_capture_complete": False,
|
||||
"report_equivalence_verified": False, "finance_ready": False, "atomic_snapshot": False,
|
||||
"hotel_id": options.hotel_id, "arrival_date": options.arrival_date,
|
||||
"search_records": 0, "verified_details": 0, "search_recheck_equal": False}
|
||||
archive.write("capture.json", {**result, "status": "started", "started_at": utc_now(),
|
||||
"service_url": SERVICE, "application_id": APPLICATION, "options": vars(options),
|
||||
"operations": [SEARCH, DETAIL], "fetch_instructions": FETCH})
|
||||
try:
|
||||
rows = search_day(reader, options)
|
||||
result["search_records"] = len(rows)
|
||||
for row in rows:
|
||||
identity = reservation_id(row)
|
||||
data = reader.read(DETAIL, identity=identity)
|
||||
reservations = data.get("reservations")
|
||||
require(isinstance(reservations, dict), "invalid_detail_envelope")
|
||||
details = reservations.get("reservation")
|
||||
require(isinstance(details, list) and len(details) == 1, "ambiguous_detail_count")
|
||||
detail = details[0]
|
||||
require(validate_row(detail, options) == identity, "detail_identity_mismatch")
|
||||
for name in ("reservationStatus", "lastModifyDateTime"):
|
||||
require(isinstance(row.get(name), str) and bool(row[name]) and detail.get(name) == row[name],
|
||||
"search_detail_state_mismatch")
|
||||
validate_note_counts(row, detail)
|
||||
result["verified_details"] += 1
|
||||
rechecked = search_day(reader, options)
|
||||
require(rechecked == rows, "source_changed_during_collection")
|
||||
result.update(status="complete_candidate_capture", candidate_capture_complete=True, search_recheck_equal=True)
|
||||
except CollectionError as error:
|
||||
result["error"] = str(error)
|
||||
except Exception:
|
||||
# Never expose exception strings, response bodies or credential-bearing requests.
|
||||
result["error"] = "unexpected_collection_failure"
|
||||
finally:
|
||||
result.update(completed_at=utc_now(), http_attempts=reader.request_count)
|
||||
archive.write("result.json", {**result, "files": list(archive.files)})
|
||||
# Return a separate root pin; result.json cannot contain its own digest.
|
||||
result["manifest_sha256"] = archive.files[-1]["sha256"]
|
||||
return result
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--arrival-date", required=True, help="Explicit YYYY-MM-DD; no implicit timezone or business date")
|
||||
parser.add_argument("--hotel-id", required=True, help="Expected server-selected hotel; does not change server routing")
|
||||
parser.add_argument("--credential-file", required=True, type=Path)
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="New private directory outside the repository")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
options = Options(args.arrival_date, args.hotel_id)
|
||||
options.validate()
|
||||
repository = Path(__file__).resolve().parents[2]
|
||||
require(not args.output_dir.resolve().is_relative_to(repository), "output_must_be_outside_repository")
|
||||
key = load_key(args.credential_file)
|
||||
archive = Archive(args.output_dir)
|
||||
reader = Reader(archive, options.hotel_id, HTTPTransport(key), key=key)
|
||||
result = collect(options, archive, reader)
|
||||
except CollectionError as error:
|
||||
result = {"status": "failed", "error": str(error), "finance_ready": False}
|
||||
except Exception:
|
||||
result = {"status": "failed", "error": "local_setup_or_archive_failure", "finance_ready": False}
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 0 if result.get("candidate_capture_complete") else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user