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.
This commit is contained in:
@@ -0,0 +1,192 @@
|
|||||||
|
"""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()
|
||||||
Reference in New Issue
Block a user