feat(catalog): curation tooling — ingest, validate, verify, fx

- catalog_ingest.py: bulk-generate the extended tier from a provider's own
  spec. Re-runnable (same input, byte-identical output), core wins on
  collision, and verification stamps survive regeneration
- catalog_validate.py: schema + referential integrity, two rule sets for the
  two tiers, exactly-one-state, and a credential leak scan
- catalog_verify.py / catalog_verify_extended.py: live-test endpoints and
  capture examples. --via-treg routes through the /call proxy so an OAuth
  provider verifies with the token treg already holds; the bulk runner adds
  budget caps, --out/--merge for parallel batches, and balance readback
- catalog_fx_update.py: refresh the FX table so costs display in one currency

Verification lessons are documented, not just implemented: a uniform block of
identical failures is our tooling until proven otherwise; after fixing such a
bug, re-run every batch that ran before the fix; retries are free only under
per-success billing; a spend cap that reads a missing price as zero cannot
fire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jason Zhou
2026-07-28 22:51:23 +10:00
co-authored by Claude Opus 5
parent 7bc5753e2a
commit a6d756759a
5 changed files with 2976 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Refresh src/treg/catalog/fx.yaml from open.er-api.com (free, keyless).
Run on deploy or cron: `uv run python scripts/catalog_fx_update.py`. Only currencies already
listed in fx.yaml are refreshed — adding a new billing currency = add its key once, rerun.
"""
from __future__ import annotations
import json
import urllib.request
from datetime import date
from pathlib import Path
import yaml
FX = Path(__file__).resolve().parent.parent / "src" / "treg" / "catalog" / "fx.yaml"
def main() -> int:
doc = yaml.safe_load(FX.read_text())
live = json.load(urllib.request.urlopen("https://open.er-api.com/v6/latest/USD", timeout=30))
if live.get("result") != "success":
print(f"rate API answered {live.get('result')!r} — fx.yaml left unchanged")
return 1
usd_to = live["rates"]
for cur in doc["rates_to_usd"]:
if cur == "USD" or cur not in usd_to:
continue
doc["rates_to_usd"][cur] = round(1 / usd_to[cur], 4)
doc["updated"] = date.today().isoformat()
FX.write_text(
FX.read_text().split("rates_to_usd:")[0] # keep the header comment block
+ "rates_to_usd:\n"
+ "".join(f" {c}: {v}\n" for c, v in doc["rates_to_usd"].items())
+ f"updated: {doc['updated']}\n"
+ f"source: {doc['source']}\n"
)
print(f"fx.yaml refreshed: {doc['rates_to_usd']} ({doc['updated']})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Validate the endpoint catalog (src/treg/catalog/*.yaml) — schema shape + referential integrity.
Run from the repo root: `uv run python scripts/catalog_validate.py [service ...]`
Exit 0 = valid. Every violation prints one line: `<file>: <problem>`.
Checks (the success criteria from docs/context/architecture/catalog.md):
- provider file's `provider` matches its filename and exists in treg.oauth_providers.REGISTRY
- endpoint ids unique across the WHOLE catalog; id convention `<provider>.<capability>`
- `capability` exists in capabilities.yaml OR the file's own proposed_capabilities
- `platform` equals the capability's first segment and exists in capabilities.yaml platforms
- required fields present; enums valid (scope, method, cost.type)
- a `verified` endpoint must have an existing example_response file
- an extended endpoint a verification run has touched claims exactly one non-empty state
(verified | unverified | untestable | skipped), and an `untestable` one carries no
test_request for a re-verify run to call it with anyway
- no obvious credential leak (Authorization/token values) in any catalog file
Two tiers, two rule sets. `<provider>.yaml` holds hand-curated `tier: core` entries and every rule
above applies. `<provider>.extended.yaml` holds the machine-generated coverage tier written by
scripts/catalog_ingest.py: those entries need only id/platform/method/path/summary, and carry no
capability (nothing has been mapped to the taxonomy yet). Id-prefix, id-uniqueness, platform
integrity and the leak scan apply to BOTH — an extended entry that does declare a capability is
held to the core referential rules.
The evidence rule is deliberately tier-blind, and needed no loosening when the extended tier gained
generated `test_request`s and live verification (scripts/catalog_verify_extended.py): `verified`
means the same thing in both files, so in both it must be backed by an `example_response` file that
exists and a `test_request` to re-check it with. A tier decides how much is EXPECTED of an entry,
never what a claim on it is worth.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent
CATALOG = ROOT / "src" / "treg" / "catalog"
SCOPES = {"any_account", "own_account"}
METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE"}
COST_TYPES = {"per_call", "per_result", "per_success", "free", "quota_rows"}
TIERS = {"core", "extended"}
# the section heading an endpoint files under on its platform page — one lowercase word
DOMAIN = re.compile(r"[a-z][a-z0-9_]*")
REQUIRED = {
"core": ("id", "capability", "platform", "method", "path", "summary"),
"extended": ("id", "platform", "method", "path", "summary"),
}
# The four outcomes an extended endpoint can have once a verification run has touched it. They are
# mutually exclusive by construction, and an entry that has been through the pipeline must claim
# exactly one — "no state" reads as "never attempted", which is a different fact and a lie once a
# run has been over it. This caught a real regression: re-running an endpoint overwrote its result
# record, dropped the reason string, and stamped an EMPTY state that nothing else noticed.
STATES = ("verified", "unverified", "untestable", "skipped")
# a long token-looking literal anywhere in a catalog file is a leak until proven otherwise
LEAK = re.compile(r"(Bearer\s+[A-Za-z0-9+/_=-]{16,}|[A-Za-z0-9+/]{40,}={0,2})")
# ...but URL and API paths are also long runs of [A-Za-z0-9/], and the extended tier is thousands
# of them. Two things separate them from a credential: a path is built of short slash-separated
# segments, and those segments spell words ("dataforseo", "kolContentTags"). A base64 secret hits
# a `/` only about once per 64 characters, so at least one of its segments stays long and wordless.
WORDY = re.compile(r"[a-z]{8,}")
def looks_like_secret(match: str) -> bool:
if match.lower().startswith("bearer"):
return True
return any(
len(seg) >= 24 and not WORDY.search(seg) and any(c.isdigit() for c in seg)
for seg in match.split("/")
)
def fail(errors: list[str], where: str, msg: str) -> None:
errors.append(f"{where}: {msg}")
def main(argv: list[str]) -> int:
errors: list[str] = []
tax = yaml.safe_load((CATALOG / "capabilities.yaml").read_text())
platforms = set(tax.get("platforms") or {})
capabilities = set(tax.get("capabilities") or {})
sys.path.insert(0, str(ROOT / "src"))
from treg.oauth_providers import REGISTRY # noqa: E402
only = set(argv)
files = sorted(p for p in CATALOG.glob("*.yaml") if p.name not in ("capabilities.yaml", "fx.yaml"))
# "tikhub" selects tikhub.yaml AND tikhub.extended.yaml — a service is both its tiers
service_of = {p: p.stem.removesuffix(".extended") for p in files}
if only:
files = [p for p in files if service_of[p] in only]
missing = only - {service_of[p] for p in files}
for m in missing:
fail(errors, m, "no such catalog file")
seen_ids: dict[str, str] = {}
for path in files:
name = path.name
text = path.read_text()
data = yaml.safe_load(text)
if not isinstance(data, dict):
fail(errors, name, "not a mapping")
continue
extended_file = path.name.endswith(".extended.yaml")
service = data.get("provider")
if service != service_of[path]:
fail(errors, name, f"provider '{service}' != filename stem '{service_of[path]}'")
if service not in REGISTRY:
fail(errors, name, f"provider '{service}' not in oauth_providers.REGISTRY")
src = data.get("source")
# core files cite the docs they were curated from; extended files cite the specs they were
# generated from, so that a re-run is reproducible from the file alone
need = "spec_urls" if extended_file else "docs"
if not isinstance(src, dict) or not src.get(need):
fail(errors, name, f"source.{need} missing")
proposed = set(data.get("proposed_capabilities") or {})
known = capabilities | proposed
eps = data.get("endpoints")
if not isinstance(eps, list) or not eps:
fail(errors, name, "endpoints missing or empty")
continue
for ep in eps:
eid = ep.get("id", "<no id>")
where = f"{name}:{eid}"
tier = ep.get("tier", "extended" if extended_file else "core")
if tier not in TIERS:
fail(errors, where, f"bad tier '{tier}'")
tier = "extended" if extended_file else "core"
if extended_file != (tier == "extended"):
fail(errors, where, f"tier '{tier}' does not belong in {name}")
for f in REQUIRED[tier]:
if not ep.get(f):
fail(errors, where, f"missing required field '{f}'")
if eid in seen_ids:
fail(errors, where, f"duplicate id (also in {seen_ids[eid]})")
seen_ids[eid] = name
if service and not eid.startswith(f"{service}."):
fail(errors, where, f"id must start with '{service}.'")
# extended entries are unmapped by design; one that DOES claim a capability is held to
# the same referential rules as core, so a hand-promoted entry can't drift
cap = ep.get("capability", "")
if cap or tier == "core":
if cap not in known:
fail(errors, where, f"capability '{cap}' not in capabilities.yaml or proposed_capabilities")
plat = ep.get("platform", "")
if plat not in platforms:
fail(errors, where, f"platform '{plat}' not in capabilities.yaml platforms")
if cap and plat and cap.split(".")[0] != plat:
fail(errors, where, f"platform '{plat}' != capability's first segment '{cap.split('.')[0]}'")
# `domain` is optional — the loader derives one from the capability id or the path when
# it is absent. Declaring one overrides that, so it has to be the same SHAPE the derived
# ones are: one lowercase word, or the platform page grows a section of one.
# `name` is an optional short DISPLAY title; `summary` stays the provider's own
# description. Light check only: present ⇒ a non-empty string that fits a row heading.
nm = ep.get("name")
if nm is not None and (not isinstance(nm, str) or not nm.strip() or len(nm) > 60):
fail(errors, where, "name must be a non-empty string of at most 60 chars")
dom = ep.get("domain")
if dom is not None and not DOMAIN.fullmatch(str(dom)):
fail(errors, where, f"domain '{dom}' must be a single lowercase word (a-z0-9_)")
if ep.get("scope", "any_account") not in SCOPES:
fail(errors, where, f"bad scope '{ep.get('scope')}'")
if ep.get("method") not in METHODS:
fail(errors, where, f"bad method '{ep.get('method')}'")
cost = ep.get("cost")
if cost is not None or tier == "core":
# cost is optional in the extended tier — several providers publish prices per API
# family rather than per route — but a stated cost must still be a real cost model
if not isinstance(cost, dict) or cost.get("type") not in COST_TYPES:
fail(errors, where, f"cost.type missing or not one of {sorted(COST_TYPES)}")
if ep.get("verified"):
ex = ep.get("example_response")
if not ex:
fail(errors, where, "verified but no example_response")
elif not (CATALOG / ex).is_file():
fail(errors, where, f"example_response '{ex}' does not exist")
if not ep.get("test_request"):
fail(errors, where, "verified but no test_request (nothing to re-verify with)")
if tier == "extended":
# "been through the pipeline" = a run either built it a request or recorded an
# outcome for it. A freshly ingested entry that has never been verified has
# neither, and is left alone.
claimed = [s for s in STATES if ep.get(s)]
touched = ep.get("test_request") or any(s in ep for s in STATES)
if len(claimed) > 1:
fail(errors, where, f"claims {len(claimed)} states at once: {claimed} — "
"verified/unverified/untestable/skipped are exclusive")
elif touched and not claimed:
empty = [s for s in STATES if s in ep]
fail(errors, where, f"no endpoint state: {sorted(empty)} present but empty"
if empty else "has a test_request but no verified/unverified/untestable/"
"skipped saying what happened when it was called")
if ep.get("untestable") and ep.get("test_request"):
# `untestable` means no call is possible — but catalog_verify.py --extended
# replays anything that HAS a test_request, so the pair is not just a
# contradiction on paper: it gets the endpoint called, and billed, by a
# re-verification run that was told it was uncallable.
fail(errors, where, "untestable but carries a test_request — a re-verify run "
"would call it anyway; drop one of the two")
if extended_file:
# extended files are machine-generated from PUBLIC specs and public target ids (TikTok
# secUids, WeChat export ids, CDN URIs, pagination cursors) — long opaque strings that
# pattern-match as secrets endlessly. Credentials only ever travel via TREG_CATALOG_CRED
# env in the verify scripts, so here only the unambiguous leak shape is flagged.
for m in re.finditer(r"Bearer\s+[A-Za-z0-9+/_=-]{16,}", text):
fail(errors, name, f"credential literal in file: '{m.group(0)[:24]}…'")
continue
for m in LEAK.finditer(text):
if looks_like_secret(m.group(0)):
fail(errors, name, f"possible credential literal in file: '{m.group(0)[:24]}…'")
for e in errors:
print(e)
print(f"{'FAIL' if errors else 'OK'} — {len(files)} provider file(s), {len(seen_ids)} endpoint(s), {len(errors)} error(s)")
return 1 if errors else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""Live-verify catalog endpoints and capture example responses.
Usage (credential comes ONLY from the environment — never from a file or argv):
TREG_CATALOG_CRED='<secret>' uv run python scripts/catalog_verify.py <service> [--id <endpoint-id>] [--write]
TREG_CATALOG_CRED='<secret>' uv run python scripts/catalog_verify.py <service> --extended [--id …]
For each endpoint in src/treg/catalog/<service>.yaml it sends `test_request` to base_url+path with
the provider's auth shape (taken from treg.oauth_providers — token_header/format/location/encode),
checks `expect` (default: HTTP 2xx), and with --write saves a truncated example response to
src/treg/catalog/examples/<endpoint-id>.json. Prints one PASS/FAIL line per endpoint.
`--extended` reads <service>.extended.yaml instead, to REPLAY what a bulk verification run already
stamped there — it is a re-verifier, not the bulk runner (that is catalog_verify_extended.py). Three
differences the extended tier forces, all no-ops for core files:
* an entry without a `test_request` is skipped rather than called with no parameters;
* a leading duplicate of base_url's own path is stripped from `path`, because the extended tier
stores routes exactly as the provider's spec spells them and DataForSEO's include the /v3 that
base_url already ends with;
* `test_request.bodyType: form` sends the body as a form AND moves a query-param credential into
it — Just One API's POST routes read the token from the body and reject it in the query.
It does NOT stamp `verified:` in the YAML — the curator does that, only for PASSes, so a stamp is
always a human-reviewed claim. Truncation: arrays -> first 2 items, strings -> 500 chars, and a
whole-document cap; the curator must still read every example for PII before committing.
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import sys
from datetime import date
from pathlib import Path
from urllib.parse import urlsplit
import httpx
import yaml
ROOT = Path(__file__).resolve().parent.parent
CATALOG = ROOT / "src" / "treg" / "catalog"
MAX_STR = 500
MAX_LIST = 2
MAX_BYTES = 10_000
def truncate(node, depth=0):
if isinstance(node, dict):
return {k: truncate(v, depth + 1) for k, v in node.items()}
if isinstance(node, list):
out = [truncate(v, depth + 1) for v in node[:MAX_LIST]]
if len(node) > MAX_LIST:
out.append(f"… {len(node) - MAX_LIST} more item(s) truncated")
return out
if isinstance(node, str) and len(node) > MAX_STR:
return node[:MAX_STR] + f"… [{len(node)} chars total]"
return node
def _shrink_strings(node, limit: int):
if isinstance(node, dict):
return {k: _shrink_strings(v, limit) for k, v in node.items()}
if isinstance(node, list):
return [_shrink_strings(v, limit) for v in node]
if isinstance(node, str) and len(node) > limit:
return node[:limit] + "…"
return node
def dig(doc, dotted: str):
cur = doc
for part in dotted.split("."):
if isinstance(cur, list):
cur = cur[int(part)]
elif isinstance(cur, dict):
cur = cur.get(part)
else:
return None
return cur
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("service")
ap.add_argument("--id", action="append", help="only these endpoint ids")
ap.add_argument("--write", action="store_true", help="write examples/<id>.json for passes")
ap.add_argument("--extended", action="store_true",
help="verify <service>.extended.yaml instead of the curated <service>.yaml "
"(only its entries that carry a test_request are callable)")
ap.add_argument("--via-treg", metavar="TOOL",
help="route through a treg registry's /call proxy (tool name) using ~/.treg/config.json "
"instead of a raw credential — for OAuth providers whose token treg holds")
args = ap.parse_args()
cred = os.environ.get("TREG_CATALOG_CRED")
if not cred and not args.via_treg:
print("TREG_CATALOG_CRED not set (or use --via-treg <tool>)", file=sys.stderr)
return 2
sys.path.insert(0, str(ROOT / "src"))
from treg.oauth_providers import REGISTRY
prov = REGISTRY.get(args.service)
if prov is None:
print(f"unknown provider '{args.service}'", file=sys.stderr)
return 2
suffix = ".extended.yaml" if args.extended else ".yaml"
data = yaml.safe_load((CATALOG / f"{args.service}{suffix}").read_text())
headers, base_query, call_base = {}, {}, None
if args.via_treg:
cfg = json.loads((Path.home() / ".treg" / "config.json").read_text())
call_base = cfg["base_url"].rstrip("/") + f"/call/{args.via_treg}"
headers["X-Treg-Token"] = cfg["token"] # treg's own auth header, not Bearer
if cfg.get("active_org"):
headers["X-Treg-Org"] = str(cfg["active_org"])
else:
secret = cred
if prov.token_encode == "base64" and ":" in secret:
# curator may paste the raw login:password; the registry stores it base64ed
secret = base64.b64encode(secret.encode()).decode()
if prov.token_location == "query":
base_query[prov.token_param] = prov.token_format.format(secret=secret)
else:
headers[prov.token_header or "Authorization"] = (prov.token_format or "Bearer {secret}").format(secret=secret)
failures = 0
today = date.today().isoformat()
with httpx.Client(timeout=60, follow_redirects=True) as client:
for ep in data.get("endpoints", []):
eid = ep["id"]
if args.id and eid not in args.id:
continue
treq = ep.get("test_request")
if not treq:
continue # extended entries without one were never callable; nothing to replay
path = ep["path"]
for k, v in (treq.get("pathParams") or {}).items():
path = path.replace("{%s}" % k, str(v))
base = (call_base or prov.base_url.rstrip("/"))
# the extended tier stores each route exactly as the provider's spec spells it, which
# for DataForSEO includes the /v3 that base_url already ends with — joining blindly
# would request /v3/v3/…
prefix = urlsplit(base).path.rstrip("/")
if prefix and path.startswith(prefix + "/"):
path = path[len(prefix):]
url = base + path
params = {**base_query, **(treq.get("queryParams") or {})}
body = treq.get("body")
form = body if treq.get("bodyType") == "form" else None
if form is not None and base_query:
# form-bodied routes read the credential from the body; leaving it in the query
# as well makes the provider reject the call (verified live on Just One API)
form = {**base_query, **form}
params = {k: v for k, v in params.items() if k not in base_query}
try:
resp = client.request(ep["method"], url, params=params, headers=headers,
data=form,
json=body if body is not None and form is None else None)
except httpx.HTTPError as exc:
print(f"FAIL {eid} — transport error: {exc}")
failures += 1
continue
ok = 200 <= resp.status_code < 300
detail = f"http {resp.status_code}"
doc = None
try:
doc = resp.json()
except ValueError:
pass
expect = ep.get("expect")
if ok and expect and doc is not None:
got = dig(doc, expect["json_path"])
ok = got == expect.get("equals")
detail += f", {expect['json_path']}={got!r}"
if ok:
note = ""
if args.write and doc is not None:
ex_rel = ep.get("example_response") or f"examples/{eid}.json"
out = CATALOG / ex_rel
out.parent.mkdir(parents=True, exist_ok=True)
# shrink recursively until under the cap — never byte-slice serialized JSON
# (a mid-token slice writes an unparseable example file)
shrunk, max_str = truncate(doc), MAX_STR
dump = json.dumps(shrunk, indent=2, ensure_ascii=False)
while len(dump.encode()) > MAX_BYTES and max_str > 15:
max_str //= 4
shrunk = _shrink_strings(shrunk, max_str)
dump = json.dumps(shrunk, indent=2, ensure_ascii=False)
out.write_text(dump + "\n")
note = f" -> {ex_rel}"
print(f"PASS {eid} ({detail}){note} [stamp verified: {today}]")
else:
snippet = (resp.text or "")[:160].replace("\n", " ")
print(f"FAIL {eid} — {detail}: {snippet}")
failures += 1
return 1 if failures else 0
if __name__ == "__main__":
raise SystemExit(main())
+675
View File
@@ -0,0 +1,675 @@
#!/usr/bin/env python3
"""Live-verify the EXTENDED tier in bulk, under a hard spend cap.
TREG_CATALOG_CRED='<secret>' uv run python scripts/catalog_verify_extended.py tikhub \
--budget 1.80 [--platform tiktok] [--limit 50] [--dry-run] [--refresh]
scripts/catalog_verify.py is the CORE-tier tool: a handful of hand-written endpoints, one line of
output each, a human reading every result. That does not survive contact with 1385 machine-
generated entries, where the questions are different — what does this cost, what can I afford to
skip, and how big does the repo get. This script is the extended-tier answer to those:
* **Spend cap, enforced before the call.** Entries run CHEAPEST FIRST and the running total is
the sum of the per-endpoint `cost` of the calls that SUCCEEDED (TikHub bills per_success, so a
failure is free). The next call is only made if its price still fits in `--budget`. Cheapest-
first is not just safety: it buys the most verified endpoints per dollar, and it means a run
that stops early stops on the expensive tail, not somewhere arbitrary.
* **The account's own balance is the ground truth.** The provider's balance is read before and
after and printed as the real spend, because a rate card can be stale in a way our arithmetic
cannot detect. Where the provider states a per-call charge in its response (DataForSEO's
`tasks.0.cost`), that number is also written onto the endpoint as `observed_cost` — what it
ACTUALLY cost, next to the `cost` rate card that says what it should. Balance arithmetic cannot
separate this run's spend from anything else touching the same key, so a summed observed_cost
is the defensible figure for a report.
* **Examples trimmed far harder than core.** 1385 files at core's 10 KB cap would add ~14 MB of
JSON to the repo. Here: arrays keep ONE item, strings clip at 200 chars, 2 KB per document.
An extended example exists to show the response SHAPE; the core tier is where a full-fidelity
example belongs.
* **Deterministic and re-runnable.** Results are written back into the yaml — `verified` + a
`example_response` path on a pass, a one-line `unverified: http <code> …` on a failure. A
re-run skips whatever already carries `verified` (use `--refresh` to re-test those too), so
interrupting a run and restarting it resumes rather than re-billing.
Two retry rules, both free because only a successful call is billed:
* a failure that reads like a flaky upstream ("please retry", a timeout, any 5xx, a 429, a
transport error) is retried up to --retry-attempts times. This is not politeness, it is the
single highest-value lever the script has: TikHub's LinkedIn family went from 8% to 90%
verified across four passes with no change other than being asked again. Conversion per pass
decays fast (672, +30, +16, +14, +2 on tikhub) — a pass that converts ~2 is convergence, and
what is left is genuinely broken rather than flaky.
* a 4xx on an endpoint whose page-size knob we clamped is retried once with the documented
values, which separates "our test request was wrong" from "this endpoint is broken". If that
retry passes, the working `test_request` is what gets written back.
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import re
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import date
from pathlib import Path
from urllib.parse import urlsplit
import httpx
import yaml
ROOT = Path(__file__).resolve().parent.parent
CATALOG = ROOT / "src" / "treg" / "catalog"
# far tighter than catalog_verify.py's core-tier caps — see the module docstring
MAX_STR = 200
MAX_LIST = 1
MAX_BYTES = 2_000
RATE_LIMIT_PER_SEC = 8.0 # provider allows 10/s; leave headroom so we are never the cause of a 429
# Per-call ceiling. Was 60s, which sat right on top of the slowest real endpoint we have measured:
# DataForSEO's /v3/merchant/amazon/products/live/advanced answered in 55.3s on a live probe. A
# timeout gets recorded as the endpoint's verdict, so a margin that thin turns a slow-but-working
# route into an intermittent "unverified" — the failure mode this file exists to avoid.
CALL_TIMEOUT = 180
class Throttle:
"""Global request pacer shared by the worker threads."""
def __init__(self, per_sec: float) -> None:
self._gap = 1.0 / per_sec
self._lock = threading.Lock()
self._next = 0.0
def wait(self) -> None:
with self._lock:
now = time.monotonic()
at = max(now, self._next)
self._next = at + self._gap
time.sleep(max(0.0, at - now))
MIN_STR = 60 # a string clipped below this stops showing what the field CONTAINS
def trim(node, str_max: int = MAX_STR, depth_max: int = 99, depth: int = 0, keys_max: int = 99):
if isinstance(node, dict):
if depth >= depth_max:
return f"… {{{len(node)} field(s) truncated}}"
out = {k: trim(v, str_max, depth_max, depth + 1, keys_max)
for k, v in list(node.items())[:keys_max]}
if len(node) > keys_max:
out["…"] = f"{len(node) - keys_max} more field(s) truncated"
return out
if isinstance(node, list):
if depth >= depth_max:
return f"… [{len(node)} item(s) truncated]"
out = [trim(v, str_max, depth_max, depth + 1, keys_max) for v in node[:MAX_LIST]]
if len(node) > MAX_LIST:
out.append(f"… {len(node) - MAX_LIST} more item(s) truncated")
return out
if isinstance(node, str) and len(node) > str_max:
return node[:str_max] + f"… [{len(node)} chars]"
return node
def render_example(doc) -> str:
"""Trim `doc` until it serialises under MAX_BYTES. Never byte-slices the JSON.
Depth is given up before string length. What an extended example is FOR is showing the shape
of the response — which fields exist and what a value looks like — so clipping every string to
a dozen characters to preserve six levels of nesting gets the trade backwards: it keeps the
skeleton and throws away the only part that tells you what the endpoint returns.
"""
for str_max in (MAX_STR, 120, MIN_STR):
for depth_max, keys_max in ((99, 99), (6, 40), (5, 25), (4, 15), (3, 10)):
dump = json.dumps(trim(doc, str_max, depth_max, 0, keys_max), indent=2, ensure_ascii=False)
if len(dump.encode()) <= MAX_BYTES:
return dump + "\n"
return dump + "\n"
def cost_of(ep: dict) -> float:
"""What the next call to this endpoint should be budgeted at.
The rate card (`cost`) is preferred, but several providers publish prices per API family rather
than per route and their entries carry none at all — every DataForSEO entry, for one. A missing
price silently reads as FREE, which makes `--budget` inert: a run can queue the whole file at an
estimated $0.000 and still spend real money, with only the balance readback noticing afterwards.
So fall back to `observed_cost`, what the provider charged last time. It is the better number
anyway — measured rather than transcribed — and it makes the cap bind from the second run on.
"""
c = ep.get("cost") or {}
try:
listed = float(c.get("value") or 0.0)
except (TypeError, ValueError):
listed = 0.0
if listed:
return listed
observed = ep.get("observed_cost")
return float(observed) if isinstance(observed, (int, float)) else 0.0
def auth_headers(service: str, cred: str) -> tuple[dict, dict, str]:
sys.path.insert(0, str(ROOT / "src"))
from treg.oauth_providers import REGISTRY
prov = REGISTRY.get(service)
if prov is None:
raise SystemExit(f"unknown provider '{service}'")
headers, query = {}, {}
secret = cred
if prov.token_encode == "base64" and ":" in secret:
secret = base64.b64encode(secret.encode()).decode()
if prov.token_location == "query":
query[prov.token_param] = prov.token_format.format(secret=secret)
else:
headers[prov.token_header or "Authorization"] = (
prov.token_format or "Bearer {secret}").format(secret=secret)
return headers, query, prov.base_url.rstrip("/")
def join(base: str, path: str) -> str:
"""base_url + a catalog path, without doubling a shared prefix.
The extended tier stores each route exactly as the provider's spec spells it, and DataForSEO's
include the `/v3` that its `base_url` already ends with — so a blind join requests
`/v3/v3/serp/...` and every call 404s. Same fix as catalog_verify.py; a no-op for providers
whose base_url is a bare host.
"""
prefix = urlsplit(base).path.rstrip("/")
if prefix and path.startswith(prefix + "/"):
path = path[len(prefix):]
return base + path
# service -> (free route that reports the account balance, dotted path to the number)
BALANCE_ROUTE = {
"tikhub": ("/api/v1/tikhub/user/get_user_info", "user_data.balance"),
"dataforseo": ("/v3/appendix/user_data", "tasks.0.result.0.money.balance"),
}
def dig(doc, dotted: str):
"""Walk a dotted path, where a numeric segment indexes a list (`tasks.0.result.0.money`)."""
cur = doc
for part in dotted.split("."):
if isinstance(cur, dict):
cur = cur.get(part)
elif isinstance(cur, list) and part.lstrip("-").isdigit():
try:
cur = cur[int(part)]
except IndexError:
return None
else:
return None
return cur
BALANCE_ATTEMPTS = 3
def read_balance(client: httpx.Client, service: str, base: str, headers: dict, query: dict):
"""The account's own balance, or None if the provider won't say.
Retried, because a balance endpoint under repeated polling is exactly where providers get
flaky: DataForSEO answers with a null `result` when polled in quick succession. A None here is
not fatal — the run's own arithmetic still bounds the spend — but it silently removes the only
independent check on that arithmetic, so it is worth asking more than once.
"""
route = BALANCE_ROUTE.get(service)
if not route:
return None
for attempt in range(BALANCE_ATTEMPTS):
try:
r = client.get(join(base, route[0]), headers=headers, params=query, timeout=30)
value = dig(r.json(), route[1])
if isinstance(value, (int, float)):
return value
except Exception:
pass
time.sleep(1.0 * (attempt + 1))
return None
def unclamped(ep: dict) -> dict | None:
"""`test_request` with page-size knobs restored to their documented values, or None.
Used for the single free retry after a 4xx: it tells apart an endpoint that rejects our small
page size from an endpoint that is actually broken.
"""
test = json.loads(json.dumps(ep.get("test_request") or {}))
schema = ((ep.get("input") or {}).get("queryParams") or {})
changed = False
for name, value in list((test.get("queryParams") or {}).items()):
doc_example = (schema.get(name) or {}).get("example")
if doc_example not in (None, "") and doc_example != value:
test["queryParams"][name] = doc_example
changed = True
return test if changed else None
# TikHub answers a failed SCRAPE with 400 and this text — the request was well-formed, their
# upstream fetch just did not come back. LinkedIn measured ~1-in-3 on first attempt and passed on
# a retry, so a single attempt would have written off 44 working endpoints as broken. Retrying is
# free (nothing is billed unless the call succeeds), which makes "try again" strictly better than
# recording a verdict we know is unreliable.
RETRYABLE = re.compile(r"please retry|timed? ?out|temporarily|try again|too fast", re.I)
RETRY_ATTEMPTS = 3
# TikHub rate-limits PER ROUTE at 1 request/second, independently of the account-wide 10/s that
# RATE_LIMIT_PER_SEC paces. The global throttle cannot help here — it spaces consecutive requests
# across DIFFERENT routes — so any retry, which by definition hits the same route again, must wait
# out that second itself. A sub-second backoff turns a retry into a guaranteed 429, and the 429
# then gets recorded as if the endpoint had failed. That mistake cost 66 endpoints a real verdict
# on the first full run.
PER_ROUTE_GAP = 1.3
def retryable(detail: str, status: int | None) -> bool:
if status is not None and (status >= 500 or status == 429):
return True
if detail.startswith("transport"):
return True
return bool(RETRYABLE.search(detail))
def call_with_retries(client: httpx.Client, base: str, ep: dict, test: dict, headers: dict,
query: dict, throttle: Throttle, max_attempts: int = RETRY_ATTEMPTS
) -> tuple[bool, str, object, int]:
"""`call`, repeated while the failure looks like a flaky upstream rather than a verdict."""
attempts = 0
for attempt in range(max_attempts):
attempts += 1
ok, detail, doc, status = call(client, base, ep, test, headers, query, throttle)
if ok or not retryable(detail, status):
return ok, detail, doc, attempts
time.sleep(PER_ROUTE_GAP * (attempt + 1))
return ok, detail, doc, attempts
def call(client: httpx.Client, base: str, ep: dict, test: dict, headers: dict, query: dict,
throttle: Throttle) -> tuple[bool, str, object, int | None]:
path = ep["path"]
for k, v in (test.get("pathParams") or {}).items():
path = path.replace("{%s}" % k, str(v))
params = {**query, **(test.get("queryParams") or {})}
body = test.get("body")
form = body if test.get("bodyType") == "form" else None
if form is not None and query:
# these routes take the token in the form body; leaving a copy in the query string makes
# the provider reject the call with a misleading auth error (verified live on Just One API)
form = {**query, **form}
params = {k: v for k, v in params.items() if k not in query}
throttle.wait()
started = time.monotonic()
try:
resp = client.request(ep["method"], join(base, path), params=params, headers=headers,
data=form, json=body if body is not None and form is None else None,
timeout=CALL_TIMEOUT)
except httpx.HTTPError as exc:
return False, f"transport {type(exc).__name__}", None, None
finally:
LAST_ELAPSED.seconds = round(time.monotonic() - started, 2)
doc = None
try:
doc = resp.json()
except ValueError:
pass
ok = 200 <= resp.status_code < 300
# TikHub answers 200 for the HTTP layer and puts the real verdict in the body's `code`
if ok and isinstance(doc, dict) and isinstance(doc.get("code"), int) and doc["code"] not in (0, 200):
ok, detail = False, f"http {resp.status_code} body-code {doc['code']}"
else:
detail = f"http {resp.status_code}"
if not ok:
if doc is None:
msg = resp.text or ""
else:
msg = (doc.get("detail") if isinstance(doc, dict) else None) or doc
if isinstance(msg, dict):
msg = msg.get("message") or msg.get("detail") or ""
snippet = re.sub(r"\s+", " ", str(msg)).strip()[:80]
if snippet:
detail = f"{detail}: {snippet}"
return ok, detail, doc, resp.status_code
def save(path: Path, data: dict, header: str) -> None:
body = yaml.safe_dump(data, sort_keys=False, allow_unicode=True, width=4096,
default_flow_style=False)
path.write_text(header + body)
RESULT_FIELDS = ("verified", "unverified", "skipped", "example_response", "test_request",
"observed_cost", "observed_time")
class _Elapsed(threading.local):
"""Per-thread stopwatch for the last request, so `call` keeps its return signature."""
seconds = None
LAST_ELAPSED = _Elapsed()
# `observed_time` is OUR wall-clock around the request, not a number read out of the response.
# Only DataForSEO reports its own duration, and TikHub's `time` field is a TIMESTAMP
# ("2026-07-27 23:27:48") — an extractor that trusted the field name would write a date string
# into a numeric column. Client-side is also the number that matters here: CALL_TIMEOUT applies to
# our client, so what tells us a route is near the ceiling is how long WE waited. Recorded because
# a timeout is written down as the endpoint's verdict, and DataForSEO's
# /v3/merchant/amazon/products/live/advanced answered in 55.3s and 26.2s on two identical calls —
# a 2x spread that would have made a healthy route intermittently "unverified" under the old 60s.
# service -> how to read what the provider says THIS call cost, from its own response.
# `cost` in the catalog is a rate card: what the price list claims, entered by hand and liable to
# age. `observed_cost` is what the provider actually charged, straight from the response that
# produced the example — so the two can be compared, a run's spend can be summed without balance
# arithmetic (which cannot separate our calls from anyone else's on the same key), and the router
# later has a real per-endpoint price to rank on.
# TikHub is deliberately absent: it publishes no per-call charge in the response body, so its
# entries carry no observed_cost and its rate card stays the only figure available.
OBSERVED_COST = {
"dataforseo": lambda doc: dig(doc, "tasks.0.cost"),
}
def retrim(service: str) -> int:
"""Re-apply the current size caps to already-captured examples, without calling anything.
The captured JSON is on disk, so shrinking it further is a local transformation — re-running
the endpoint to get a smaller file would pay the provider a second time for a response we
already have. Only genuinely unreadable files are worth a re-capture, and this reports those
rather than deleting them.
Trimming is one-way: this cannot restore detail an earlier, coarser pass already dropped. It
exists to bring old captures under a cap that got stricter, which is the only direction that
matters for repo size.
"""
file = CATALOG / f"{service}.extended.yaml"
eps = (yaml.safe_load(file.read_text()) or {}).get("endpoints") or []
shrunk = saved = unreadable = 0
broken: list[str] = []
for ep in eps:
rel = ep.get("example_response")
if not rel:
continue
path = CATALOG / rel
if not path.is_file():
continue
before = path.stat().st_size
if before <= MAX_BYTES:
continue
try:
doc = json.loads(path.read_text())
except ValueError:
unreadable += 1
broken.append(ep["id"])
continue
path.write_text(render_example(doc))
after = path.stat().st_size
if after < before:
shrunk += 1
saved += before - after
print(f"re-trimmed {shrunk} example(s), reclaimed {saved / 1024:.0f} KB, $0 spent")
if unreadable:
print(f"{unreadable} unreadable file(s) need a re-capture: {broken[:5]}", file=sys.stderr)
over = [ep["id"] for ep in eps if ep.get("example_response")
and (CATALOG / ep["example_response"]).is_file()
and (CATALOG / ep["example_response"]).stat().st_size > MAX_BYTES]
print(f"{len(over)} example(s) still over {MAX_BYTES} B" + (f", e.g. {over[:3]}" if over else ""))
return 0
def merge_results(service: str, paths: list[Path]) -> int:
"""Fold batch result files into `<service>.extended.yaml`. The ONLY writer of that file.
Splitting a provider across concurrent agents means splitting the endpoints, not the file: two
processes rewriting one yaml lose each other's work, and the loss is silent because both
produce a valid document. So a batch run takes `--out` and writes only its own results, and one
merge folds them in. Example RESPONSES need no such care — each is its own file named after the
endpoint that produced it, so disjoint batches never touch the same one.
A batch may only report on endpoints it was given; an id that is not already in the yaml is
refused rather than added, because the endpoint list is catalog_ingest.py's to own.
"""
file = CATALOG / f"{service}.extended.yaml"
text = file.read_text()
header = text[: text.index("\nprovider:") + 1] if "\nprovider:" in text else ""
data = yaml.safe_load(text)
by_id = {e["id"]: e for e in data["endpoints"]}
applied, unknown, conflicts = 0, [], []
seen: dict[str, Path] = {}
for path in paths:
payload = json.loads(path.read_text())
if payload.get("service") != service:
raise SystemExit(f"{path}: results are for '{payload.get('service')}', not '{service}'")
for res in payload.get("results") or []:
eid = res.get("id")
ep = by_id.get(eid)
if ep is None:
unknown.append(eid)
continue
if eid in seen:
conflicts.append(f"{eid} (in {seen[eid].name} and {path.name})")
continue
seen[eid] = path
for field in RESULT_FIELDS:
if field in res:
# an EMPTY value counts as absent, never as a claim. A state key present but
# blank passes a "has a state" check while saying nothing, which is worse than
# no key at all — the entry looks handled and isn't. (Same bug the dataforseo
# run hit when a re-run dropped a curated reason string.)
if res[field] is None or res[field] == "":
ep.pop(field, None)
else:
ep[field] = res[field]
if res.get("verified"):
ep.pop("untestable", None)
# exactly one outcome state survives a merge: a batch that reports an endpoint as
# verified must not leave last run's `unverified` sitting beside it
states = {"verified", "unverified", "skipped"}
if states & set(res):
for stale in states - {s for s in res if res.get(s)}:
ep.pop(stale, None)
applied += 1
if conflicts:
# two batches claiming one endpoint means the split was wrong; merging either result would
# hide that, so refuse and let the caller fix the partition
raise SystemExit("overlapping batches: " + "; ".join(conflicts[:10]))
if unknown:
print(f"warning: {len(unknown)} unknown id(s) ignored, e.g. {unknown[:3]}", file=sys.stderr)
save(file, data, header)
print(f"merged {applied} result(s) from {len(paths)} batch file(s) into {file.relative_to(ROOT)}")
print(f"{sum(1 for e in data['endpoints'] if e.get('verified'))} of {len(data['endpoints'])} verified")
return 0
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("service")
ap.add_argument("--budget", type=float, default=1.0, help="max USD to spend this run")
ap.add_argument("--platform", action="append", help="only these platforms")
ap.add_argument("--limit", type=int, help="stop after N calls")
ap.add_argument("--max-cost", type=float, help="skip endpoints priced above this")
ap.add_argument("--concurrency", type=int, default=5)
ap.add_argument("--refresh", action="store_true", help="re-test endpoints already verified")
ap.add_argument("--dry-run", action="store_true", help="plan and cost only; make no calls")
ap.add_argument("--out", type=Path, metavar="FILE",
help="write results as JSON here instead of touching the yaml (batch mode)")
ap.add_argument("--merge", type=Path, nargs="+", metavar="FILE",
help="fold --out result files into the yaml; makes no calls")
ap.add_argument("--retry-attempts", type=int, default=RETRY_ATTEMPTS,
help="attempts per endpoint when the failure looks transient. Raising this is "
"free (only successes bill) and pays off on providers whose scraper is "
"flaky rather than broken — TikHub's Douyin/LinkedIn families convert "
"steadily across repeated attempts")
ap.add_argument("--retrim", action="store_true",
help="re-apply the size caps to captured examples locally; makes no calls")
args = ap.parse_args(argv)
if args.merge:
return merge_results(args.service, args.merge)
if args.retrim:
return retrim(args.service)
cred = os.environ.get("TREG_CATALOG_CRED")
if not cred and not args.dry_run:
print("TREG_CATALOG_CRED not set", file=sys.stderr)
return 2
file = CATALOG / f"{args.service}.extended.yaml"
text = file.read_text()
header = text[: text.index("\nprovider:") + 1] if "\nprovider:" in text else ""
data = yaml.safe_load(text)
eps = data["endpoints"]
todo = [e for e in eps if e.get("test_request") is not None]
if args.platform:
todo = [e for e in todo if e["platform"] in set(args.platform)]
if not args.refresh:
todo = [e for e in todo if not e.get("verified")]
if args.max_cost is not None:
todo = [e for e in todo if cost_of(e) <= args.max_cost]
todo.sort(key=lambda e: (cost_of(e), e["platform"], e["path"]))
print(f"{len(eps)} endpoint(s); {sum(1 for e in eps if e.get('test_request') is not None)} testable; "
f"{len(todo)} queued this run")
print(f"queued at list price: ${sum(cost_of(e) for e in todo):.3f} budget ${args.budget:.2f}")
if args.dry_run:
return 0
headers, query, base = auth_headers(args.service, cred)
spent = 0.0
stats = {"pass": 0, "fail": 0, "skipped_budget": 0, "retried": 0}
touched: set[str] = set()
lock = threading.Lock()
throttle = Throttle(RATE_LIMIT_PER_SEC)
stop = threading.Event()
calls = 0
with httpx.Client(timeout=60, follow_redirects=True) as client:
before = read_balance(client, args.service, base, headers, query)
print(f"balance before: {before}")
def work(ep: dict) -> None:
nonlocal spent, calls
price = cost_of(ep)
with lock:
if stop.is_set():
return
if spent + price > args.budget:
# the FOURTH state (convention shared with the dataforseo/justoneapi run): a
# usable test request exists and the call was simply never made. Calling this
# `unverified` would invent a failure that never happened, and `untestable` would
# be a false claim about the endpoint. A `skipped` entry clears with money and a
# re-run; the other two need a human.
stats["skipped_budget"] += 1
if ep.get("verified"):
# already proven by an earlier run and merely not RE-checked by this one.
# Downgrading it to `skipped` would throw away a real verification, and
# emitting both states puts a contradiction in the --out file that a later
# --merge would fold into the yaml. Leave it alone and report nothing.
return
ep["skipped"] = (f"not called — ${price:.4f} would exceed this run's "
f"${args.budget:.2f} budget")
ep.pop("unverified", None)
touched.add(ep["id"])
return
if args.limit and calls >= args.limit:
stop.set()
return
calls += 1
# reserve the price up front so N concurrent workers cannot jointly overshoot;
# it is released below if the call fails (failures are not billed)
spent += price
ok, detail, doc, tries = call_with_retries(client, base, ep, ep["test_request"],
headers, query, throttle,
args.retry_attempts)
if not ok and detail.startswith("http 4"):
alt = unclamped(ep)
if alt:
time.sleep(PER_ROUTE_GAP) # same route again — clear its 1/s window first
stats["retried"] += 1
ok2, detail2, doc2, _ = call_with_retries(client, base, ep, alt, headers,
query, throttle, args.retry_attempts)
if ok2:
ep["test_request"] = alt
ok, detail, doc = ok2, detail2, doc2
with lock:
if not ok:
spent -= price
if ok and doc is not None:
rel = f"examples/{ep['id']}.json"
out = CATALOG / rel
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(render_example(doc))
ep["verified"] = date.today().isoformat()
ep["example_response"] = rel
observed = (OBSERVED_COST.get(args.service) or (lambda _d: None))(doc)
if isinstance(observed, (int, float)):
ep["observed_cost"] = observed
if isinstance(LAST_ELAPSED.seconds, (int, float)):
ep["observed_time"] = LAST_ELAPSED.seconds
ep.pop("unverified", None)
ep.pop("skipped", None)
with lock:
touched.add(ep["id"])
stats["pass"] += 1
else:
# never write a blank state: an empty reason satisfies "has a state" while telling
# the next reader nothing, so fall back to the bare status rather than to silence
ep["unverified"] = detail.strip() or "failed with no status or message"
ep.pop("verified", None)
ep.pop("example_response", None)
ep.pop("skipped", None)
with lock:
touched.add(ep["id"])
stats["fail"] += 1
done = stats["pass"] + stats["fail"]
if done % 50 == 0:
print(f" … {done} called, {stats['pass']} pass, ${spent:.3f} spent", flush=True)
try:
with ThreadPoolExecutor(max_workers=args.concurrency) as pool:
list(pool.map(work, todo))
except KeyboardInterrupt:
stop.set()
print("interrupted — writing partial results", file=sys.stderr)
after = read_balance(client, args.service, base, headers, query)
if args.out:
# batch mode: report only what THIS run called, so the merge cannot resurrect a stale
# verdict for an endpoint another batch owns
results = [{"id": e["id"], **{f: e.get(f) for f in RESULT_FIELDS}}
for e in eps if e["id"] in touched]
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(
{"service": args.service, "generated": date.today().isoformat(),
"platforms": sorted({e["platform"] for e in todo}), "results": results},
indent=2, ensure_ascii=False) + "\n")
else:
save(file, data, header)
print(f"\nPASS {stats['pass']} FAIL {stats['fail']} skipped-for-budget {stats['skipped_budget']}"
f" retried {stats['retried']}")
print(f"estimated spend ${spent:.3f}; balance {before} -> {after}"
+ (f" (actual ${before - after:.3f})" if isinstance(before, (int, float))
and isinstance(after, (int, float)) else ""))
if args.out:
print(f"wrote {len(results)} result(s) to {args.out}"
f" — {file.name} NOT touched; apply with --merge {args.out}")
else:
print(f"{sum(1 for e in eps if e.get('verified'))} of {len(eps)} endpoint(s) now verified;"
f" wrote {file.relative_to(ROOT)}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))