Files
AIChatExporter/tools/mint_headers_probe.py
T
JesseMarkowitz d30a9510bb tools: find what the browser sends that we don't
The decisive fact arrived: the images DO render in ChatGPT. So a valid
signature exists for files that hand us 403, and there is a route to find.

The rest is now pinned down. /files/{id}/download is the minting endpoint —
a working file's download_url is the same estuary/content URL the browser
uses. Signatures are per-file: swapping an id into a working URL returns
"Invalid signature or expired URL", and a ts without a sig gets the same.
So the difference is in the request, not the route.

Our call sends Authorization: Bearer plus cookies. A browser leans on
cookies and adds client headers a gated endpoint may check. This walks
those variants — no Authorization, conversation Referer, oai-device-id,
oai-language, sec-fetch-*, an image Accept, a conversation_id param —
against a file that is currently refused, and reports which mints a URL.

If none do, it prints the DevTools recipe for finding the minting call by
searching the Network panel for the file id.
2026-08-17 11:07:18 -04:00

193 lines
6.4 KiB
Python

"""The browser mints signatures for files we get 403 on. What does it send that we don't?
Established:
* The images DO render in ChatGPT, so a valid signature exists for them.
* /files/{id}/download is the minting endpoint — a working file's
download_url is the same estuary/content URL the browser uses.
* Signatures are per-file: swapping an id into a working URL gives
"Invalid signature or expired URL".
So the difference is in the request, not the route. Our call sends an
Authorization: Bearer header plus session cookies; a browser leans on cookies
and adds client headers (oai-device-id, oai-client-version, a conversation
Referer) that a gated endpoint may check. This walks those variants against a
file that is currently refused, and prints which combination mints a URL.
Any 200 carrying a download_url is the answer, and the exporter adopts those
headers.
Run from the project root:
python tools/mint_headers_probe.py
"""
import json
import os
import re
import sys
import uuid
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from dotenv import load_dotenv
load_dotenv()
from src.providers.chatgpt import BASE_URL, ChatGPTProvider, parse_asset_file_id # noqa: E402
DEAD_RE = re.compile(r"🖼️ \*\*Image attached\*\* — `([^`]+)`\s*\(([^)]*)\)")
CONV_ID_RE = re.compile(r"^conversation_id:\s*(\S+)", re.MULTILINE)
def find_failed(export_dir: Path) -> list[tuple[str, str]]:
"""(file_id, conversation_id) for every image that failed to download."""
out = []
for md in export_dir.rglob("*.md"):
try:
text = md.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
conv = CONV_ID_RE.search(text)
if not conv:
continue
for ref, _meta in DEAD_RE.findall(text):
fid = parse_asset_file_id(ref)
if fid:
out.append((fid, conv.group(1)))
return out
def main() -> None:
export_dir = Path(os.getenv("EXPORT_DIR", "./exports")).expanduser()
failed = find_failed(export_dir)
if not failed:
print(f"no failed images found under {export_dir}")
return
provider = ChatGPTProvider()
session = provider._session
# Pick one that still exists — a deleted file 403s no matter what we send.
target = conv_id = None
for fid, cid in failed:
provider._pace()
meta = session.request("GET", f"{BASE_URL}/files/{fid}", timeout=30)
if meta.status_code == 200:
target, conv_id = fid, cid
break
if not target:
print("every failure is a deleted file — nothing to test")
return
url = f"{BASE_URL}/files/{target}/download"
print("=" * 78)
print(f"Minting attempts for {target}")
print(f" conversation {conv_id}")
print("=" * 78)
def attempt(
label: str,
*,
add: dict | None = None,
drop: tuple[str, ...] = (),
target_url: str | None = None,
) -> bool:
removed = {}
for header in drop:
for key in list(session.headers.keys()):
if key.lower() == header.lower():
removed[key] = session.headers.pop(key)
try:
provider._pace()
resp = session.request(
"GET", target_url or url, headers=add or None, timeout=30
)
except Exception as e: # noqa: BLE001 - diagnostic
print(f" {label:<46} error {type(e).__name__}")
return False
finally:
session.headers.update(removed)
try:
body = resp.json()
except Exception: # noqa: BLE001 - diagnostic
body = {}
if resp.status_code == 200 and isinstance(body, dict) and body.get("download_url"):
print(f" {label:<46} 200 ← MINTED")
print(f" {str(body['download_url'])[:110]}")
return True
detail = body.get("detail") if isinstance(body, dict) else None
print(f" {label:<46} {resp.status_code} {json.dumps(detail, default=str)[:60] if detail else ''}")
return False
device_id = str(uuid.uuid4())
winners = []
checks = [
("baseline (bearer + cookies)", {}, ()),
("no Authorization (cookies only)", {}, ("Authorization",)),
("Referer: the conversation", {"Referer": f"https://chatgpt.com/c/{conv_id}"}, ()),
("oai-device-id", {"oai-device-id": device_id}, ()),
("oai-language", {"oai-language": "en-US"}, ()),
(
"browser-ish set",
{
"Referer": f"https://chatgpt.com/c/{conv_id}",
"oai-device-id": device_id,
"oai-language": "en-US",
"sec-fetch-site": "same-origin",
"sec-fetch-mode": "cors",
"sec-fetch-dest": "empty",
},
(),
),
(
"browser-ish, no Authorization",
{
"Referer": f"https://chatgpt.com/c/{conv_id}",
"oai-device-id": device_id,
"oai-language": "en-US",
},
("Authorization",),
),
("Accept: image/*", {"Accept": "image/avif,image/webp,*/*"}, ()),
]
for label, add, drop in checks:
if attempt(label, add=add, drop=drop):
winners.append(label)
# Same call with the conversation named in the query string.
if attempt(
"?conversation_id=",
target_url=f"{url}?conversation_id={conv_id}",
):
winners.append("?conversation_id=")
print()
print("=" * 78)
print("Result")
print("=" * 78)
if winners:
print(" Minted by:")
for label in winners:
print(f" {label}")
print()
print(" → Adopt those headers in ChatGPTProvider and the 11 refused")
print(" images become downloadable.")
else:
print(" No header combination minted a URL.")
print()
print(" The browser is getting its signature some other way. Find it:")
print(" 1. Open the conversation in ChatGPT with DevTools → Network.")
print(" 2. Press Ctrl+F (search inside requests) and search for")
print(f" {target[5:22]}")
print(" 3. The hit that is NOT the estuary/content request is the")
print(" call that mints the signature — send me its URL, method,")
print(" and (if POST) its request body.")
if __name__ == "__main__":
main()