I recently downloaded my ChatGPT data export and discovered that it contains my exported conversations along with associated images, uploaded documents, source code, and other files.
The export includes a chat.html file, but with a large ChatGPT history it can be difficult to use as an archive for individual conversations.
With ChatGPT’s help, I created and tested a standalone Python script that searches an official ChatGPT data-export ZIP for an exact chat name and extracts that individual conversation into its own readable HTML archive.
The result can be opened directly in Chrome, Edge, Firefox, or another modern browser and includes the full visible conversation, formatting, code, tables, images, and associated attachments.
The Python script uses only Python’s standard library. No additional Python packages are required.
1. Request Your ChatGPT Data Export
In ChatGPT, open:
Settings → Data controls → Export data
Click Export and confirm the request.
ChatGPT will prepare your data and send an email when the export is ready to download.
My export took approximately 3–4 days to arrive. Your processing time may be different.
When the email arrives, download the ZIP file to your computer.
Keep this ZIP private. It can contain your ChatGPT conversations and files you have uploaded.
2. Install Python
If Python is not already installed on your Windows computer, download it from the official Python website.
Do not download Python from an unknown third-party download site.
On the Python download page, download a current Python 3 Windows installer (64-bit).
3. Create a Working Directory
Create a directory where you will keep your ChatGPT export ZIP and the Python extraction script.
For example:
C:\ChatGPT Export
Place both of these files in that directory (source code for chatgpt_extract_chat.py is listed below):
C:\ChatGPT Export\
chatgpt_extract_chat.py
your-chatgpt-export.zip
Do not unzip the ChatGPT export.
The Python script reads the export directly from the original ZIP file.
For these instructions, the ChatGPT export ZIP and chatgpt_extract_chat.py are kept in the same directory.
The extracted conversation will also be written to a new folder inside this same directory.
4. Open Command Prompt in the Working Directory
Open Command Prompt and change to the directory you created.
For example:
cd /d "C:\ChatGPT Export"
Your prompt should now look something like:
C:\ChatGPT Export>
You are now ready to run the extraction script.
5. Choose the Chat Name
You need the exact title of the ChatGPT conversation you want to extract.
For example:
Arlington Venue Targets App (Part 1)
The title is the name displayed for that conversation in ChatGPT.
6. Run the Extraction Script
The command has three parameters:
python chatgpt_extract_chat.py INPUT_ZIP CHAT_NAME OUTPUT_FOLDER
7. What Happens When It Finishes?
If the command succeeds, the script displays something similar to:
Done.
Conversation : Arlington Venue Targets App (Part 1)
Output : Arlington Venue Targets App (Part 1)
Open : Arlington Venue Targets App (Part 1)\index.html
8. Read Your Extracted Chat
Open:
Arlington Venue Targets App (Part 1)
and double-click:
index.html
The extracted conversation will open locally in your normal web browser.
You do not need:
-
A web server
-
An Internet connection to read the extracted archive
-
A browser extension
-
Node.js
-
pip -
Additional Python libraries
-
A special ChatGPT archive viewer
The resulting HTML is designed to be a clean, readable representation of the original ChatGPT conversation.
What Gets Preserved?
The script attempts to preserve and render:
-
User messages
-
ChatGPT responses
-
Message order
-
Timestamps when available
-
Headings
-
Bold and italic text
-
Lists
-
Markdown
-
Code blocks
-
Inline code
-
Tables
-
Block quotes
-
Links
-
Images
-
Uploaded attachments
-
Complete embedded HTML documents
Images associated with the conversation can be displayed directly in the extracted conversation.
Other files associated with the conversation are placed in the:
attachments
directory.
Complete HTML documents found in appropriate fenced HTML blocks can be placed in:
embedded_html
and displayed from the main conversation using an iframe.
Python Source Code
Create a file named:
chatgpt_extract_chat.py
Copy the complete Python source code below into that file and save it in the same directory as your ChatGPT export ZIP.
#!/usr/bin/env python3
"""Extract one ChatGPT conversation from an official ChatGPT data-export ZIP.
Usage:
python chatgpt_extract_chat.py INPUT_ZIP CHAT_NAME OUTPUT_FOLDER
Example:
python chatgpt_extract_chat.py "chatgpt_export_dump 20260902_2c11301fb280423bea464e9ed19f2e878a624chatgpt_export_180e799b4c615efa9fe3e28c2dd-2026-09-02-02-19-56-60092736848c4842aa0e4fb60b63476f.zip" "Arlington Venue Targets App (Part 1)" "Arlington Venue Targets App (Part 1)"
No third-party Python packages are required. Python 3.10+ is recommended.
"""
from __future__ import annotations
import argparse
import datetime as _dt
import html
import json
import mimetypes
import os
from pathlib import Path
import re
import shutil
import sys
import zipfile
from typing import Any, Iterable
VISIBLE_ROLES = {"user", "assistant"}
SKIP_CONTENT_TYPES = {"thoughts", "reasoning_recap"}
def sanitize_filename(name: str, fallback: str = "file") -> str:
name = (name or fallback).strip().replace("\x00", "")
name = re.sub(r'[<>:"/\\|?*]', "_", name)
name = re.sub(r"\s+", " ", name).strip(" .")
if not name:
name = fallback
# Keep Windows paths comfortably below legacy MAX_PATH when possible.
return name[:180]
def unique_path(folder: Path, filename: str) -> Path:
p = folder / filename
if not p.exists():
return p
stem, suffix = p.stem, p.suffix
i = 2
while True:
candidate = folder / f"{stem} ({i}){suffix}"
if not candidate.exists():
return candidate
i += 1
def format_timestamp(value: Any) -> str:
try:
if value is None:
return ""
return _dt.datetime.fromtimestamp(float(value), tz=_dt.timezone.utc).astimezone().strftime("%Y-%m-%d %I:%M:%S %p %Z")
except Exception:
return ""
def load_json_from_zip(zf: zipfile.ZipFile, member: str) -> Any:
with zf.open(member) as f:
return json.load(f)
def find_conversation(zf: zipfile.ZipFile, title: str) -> tuple[dict[str, Any], str]:
members = sorted(
n for n in zf.namelist()
if re.fullmatch(r"conversations(?:-\d+)?\.json", Path(n).name, flags=re.I)
)
if not members:
raise RuntimeError("No conversations.json or conversations-###.json files were found in the ZIP.")
exact: list[tuple[dict[str, Any], str]] = []
casefolded: list[tuple[dict[str, Any], str]] = []
for member in members:
data = load_json_from_zip(zf, member)
if not isinstance(data, list):
continue
for conv in data:
if not isinstance(conv, dict):
continue
conv_title = str(conv.get("title") or "")
if conv_title == title:
exact.append((conv, member))
elif conv_title.casefold() == title.casefold():
casefolded.append((conv, member))
matches = exact or casefolded
if not matches:
# Give useful nearby titles without reading anything outside the export.
needle = title.casefold()
nearby: list[str] = []
for member in members:
data = load_json_from_zip(zf, member)
for conv in data if isinstance(data, list) else []:
t = str(conv.get("title") or "")
if needle in t.casefold() or t.casefold() in needle:
nearby.append(t)
msg = f'Conversation not found: "{title}"'
if nearby:
msg += "\nPossible matches:\n - " + "\n - ".join(sorted(set(nearby))[:20])
raise RuntimeError(msg)
if len(matches) > 1:
# Prefer the most recently updated duplicate title.
matches.sort(key=lambda x: float(x[0].get("update_time") or 0), reverse=True)
print(f"Warning: found {len(matches)} conversations with that title; using the most recently updated one.", file=sys.stderr)
return matches[0]
def current_branch(conv: dict[str, Any]) -> list[dict[str, Any]]:
mapping = conv.get("mapping") or {}
if not isinstance(mapping, dict) or not mapping:
return []
current = conv.get("current_node")
if current not in mapping:
# Fallback: use all nodes sorted by create time. This is less precise, but avoids failure on format changes.
vals = list(mapping.values())
vals.sort(key=lambda n: float(((n or {}).get("message") or {}).get("create_time") or 0))
return vals
out: list[dict[str, Any]] = []
seen: set[str] = set()
node_id = current
while node_id and node_id in mapping and node_id not in seen:
seen.add(node_id)
node = mapping[node_id]
out.append(node)
node_id = node.get("parent")
out.reverse()
return out
def inline_markup(text: str) -> str:
"""Small, safe Markdown-ish inline renderer using only stdlib."""
escaped = html.escape(text, quote=False)
# Protect inline-code spans before other formatting.
code_spans: list[str] = []
def code_repl(m: re.Match[str]) -> str:
code_spans.append(f"<code>{html.escape(m.group(1), quote=False)}</code>")
return f"\x00CODE{len(code_spans)-1}\x00"
escaped = re.sub(r"`([^`\n]+)`", code_repl, escaped)
# Markdown links and bare URLs.
escaped = re.sub(
r"\[([^\]]+)\]\((https?://[^)\s]+)\)",
lambda m: f'<a href="{html.escape(m.group(2), quote=True)}" target="_blank" rel="noopener">{m.group(1)}</a>',
escaped,
)
escaped = re.sub(
r"(?<![\"'=])(https?://[^\s<]+)",
lambda m: f'<a href="{html.escape(m.group(1).rstrip(".,;:)"), quote=True)}" target="_blank" rel="noopener">{m.group(1).rstrip(".,;:)")}</a>{m.group(1)[len(m.group(1).rstrip(".,;:)")):]}',
escaped,
)
# Bold then italics. Deliberately conservative to avoid mangling code-like text.
escaped = re.sub(r"\*\*([^*\n]+)\*\*", r"<strong>\1</strong>", escaped)
escaped = re.sub(r"__([^_\n]+)__", r"<strong>\1</strong>", escaped)
escaped = re.sub(r"(?<!\*)\*([^*\n]+)\*(?!\*)", r"<em>\1</em>", escaped)
for i, rendered in enumerate(code_spans):
escaped = escaped.replace(f"\x00CODE{i}\x00", rendered)
return escaped
def is_table_separator(line: str) -> bool:
cells = [c.strip() for c in line.strip().strip("|").split("|")]
return bool(cells) and all(re.fullmatch(r":?-{3,}:?", c) for c in cells)
def split_table_row(line: str) -> list[str]:
return [c.strip() for c in line.strip().strip("|").split("|")]
def render_markdown(text: str) -> str:
"""Render the common Markdown used in ChatGPT conversations without external packages."""
text = text.replace("\r\n", "\n").replace("\r", "\n")
lines = text.split("\n")
out: list[str] = []
i = 0
in_ul = False
in_ol = False
def close_lists() -> None:
nonlocal in_ul, in_ol
if in_ul:
out.append("</ul>")
in_ul = False
if in_ol:
out.append("</ol>")
in_ol = False
while i < len(lines):
line = lines[i]
# Fenced code block.
m = re.match(r"^\s*```([^`]*)$", line)
if m:
close_lists()
lang = m.group(1).strip()
code: list[str] = []
i += 1
while i < len(lines) and not re.match(r"^\s*```\s*$", lines[i]):
code.append(lines[i])
i += 1
klass = f' class="language-{html.escape(lang, quote=True)}"' if lang else ""
out.append(f"<pre><code{klass}>{html.escape(chr(10).join(code), quote=False)}</code></pre>")
i += 1
continue
if not line.strip():
close_lists()
out.append("")
i += 1
continue
# Markdown table: header row followed by separator row.
if "|" in line and i + 1 < len(lines) and is_table_separator(lines[i + 1]):
close_lists()
headers = split_table_row(line)
out.append("<div class=\"table-wrap\"><table><thead><tr>" + "".join(f"<th>{inline_markup(c)}</th>" for c in headers) + "</tr></thead><tbody>")
i += 2
while i < len(lines) and "|" in lines[i] and lines[i].strip():
cells = split_table_row(lines[i])
out.append("<tr>" + "".join(f"<td>{inline_markup(c)}</td>" for c in cells) + "</tr>")
i += 1
out.append("</tbody></table></div>")
continue
hm = re.match(r"^(#{1,6})\s+(.+)$", line)
if hm:
close_lists()
level = len(hm.group(1))
out.append(f"<h{level}>{inline_markup(hm.group(2))}</h{level}>")
i += 1
continue
if re.match(r"^\s*(?:---+|___+|\*\*\*+)\s*$", line):
close_lists()
out.append("<hr>")
i += 1
continue
if line.lstrip().startswith(">"):
close_lists()
quote_lines: list[str] = []
while i < len(lines) and lines[i].lstrip().startswith(">"):
quote_lines.append(re.sub(r"^\s*>\s?", "", lines[i]))
i += 1
out.append("<blockquote>" + "<br>".join(inline_markup(q) for q in quote_lines) + "</blockquote>")
continue
um = re.match(r"^\s*[-*+]\s+(.+)$", line)
if um:
if in_ol:
out.append("</ol>")
in_ol = False
if not in_ul:
out.append("<ul>")
in_ul = True
out.append(f"<li>{inline_markup(um.group(1))}</li>")
i += 1
continue
om = re.match(r"^\s*\d+[.)]\s+(.+)$", line)
if om:
if in_ul:
out.append("</ul>")
in_ul = False
if not in_ol:
out.append("<ol>")
in_ol = True
out.append(f"<li>{inline_markup(om.group(1))}</li>")
i += 1
continue
close_lists()
# Paragraph: collect plain consecutive lines and preserve manual line breaks.
para = [line]
i += 1
while i < len(lines):
nxt = lines[i]
if (not nxt.strip() or re.match(r"^\s*```", nxt) or re.match(r"^(#{1,6})\s+", nxt)
or nxt.lstrip().startswith(">") or re.match(r"^\s*[-*+]\s+", nxt)
or re.match(r"^\s*\d+[.)]\s+", nxt)
or ("|" in nxt and i + 1 < len(lines) and is_table_separator(lines[i + 1]))):
break
para.append(nxt)
i += 1
out.append("<p>" + "<br>".join(inline_markup(p) for p in para) + "</p>")
close_lists()
return "\n".join(out)
def extract_html_documents(text: str, embedded_dir: Path, serial_start: int = 1) -> tuple[str, list[tuple[str, str]]]:
"""Extract fenced full HTML documents into separate files and return iframe descriptors."""
docs: list[tuple[str, str]] = []
pattern = re.compile(r"```(?:html)?\s*\n(\s*<!DOCTYPE\s+html\b.*?</html>\s*)\n```", re.I | re.S)
serial = serial_start
def repl(m: re.Match[str]) -> str:
nonlocal serial
source = m.group(1)
filename = f"embedded_{serial:03d}.html"
(embedded_dir / filename).write_text(source, encoding="utf-8")
token = f"\x00EMBEDDEDHTML{len(docs)}\x00"
docs.append((token, filename))
serial += 1
return token
return pattern.sub(repl, text), docs
def render_message_text(text: str, embedded_dir: Path, embedded_counter: list[int]) -> str:
transformed, docs = extract_html_documents(text, embedded_dir, embedded_counter[0])
embedded_counter[0] += len(docs)
rendered = render_markdown(transformed)
for token, filename in docs:
block = (
'<div class="embedded-doc">'
'<div class="embedded-label">Embedded HTML document</div>'
f'<iframe sandbox="allow-scripts allow-forms allow-modals allow-popups" loading="lazy" src="embedded_html/{html.escape(filename, quote=True)}"></iframe>'
f'<div class="embedded-link"><a href="embedded_html/{html.escape(filename, quote=True)}" target="_blank">Open embedded HTML in a new tab</a></div>'
'</div>'
)
# Token will be escaped inside a paragraph; replace both likely forms.
rendered = rendered.replace(token, block).replace(f"<p>{token}</p>", block)
return rendered
def extract_attachment(zf: zipfile.ZipFile, attachment: dict[str, Any], attachments_dir: Path) -> tuple[str | None, str | None]:
file_id = str(attachment.get("id") or "")
original_name = sanitize_filename(str(attachment.get("name") or file_id or "attachment"))
if not file_id:
return None, None
candidates = [
f"{file_id}.dat",
file_id,
]
member = next((c for c in candidates if c in zf.namelist()), None)
if member is None:
# Some export formats put assets in subfolders.
base_candidates = {Path(c).name for c in candidates}
member = next((n for n in zf.namelist() if Path(n).name in base_candidates), None)
if member is None:
return None, None
# Supply an extension if metadata has only an id-like name.
if "." not in Path(original_name).name:
ext = mimetypes.guess_extension(str(attachment.get("mime_type") or "")) or ""
original_name += ext
dest = unique_path(attachments_dir, original_name)
with zf.open(member) as src, open(dest, "wb") as dst:
shutil.copyfileobj(src, dst)
return dest.name, str(attachment.get("mime_type") or "")
def message_parts(message: dict[str, Any]) -> tuple[list[str], list[dict[str, Any]]]:
content = message.get("content") or {}
parts = content.get("parts") or []
texts: list[str] = []
asset_parts: list[dict[str, Any]] = []
if isinstance(parts, list):
for part in parts:
if isinstance(part, str):
texts.append(part)
elif isinstance(part, dict):
if part.get("asset_pointer"):
asset_parts.append(part)
# Some export records carry text in dict form.
if isinstance(part.get("text"), str):
texts.append(part["text"])
elif isinstance(parts, str):
texts.append(parts)
return texts, asset_parts
def html_template(title: str, body: str, stats: str) -> str:
safe_title = html.escape(title)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{safe_title}</title>
<style>
:root {{
color-scheme: light dark;
--bg:#ffffff; --panel:#f7f7f8; --text:#202123; --muted:#6b6c70;
--border:#dedfe2; --user:#f4f4f4; --assistant:#ffffff; --code:#f6f7f8;
--link:#0b57d0; --accent:#10a37f;
}}
@media (prefers-color-scheme: dark) {{
:root {{ --bg:#212121; --panel:#2f2f2f; --text:#ececec; --muted:#b4b4b4; --border:#454545; --user:#2f2f2f; --assistant:#212121; --code:#171717; --link:#7ab7ff; }}
}}
* {{ box-sizing:border-box; }}
html {{ scroll-behavior:smooth; }}
body {{ margin:0; background:var(--bg); color:var(--text); font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; line-height:1.55; }}
header {{ position:sticky; top:0; z-index:10; background:color-mix(in srgb,var(--bg) 92%,transparent); backdrop-filter:blur(10px); border-bottom:1px solid var(--border); }}
.header-inner {{ max-width:980px; margin:auto; padding:16px 22px; }}
h1 {{ margin:0; font-size:20px; }}
.stats {{ margin-top:4px; color:var(--muted); font-size:12px; }}
main {{ max-width:980px; margin:0 auto; padding:20px 22px 80px; }}
.message {{ display:grid; grid-template-columns:44px minmax(0,1fr); gap:14px; padding:24px 0; border-bottom:1px solid var(--border); }}
.avatar {{ width:34px; height:34px; border-radius:8px; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:13px; border:1px solid var(--border); }}
.user .avatar {{ background:var(--user); }}
.assistant .avatar {{ background:var(--accent); color:white; border-color:transparent; }}
.role-line {{ display:flex; gap:10px; align-items:baseline; margin-bottom:7px; }}
.role {{ font-weight:700; }}
.time {{ color:var(--muted); font-size:11px; }}
.content {{ overflow-wrap:anywhere; }}
.content p {{ margin:.55em 0; }}
.content h1,.content h2,.content h3,.content h4 {{ margin:1.1em 0 .45em; }}
.content h1 {{ font-size:1.55em; }} .content h2 {{ font-size:1.35em; }} .content h3 {{ font-size:1.15em; }}
pre {{ overflow:auto; background:var(--code); border:1px solid var(--border); border-radius:10px; padding:14px 16px; white-space:pre; tab-size:4; }}
code {{ font-family:"Cascadia Code","Consolas","Courier New",monospace; font-size:.92em; }}
p code, li code, td code {{ background:var(--code); padding:.12em .32em; border-radius:5px; border:1px solid var(--border); }}
a {{ color:var(--link); }}
blockquote {{ border-left:4px solid var(--border); margin:12px 0; padding:3px 0 3px 14px; color:var(--muted); }}
ul,ol {{ padding-left:1.6em; }}
.table-wrap {{ overflow:auto; margin:14px 0; }}
table {{ border-collapse:collapse; min-width:60%; }}
th,td {{ border:1px solid var(--border); padding:7px 10px; vertical-align:top; text-align:left; }}
th {{ background:var(--panel); }}
.attachments {{ margin-top:14px; display:flex; flex-wrap:wrap; gap:8px; }}
.attachment {{ display:inline-block; border:1px solid var(--border); background:var(--panel); border-radius:9px; padding:8px 11px; text-decoration:none; color:var(--text); font-size:13px; }}
.image-attachment {{ margin-top:14px; }}
.image-attachment img {{ max-width:100%; height:auto; border-radius:9px; border:1px solid var(--border); display:block; }}
.missing {{ color:#a33; font-size:12px; border:1px dashed #a66; padding:7px 10px; border-radius:7px; display:inline-block; }}
.embedded-doc {{ margin:16px 0; border:1px solid var(--border); border-radius:10px; overflow:hidden; }}
.embedded-label,.embedded-link {{ padding:8px 10px; background:var(--panel); font-size:12px; color:var(--muted); }}
.embedded-doc iframe {{ width:100%; height:600px; border:0; background:white; display:block; }}
footer {{ max-width:980px; margin:auto; padding:20px 22px 50px; color:var(--muted); font-size:12px; }}
@media (max-width:640px) {{ .message {{ grid-template-columns:34px minmax(0,1fr); gap:10px; }} .avatar {{ width:30px; height:30px; }} main,.header-inner {{ padding-left:12px; padding-right:12px; }} }}
@media print {{ header {{ position:static; }} .message {{ break-inside:avoid-page; }} body {{ color:#000; background:#fff; }} }}
</style>
</head>
<body>
<header><div class="header-inner"><h1>{safe_title}</h1><div class="stats">{html.escape(stats)}</div></div></header>
<main>
{body}
</main>
<footer>Extracted locally from an official ChatGPT data export. This viewer is self-contained and does not contact ChatGPT.</footer>
</body>
</html>
"""
def extract_chat(input_zip: Path, chat_name: str, output_folder: Path) -> None:
if not input_zip.is_file():
raise RuntimeError(f"Input ZIP does not exist: {input_zip}")
if not zipfile.is_zipfile(input_zip):
raise RuntimeError(f"Input file is not a valid ZIP archive: {input_zip}")
output_folder.mkdir(parents=True, exist_ok=True)
attachments_dir = output_folder / "attachments"
embedded_dir = output_folder / "embedded_html"
attachments_dir.mkdir(exist_ok=True)
embedded_dir.mkdir(exist_ok=True)
with zipfile.ZipFile(input_zip, "r") as zf:
conv, source_json = find_conversation(zf, chat_name)
branch = current_branch(conv)
rendered_messages: list[str] = []
visible_count = 0
attachment_cache: dict[str, tuple[str | None, str | None]] = {}
embedded_counter = [1]
for node in branch:
message = node.get("message") if isinstance(node, dict) else None
if not isinstance(message, dict):
continue
role = str(((message.get("author") or {}).get("role") or "")).lower()
if role not in VISIBLE_ROLES:
continue
content_type = str(((message.get("content") or {}).get("content_type") or ""))
if content_type in SKIP_CONTENT_TYPES:
continue
texts, asset_parts = message_parts(message)
metadata = message.get("metadata") or {}
attachments = metadata.get("attachments") or []
if not isinstance(attachments, list):
attachments = []
body_bits: list[str] = []
if texts:
body_bits.append(render_message_text("\n\n".join(texts), embedded_dir, embedded_counter))
# Extract every attachment once, using the human-readable filename from message metadata.
attachment_html: list[str] = []
image_html: list[str] = []
extracted_ids: set[str] = set()
for a in attachments:
if not isinstance(a, dict):
continue
file_id = str(a.get("id") or "")
if not file_id:
continue
extracted_ids.add(file_id)
if file_id not in attachment_cache:
attachment_cache[file_id] = extract_attachment(zf, a, attachments_dir)
filename, mime = attachment_cache[file_id]
label = html.escape(str(a.get("name") or filename or file_id))
if filename:
href = "attachments/" + html.escape(filename, quote=True)
if str(mime).startswith("image/"):
image_html.append(f'<div class="image-attachment"><a href="{href}" target="_blank"><img src="{href}" alt="{label}" loading="lazy"></a></div>')
else:
attachment_html.append(f'<a class="attachment" href="{href}" target="_blank">📎 {label}</a>')
else:
attachment_html.append(f'<span class="missing">Attachment not found in export: {label}</span>')
# Image asset pointers occasionally exist without a parallel metadata attachment.
for part in asset_parts:
pointer = str(part.get("asset_pointer") or "")
file_id = pointer.split("sediment://", 1)[-1] if "sediment://" in pointer else ""
if not file_id or file_id in extracted_ids:
continue
synthetic = {
"id": file_id,
"name": file_id + ".png",
"mime_type": "image/png",
}
if file_id not in attachment_cache:
attachment_cache[file_id] = extract_attachment(zf, synthetic, attachments_dir)
filename, _ = attachment_cache[file_id]
if filename:
href = "attachments/" + html.escape(filename, quote=True)
image_html.append(f'<div class="image-attachment"><a href="{href}" target="_blank"><img src="{href}" alt="image" loading="lazy"></a></div>')
if image_html:
body_bits.extend(image_html)
if attachment_html:
body_bits.append('<div class="attachments">' + "".join(attachment_html) + "</div>")
# Keep attachment-only messages too.
if not body_bits:
continue
visible_count += 1
role_label = "You" if role == "user" else "ChatGPT"
avatar = "YOU" if role == "user" else "GPT"
timestamp = format_timestamp(message.get("create_time"))
rendered_messages.append(
f'<article class="message {role}">'
f'<div class="avatar">{avatar}</div>'
f'<div><div class="role-line"><span class="role">{role_label}</span>'
+ (f'<span class="time">{html.escape(timestamp)}</span>' if timestamp else "")
+ f'</div><div class="content">{"".join(body_bits)}</div></div></article>'
)
stats = f"{visible_count} visible user/assistant messages • source: {source_json}"
index = html_template(str(conv.get("title") or chat_name), "\n".join(rendered_messages), stats)
(output_folder / "index.html").write_text(index, encoding="utf-8")
readme = f"""ChatGPT Conversation Extract
============================
Conversation:
{conv.get('title') or chat_name}
Source export:
{input_zip}
Source conversation JSON:
{source_json}
Visible messages exported:
{visible_count}
Open index.html in Chrome, Edge, Firefox, or another modern browser.
Folders:
attachments Files/images attached to this conversation
embedded_html Full HTML documents extracted from fenced HTML blocks
This extraction was created entirely with Python's standard library.
No pip packages, Node.js, browser extension, or external viewer is required.
"""
(output_folder / "README.txt").write_text(readme, encoding="utf-8")
print("Done.")
print(f"Conversation : {chat_name}")
print(f"Output : {output_folder}")
print(f"Open : {output_folder / 'index.html'}")
def build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description="Extract one named conversation from an official ChatGPT export ZIP into a self-contained HTML folder."
)
p.add_argument("input_zip", help="Path to the ChatGPT export ZIP")
p.add_argument("chat_name", help="Exact ChatGPT conversation title")
p.add_argument("output_folder", help="Folder to create/use for the extracted HTML and attachments")
return p
def main() -> int:
args = build_arg_parser().parse_args()
try:
extract_chat(Path(args.input_zip).expanduser(), args.chat_name, Path(args.output_folder).expanduser())
return 0
except KeyboardInterrupt:
print("Cancelled.", file=sys.stderr)
return 130
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
Then run:
python chatgpt_extract_chat.py INPUT_ZIP CHAT_NAME OUTPUT_FOLDER
Real example:
python chatgpt_extract_chat.py "chatgpt_export_dump 20260902_2c11301fb280423bea464e9ed19f2e878a624chatgpt_export_180e799b4c615efa9fe3e28c2dd-2026-09-02-02-19-56-60092736848c4842aa0e4fb60b63476f.zip" "Arlington Venue Targets App (Part 1)" "Arlington Venue Targets App (Part 1)"
Why I Made This
I have some very long ChatGPT conversations, particularly software-development projects containing source code, screenshots, uploaded documents, and a substantial amount of project history.
I wanted a way to preserve an individual conversation as a readable local archive instead of searching through the entire exported chat.html file or manually interpreting the underlying JSON files.
This script gives me a separate HTML archive for an individual ChatGPT conversation that I can keep on my computer and open whenever I need it.
I hope somebody else finds it useful.



