← back to Uspto Trtyrap

download_backfill.py

108 lines

#!/usr/bin/env python3
"""
USPTO ODP Bulk Data downloader for TRTYRAP
(Trademark Full Text XML Data (No Images) - Annual Applications, 1884-2025 backfile).

Uses the OFFICIAL Open Data Portal Bulk Data API with an X-API-KEY
(obtained after ID.me verification at account.uspto.gov). This is the
sanctioned path -- it sidesteps the AWS WAF bot-control that blocks
curl / curl_cffi on the raw file bytes.

Key resolution order:
  1) env USPTO_API_KEY
  2) file .apikey in this dir (gitignored)

Run:  .venv/bin/python download_backfill.py
Resume-safe: skips files already fully downloaded (size match).
"""
import os, sys, json, time, pathlib
from curl_cffi import requests

HERE = pathlib.Path(__file__).parent
OUT = HERE / "files"; OUT.mkdir(exist_ok=True)
PRODUCT = "TRTYRAP"
FROM, TO = "1884-04-07", "2025-12-31"

def get_key():
    k = os.environ.get("USPTO_API_KEY")
    if not k and (HERE / ".apikey").exists():
        k = (HERE / ".apikey").read_text().strip()
    if not k:
        sys.exit("No API key. Set USPTO_API_KEY or write it to .apikey")
    return k

def api_get(url, key, **kw):
    h = {"X-API-KEY": key, "Accept": "application/json"}
    return requests.get(url, headers=h, impersonate="chrome", timeout=kw.pop("timeout", 60), **kw)

def list_files(key):
    # Primary ODP bulk-data product endpoint (returns file bag w/ download URIs).
    candidates = [
        f"https://api.uspto.gov/api/v1/datasets/products/{PRODUCT}?fileDataFromDate={FROM}&fileDataToDate={TO}",
        f"https://api.uspto.gov/api/v1/datasets/products/{PRODUCT}",
    ]
    for url in candidates:
        r = api_get(url, key)
        if r.status_code == 200 and "json" in (r.headers.get("content-type") or ""):
            d = r.json()
            files = extract_files(d)
            if files:
                return files
        print(f"  [list] {url} -> {r.status_code} {r.headers.get('content-type')}")
    sys.exit("Could not list files from the API -- inspect the endpoint shape with the real key.")

def extract_files(o, out=None):
    out = [] if out is None else out
    if isinstance(o, dict):
        fn = o.get("fileName") or o.get("productFileName")
        uri = o.get("fileDownloadURI") or o.get("fileDownloadUri") or o.get("downloadUrl")
        if fn and fn.endswith(".zip"):
            out.append({"name": fn, "uri": uri, "size": o.get("fileSize") or o.get("fileSizeBytes")})
        for v in o.values(): extract_files(v, out)
    elif isinstance(o, list):
        for x in o: extract_files(x, out)
    return out

def download(f, key):
    name, uri, size = f["name"], f["uri"], f.get("size")
    dest = OUT / name
    if dest.exists() and size and dest.stat().st_size == int(size):
        return dest.stat().st_size, "skip"
    if not uri:
        uri = f"https://api.uspto.gov/api/v1/datasets/products/{PRODUCT}/files/{name}"
    h = {"X-API-KEY": key, "Accept": "application/zip,application/octet-stream,*/*"}
    with requests.get(uri, headers=h, impersonate="chrome", timeout=1800, stream=True) as r:
        if r.status_code != 200 or "html" in (r.headers.get("content-type") or ""):
            return 0, f"FAIL {r.status_code} {r.headers.get('content-type')}"
        tmp = dest.with_suffix(".part")
        n = 0
        with open(tmp, "wb") as fh:
            for chunk in r.iter_content(chunk_size=1 << 20):
                fh.write(chunk); n += len(chunk)
        # verify zip magic
        with open(tmp, "rb") as fh:
            if fh.read(4) != b"PK\x03\x04":
                tmp.unlink(missing_ok=True)
                return 0, "FAIL not-a-zip"
        tmp.rename(dest)
        return n, "ok"

def main():
    key = get_key()
    print(f"Listing {PRODUCT} files via ODP API...")
    files = list_files(key)
    total = len(files)
    tot_bytes = sum(int(f["size"]) for f in files if f.get("size"))
    print(f"{total} files, ~{tot_bytes/1e9:.1f} GB total\n")
    done_b = 0
    for i, f in enumerate(files, 1):
        t0 = time.time()
        n, status = download(f, key)
        done_b += n
        print(f"[{i}/{total}] {f['name']:40s} {status:8s} {n/1e6:8.1f} MB  "
              f"({done_b/1e9:5.2f} GB cum, {time.time()-t0:4.1f}s)")
    print(f"\nDone. {sum(1 for p in OUT.glob('*.zip'))} zips in {OUT}")

if __name__ == "__main__":
    main()