How to Download All of Your ChatGPT Chats and Extract an Individual Chat by Name as an HTML Document

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.

Screen Shots of Final HTML Output:

Very nice. Thank you so much for sharing this.

Please let me know if you actually take this for a spin and try it. It will take up to a week to get the download link if you request it from ChatGPT. It works great for me and it would be nice to confirm it works for someone else. I testes the on Windows 11 with Python 3.10 but have not tested it on my Mac or Ubuntu computers.
Thank you for your nice reply.

I will. I’ve got an abstract chat in progress, but when I complete and rest, I will download and modify for my computer (an Ubuntu Linux PC). Because you are using Python, it should be relatively easy. I will, of course, as ChatGPT for help, as I always do now. I have saved a bookmark to this topic. I will post a response after I get results. Again, thank you so much for working out this solution.

Thanks, when I originally posted this I ran out of space and had to deleted or reduce some of the sections.
One of the sections was very important and that is how to continue and existing chat by creating a new chat.

Here is that missing section, excuse the formatting:

HOW TO CONTINUE THE CHAT THAT HAS REACHED THE LIMIT IN A NEW CHAT

The extracted conversation can also be used as a recovery archive when a ChatGPT conversation reaches its maximum length.

1. ZIP the Extracted Conversation

In Windows File Explorer, locate the conversation’s output folder, for example:


Arlington Venue Targets App (Part 1)

Right-click the folder and select:

Compress to… → ZIP File

This creates:


Arlington Venue Targets App (Part 1).zip

ZIP the entire folder, not just index.html. This preserves the conversation, images, attachments, and embedded HTML together.

2. Start the New Chat

Create a new ChatGPT conversation, for example:


Arlington Venue Targets App (Part 2)

Upload:


Arlington Venue Targets App (Part 1).zip

Then give ChatGPT these exact instructions:


This ZIP is the recovery archive for my previous conversation, "Arlington Venue Targets App (Part 1)".

Inspect the complete contents of the ZIP recursively.

Treat index.html as the chronological record of the previous conversation. Also review the files under attachments and embedded_html when relevant.

Recover the important requirements, decisions, completed work, test results, unresolved issues, and where we left off.

Do not propose new work yet. First summarize what you recovered and synchronize yourself with Part 1.

3. Verify the Recovery Before Continuing

After ChatGPT gives you its summary, ask:


Based only on the recovery archive, tell me exactly where we left off at the end of "Arlington Venue Targets App (Part 1)".

Tell me the last completed step, the most recent test/build result, what we intended to do next, and any unresolved decisions.

Do not move the project forward yet. I am testing whether you recovered the exact handoff point.

Also tell me which parts of index.html and which attachments you relied on.

Review the answer and correct anything important that was missed before continuing the project.

Important

Uploading the ZIP does not literally restore the old conversation’s internal state. It gives the new chat a detailed record from which it can reconstruct the previous conversation’s context.

Keeping index.html, attachments, and embedded_html together gives the new chat much more recovery information than uploading index.html alone.

ESTABLISH AN AGREEMENT WITH CHATGPT THAT YOU CAN ASK IT TO LOOK IN THE UPLOADED ZIP IF IT CAN’T REMEMBER SOMETHING

After the new continuation chat has reviewed the recovery ZIP, I recommend establishing one additional rule. This lets ChatGPT know that the uploaded archive remains the reference for information from the previous chat.

Give ChatGPT this instruction:


For the remainder of this continuation chat, treat the uploaded recovery ZIP as the reference archive for Part 1.

If I ask a question and you do not remember an important detail from Part 1, or you are uncertain about a previous decision, requirement, test result, file, or where we left off, check the recovery ZIP before answering rather than guessing.

You do not need to reread the entire ZIP for every question. Use it when the answer depends on information from Part 1 that you cannot reliably recall from the current conversation.

If you are still unable to determine the answer after checking the recovery ZIP, tell me rather than inventing an answer.

After ChatGPT agrees, you can continue the new conversation normally.

If at any point you think ChatGPT may have forgotten something from Part 1, you can simply say:


Please check the Part 1 recovery ZIP before answering this question.

This allows the ZIP to serve as a reference archive throughout the continuation chat without requiring ChatGPT to reprocess the entire archive for every question.

Thank you for that addendum. I see a lot of people complaining about their chats being cut off when they reach some limit.

Good news, I just tested it on my Ubuntu 24.04 LTS computer with Python 3.12.3
It works perfectly with no modifications needed. Python 3.10 or higher should work.

Thank you! I recently upgraded (clean install) from 22.04.5 to 26.04.1. I’m now running Python 3.14.4, so your script should work fine. BTW, Canonical is supposed to have the direct upgrade from 24.04 to 26.04 ready soon. I believe there are a lot of Ubuntu users who have been waiting for that.