| """Parse ChatGPT and Claude exports (.json or .zip) into a normalized `ParsedExport`. |
| |
| Mirrors the real export shapes: |
| - ChatGPT: list of conversations, each with a `mapping` node-tree (author.role, content.parts, |
| create_time as unix epoch). We walk parent->first-child from the root to recover order. |
| - Claude: list of conversations, each with `chat_messages` (sender: human|assistant, text, |
| created_at as ISO-8601), conversation `created_at`. |
| |
| A .zip is the raw download from either provider; we locate the first `conversations.json` |
| (or any top-level .json that looks like an export). |
| """ |
| from __future__ import annotations |
|
|
| import datetime as _dt |
| import io |
| import json |
| import os |
| import re |
| import zipfile |
| from dataclasses import dataclass, field |
|
|
| from .language import is_english |
|
|
| _MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] |
|
|
|
|
| class ParseError(Exception): |
| """Raised when the input is not a recognizable ChatGPT/Claude export.""" |
|
|
|
|
| @dataclass |
| class Turn: |
| role: str |
| text: str |
| epoch: float | None = None |
|
|
|
|
| @dataclass |
| class Conversation: |
| turns: list[Turn] = field(default_factory=list) |
|
|
|
|
| @dataclass |
| class ParsedExport: |
| source: str |
| conversations: list[Conversation] = field(default_factory=list) |
|
|
| |
| @property |
| def conversation_count(self) -> int: |
| return len(self.conversations) |
|
|
| @property |
| def all_turns(self) -> list[Turn]: |
| return [t for c in self.conversations for t in c.turns] |
|
|
| @property |
| def turn_count(self) -> int: |
| return len(self.all_turns) |
|
|
| @property |
| def user_turns(self) -> list[Turn]: |
| return [t for t in self.all_turns if t.role == "user"] |
|
|
| @property |
| def english_turn_count(self) -> int: |
| return sum(1 for t in self.all_turns if is_english(t.text)) |
|
|
| @property |
| def other_turn_count(self) -> int: |
| return self.turn_count - self.english_turn_count |
|
|
| def date_range(self) -> str | None: |
| epochs = [t.epoch for t in self.all_turns if t.epoch] |
| if not epochs: |
| return None |
| lo, hi = min(epochs), max(epochs) |
| return f"{_fmt_month(lo)} – {_fmt_month(hi)}" |
|
|
| def busiest_slot(self) -> str | None: |
| """Best-effort 'most active on <weekday> <period>' from timestamps, else None.""" |
| epochs = [t.epoch for t in self.all_turns if t.epoch] |
| if len(epochs) < 3: |
| return None |
| from collections import Counter |
| days, periods = Counter(), Counter() |
| for e in epochs: |
| d = _dt.datetime.fromtimestamp(e, _dt.timezone.utc) |
| days[d.strftime("%A")] += 1 |
| periods[_period(d.hour)] += 1 |
| return f"Most active on {days.most_common(1)[0][0]} {periods.most_common(1)[0][0]}" |
|
|
|
|
| def _period(hour: int) -> str: |
| if 5 <= hour < 12: |
| return "mornings" |
| if 12 <= hour < 17: |
| return "afternoons" |
| if 17 <= hour < 22: |
| return "evenings" |
| return "nights" |
|
|
|
|
| def _fmt_month(epoch: float) -> str: |
| d = _dt.datetime.fromtimestamp(epoch, _dt.timezone.utc) |
| return f"{_MONTHS[d.month - 1]} {d.year}" |
|
|
|
|
| def _to_epoch(value) -> float | None: |
| """Accept unix epoch (int/float/str, seconds OR milliseconds) or ISO-8601 string. Returns a |
| plausible unix-seconds float, or None for missing/garbage/out-of-range values.""" |
| if value is None: |
| return None |
| raw = None |
| if isinstance(value, (int, float)): |
| raw = float(value) |
| else: |
| s = str(value).strip() |
| if not s: |
| return None |
| try: |
| raw = float(s) |
| except ValueError: |
| try: |
| return _sane(_dt.datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()) |
| except ValueError: |
| return None |
| if raw is not None and raw > 1e11: |
| raw /= 1000.0 |
| return _sane(raw) |
|
|
|
|
| def _sane(epoch: float | None) -> float | None: |
| """Keep only plausible timestamps (2001-01-01 .. 2035-12-31); reject garbage so datetime can't throw.""" |
| if epoch is None or not (1_000_000_000 <= epoch <= 2_080_000_000): |
| return None |
| return epoch |
|
|
|
|
| def _norm_role(raw: str) -> str | None: |
| r = (raw or "").lower() |
| if r in ("user", "human"): |
| return "user" |
| if r in ("assistant", "model", "ai", "bot"): |
| return "assistant" |
| return None |
|
|
|
|
| |
| |
| |
|
|
| def _parse_chatgpt(data: list) -> list[Conversation]: |
| convs: list[Conversation] = [] |
| for conv in data: |
| if not isinstance(conv, dict): |
| continue |
| mapping = conv.get("mapping") |
| if not isinstance(mapping, dict): |
| continue |
| convs.append(Conversation(turns=_walk_chatgpt(mapping, conv.get("current_node")))) |
| return convs |
|
|
|
|
| def _node_to_turn(node: dict) -> Turn | None: |
| msg = node.get("message") or {} |
| role = _norm_role((msg.get("author") or {}).get("role", "")) |
| text = _text_from_parts(msg.get("content") or {}) |
| return Turn(role=role, text=text, epoch=_to_epoch(msg.get("create_time"))) if role and text else None |
|
|
|
|
| def _walk_chatgpt(mapping: dict, current_node: str | None = None) -> list[Turn]: |
| """Order a ChatGPT conversation's mapping nodes. Old exports carry `children` (walk root→child[0]); |
| new exports dropped `children` and keep only `parent` (walk the active leaf `current_node` up to root). |
| Falls back to create_time order if neither path is available.""" |
| order: list[str] = [] |
| if any(n.get("children") for n in mapping.values()): |
| root = next((nid for nid, n in mapping.items() if not n.get("parent")), None) or next(iter(mapping), None) |
| cur, seen = root, set() |
| while cur and cur in mapping and cur not in seen: |
| seen.add(cur); order.append(cur) |
| kids = mapping[cur].get("children") or [] |
| cur = kids[0] if kids else None |
| elif current_node and current_node in mapping: |
| cur, seen = current_node, set() |
| while cur and cur in mapping and cur not in seen: |
| seen.add(cur); order.append(cur) |
| cur = mapping[cur].get("parent") |
| order.reverse() |
| if not order: |
| order = sorted((nid for nid, n in mapping.items() if n.get("message")), |
| key=lambda nid: (mapping[nid]["message"].get("create_time") or 0)) |
| turns = [] |
| for nid in order: |
| t = _node_to_turn(mapping[nid]) |
| if t: |
| turns.append(t) |
| return turns |
|
|
|
|
| def _text_from_parts(content: dict) -> str: |
| if not isinstance(content, dict): |
| return "" |
| parts = content.get("parts") or [] |
| return "\n".join(p for p in parts if isinstance(p, str)).strip() |
|
|
|
|
| def _parse_claude(data: list) -> list[Conversation]: |
| convs: list[Conversation] = [] |
| for conv in data: |
| if not isinstance(conv, dict): |
| continue |
| msgs = conv.get("chat_messages") |
| if not isinstance(msgs, list): |
| continue |
| turns = [] |
| for m in msgs: |
| if not isinstance(m, dict): |
| continue |
| role = _norm_role(m.get("sender", "")) |
| text = (m.get("text") or "").strip() |
| if role and text: |
| turns.append(Turn(role=role, text=text, epoch=_to_epoch(m.get("created_at")))) |
| convs.append(Conversation(turns=turns)) |
| return convs |
|
|
|
|
| def _detect(data) -> str: |
| sample = data[0] if isinstance(data, list) and data else data |
| if isinstance(sample, dict): |
| if "mapping" in sample: |
| return "chatgpt" |
| if "chat_messages" in sample: |
| return "claude" |
| raise ParseError("Unrecognized export — expected a ChatGPT or Claude conversations JSON.") |
|
|
|
|
| |
| |
| |
|
|
| def parse_export(path: str) -> ParsedExport: |
| """Parse a ChatGPT/Claude export file (.json or .zip). Raises ParseError if unrecognized.""" |
| raw = _load_json(path) |
| if not isinstance(raw, list): |
| raise ParseError("Export must be a JSON list of conversations.") |
| source = _detect(raw) |
| convs = _parse_chatgpt(raw) if source == "chatgpt" else _parse_claude(raw) |
| if not any(c.turns for c in convs): |
| raise ParseError("No conversation turns found in this export.") |
| return ParsedExport(source=source, conversations=convs) |
|
|
|
|
| def _load_json(path: str): |
| if path is None or not os.path.exists(path): |
| raise ParseError("No file provided.") |
| if zipfile.is_zipfile(path): |
| with zipfile.ZipFile(path) as zf: |
| members = _conversation_members(zf.namelist()) |
| if not members: |
| raise ParseError("Zip has no conversations.json (or conversations-NNN.json shards).") |
| merged = [] |
| for name in members: |
| with zf.open(name) as fh: |
| part = json.load(io.TextIOWrapper(fh, encoding="utf-8")) |
| merged.extend(part if isinstance(part, list) else [part]) |
| return merged |
| try: |
| with open(path, "r", encoding="utf-8") as fh: |
| return json.load(fh) |
| except (json.JSONDecodeError, UnicodeDecodeError) as e: |
| raise ParseError(f"File is not valid JSON: {e}") from e |
|
|
|
|
| |
| _META_JSON = {"user.json", "user_settings.json", "export_manifest.json", "shared_conversations.json", |
| "conversation_asset_file_names.json", "library_files.json", "users.json", "memories.json"} |
|
|
|
|
| def _conversation_members(names: list[str]) -> list[str]: |
| """The conversation JSON member(s) in an export zip: a single `conversations.json`, OR the |
| `conversations-NNN.json` shards (new ChatGPT format), OR a best-effort non-metadata fallback.""" |
| base = lambda n: n.rsplit("/", 1)[-1] |
| exact = [n for n in names if base(n) == "conversations.json"] |
| if exact: |
| return exact[:1] |
| shards = sorted(n for n in names if re.match(r"conversations-\d+\.json$", base(n))) |
| if shards: |
| return shards |
| other = [n for n in names if n.endswith(".json") and not n.startswith("__MACOSX") |
| and "/projects/" not in n and base(n) not in _META_JSON] |
| return other[:1] |
|
|