ai-chat-export 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +11 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/bin/ai-chat-export.mjs +196 -0
- package/package.json +45 -0
- package/skills/ai-chat-export/SKILL.md +59 -0
- package/src/browser.mjs +64 -0
- package/src/formatter.mjs +45 -0
- package/src/providers/gemini.mjs +254 -0
- package/src/providers/index.mjs +33 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-chat-export",
|
|
3
|
+
"description": "Export AI chat conversations (Gemini, ChatGPT, Copilot, DeepSeek) to Markdown and JSON using AppleScript + Chrome",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Kapil Tundwal"
|
|
7
|
+
},
|
|
8
|
+
"homepage": "https://github.com/ktundwal/ai-chat-export",
|
|
9
|
+
"repository": "https://github.com/ktundwal/ai-chat-export",
|
|
10
|
+
"license": "MIT"
|
|
11
|
+
}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Kapil Tundwal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# ai-chat-export
|
|
2
|
+
|
|
3
|
+
Export your AI chat conversations to Markdown and JSON files — for backup, analysis, or feeding to other AI tools.
|
|
4
|
+
|
|
5
|
+
**Supported providers:**
|
|
6
|
+
|
|
7
|
+
| Provider | Status |
|
|
8
|
+
|----------|--------|
|
|
9
|
+
| Google Gemini | Available |
|
|
10
|
+
| ChatGPT | Coming soon |
|
|
11
|
+
| Microsoft Copilot | Coming soon |
|
|
12
|
+
| DeepSeek | Coming soon |
|
|
13
|
+
|
|
14
|
+
Most AI chat platforms don't offer full conversation export. Google Takeout doesn't support Gemini chats at all. This tool fills that gap.
|
|
15
|
+
|
|
16
|
+
## Why AppleScript?
|
|
17
|
+
|
|
18
|
+
We tried every reasonable approach before landing on AppleScript:
|
|
19
|
+
|
|
20
|
+
| Approach | Result |
|
|
21
|
+
|---|---|
|
|
22
|
+
| Playwright `launchPersistentContext` | Chrome blocks `--remote-debugging-pipe` on its default user data directory |
|
|
23
|
+
| Copy Chrome profile to temp dir | Cookies are encrypted via macOS Keychain — copied profile loses authentication |
|
|
24
|
+
| `--remote-debugging-port` on default profile | Same restriction — Chrome refuses remote debugging on its standard data directory |
|
|
25
|
+
| Symlink trick | Chrome resolves symlinks, still detects the default directory |
|
|
26
|
+
| Copy profile + `--user-data-dir` | CDP works, but cookies can't be decrypted in the new location |
|
|
27
|
+
|
|
28
|
+
**AppleScript controls the real Chrome process with real cookies** — no profile copying, no CDP, no authentication issues. The tradeoff is macOS-only.
|
|
29
|
+
|
|
30
|
+
## Prerequisites
|
|
31
|
+
|
|
32
|
+
- **macOS** (AppleScript requirement)
|
|
33
|
+
- **Google Chrome** open and signed in to your AI provider
|
|
34
|
+
- **Allow JavaScript from Apple Events** — in Chrome: `View > Developer > Allow JavaScript from Apple Events`
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx ai-chat-export
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
This exports all your Gemini conversations as Markdown files to `./ai-chats/`.
|
|
43
|
+
|
|
44
|
+
## CLI Options
|
|
45
|
+
|
|
46
|
+
| Option | Short | Default | Description |
|
|
47
|
+
|---|---|---|---|
|
|
48
|
+
| `--provider <name>` | `-p` | `gemini` | AI provider to export from |
|
|
49
|
+
| `--output <dir>` | `-o` | `./ai-chats` | Output directory |
|
|
50
|
+
| `--format <type>` | `-f` | `markdown` | Output format: `markdown`, `json`, or `both` |
|
|
51
|
+
| `--delay <ms>` | `-d` | `3000` | Delay between chat exports (ms) |
|
|
52
|
+
| `--verbose` | `-v` | `false` | Enable debug logging |
|
|
53
|
+
| `--help` | `-h` | | Show help message |
|
|
54
|
+
|
|
55
|
+
## Examples
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# Export Gemini chats as JSON (for programmatic analysis)
|
|
59
|
+
npx ai-chat-export --format json
|
|
60
|
+
|
|
61
|
+
# Export both Markdown and JSON
|
|
62
|
+
npx ai-chat-export --format both --output ~/my-exports
|
|
63
|
+
|
|
64
|
+
# Slower export (for rate limiting concerns)
|
|
65
|
+
npx ai-chat-export --delay 5000
|
|
66
|
+
|
|
67
|
+
# Debug mode
|
|
68
|
+
npx ai-chat-export --verbose
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Output Formats
|
|
72
|
+
|
|
73
|
+
### Markdown (default)
|
|
74
|
+
|
|
75
|
+
```markdown
|
|
76
|
+
# How to make sourdough bread
|
|
77
|
+
|
|
78
|
+
## User
|
|
79
|
+
|
|
80
|
+
How do I make sourdough bread from scratch?
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Gemini
|
|
85
|
+
|
|
86
|
+
Here's a step-by-step guide to making sourdough bread...
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### JSON
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"title": "How to make sourdough bread",
|
|
96
|
+
"url": "https://gemini.google.com/app/abc123",
|
|
97
|
+
"exportedAt": "2025-01-15T10:30:00.000Z",
|
|
98
|
+
"messages": [
|
|
99
|
+
{ "role": "User", "content": "How do I make sourdough bread from scratch?" },
|
|
100
|
+
{ "role": "Gemini", "content": "Here's a step-by-step guide to making sourdough bread..." }
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## How It Works
|
|
106
|
+
|
|
107
|
+
1. **AppleScript bridge** — Executes JavaScript in Chrome's active tab via `osascript`
|
|
108
|
+
2. **Sidebar scanning** — Opens the sidebar and scrolls to load all lazy-loaded conversations
|
|
109
|
+
3. **Link collection** — Extracts all chat URLs, filtering out non-chat links
|
|
110
|
+
4. **Message extraction** — Navigates to each chat and extracts messages using multiple fallback DOM strategies
|
|
111
|
+
5. **Formatting** — Converts extracted messages to Markdown and/or JSON
|
|
112
|
+
|
|
113
|
+
## Adding a New Provider
|
|
114
|
+
|
|
115
|
+
Each provider is a single file in `src/providers/` that exports:
|
|
116
|
+
|
|
117
|
+
```js
|
|
118
|
+
export const name = "provider-id";
|
|
119
|
+
export const displayName = "Provider Name";
|
|
120
|
+
export const url = "https://provider.example.com";
|
|
121
|
+
export function checkSignedIn() { /* ... */ }
|
|
122
|
+
export async function collectChats({ verbose }) { /* ... */ }
|
|
123
|
+
export async function extractMessages() { /* ... */ }
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
See `src/providers/gemini.mjs` for a complete example.
|
|
127
|
+
|
|
128
|
+
## Limitations
|
|
129
|
+
|
|
130
|
+
- **macOS only** — AppleScript is a macOS technology
|
|
131
|
+
- **Chrome only** — Uses Chrome-specific AppleScript commands
|
|
132
|
+
- **DOM-dependent** — Message extraction relies on each provider's DOM structure, which may change
|
|
133
|
+
- **Sequential** — Exports one chat at a time (no parallel extraction)
|
|
134
|
+
- **No retry** — Failed chats are skipped without retry
|
|
135
|
+
|
|
136
|
+
## Future Work
|
|
137
|
+
|
|
138
|
+
- [ ] **More providers** — ChatGPT, Microsoft Copilot, DeepSeek
|
|
139
|
+
- [ ] **Windows/Linux support** — CDP-based approach where user launches Chrome with `--remote-debugging-port` on a separate `--user-data-dir`, signs in once, then tool connects via CDP
|
|
140
|
+
- [ ] **Retry logic** — Retry failed chat exports
|
|
141
|
+
- [ ] **Incremental export** — Skip chats already exported (by checking existing files)
|
|
142
|
+
|
|
143
|
+
## Use with Claude Code
|
|
144
|
+
|
|
145
|
+
This repo is a [Claude Code plugin](https://docs.anthropic.com/en/docs/claude-code/plugins). Install it and get the `/ai-chat-export` slash command:
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
/plugin install https://github.com/ktundwal/ai-chat-export
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Then use it:
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
/ai-chat-export
|
|
155
|
+
/ai-chat-export --provider gemini --format json --output ~/exports
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Contributing
|
|
159
|
+
|
|
160
|
+
Contributions welcome — especially new providers! See [CLAUDE.md](CLAUDE.md) for development guidelines.
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ai-chat-export — CLI entry point
|
|
5
|
+
*
|
|
6
|
+
* Exports AI chat conversations to Markdown and/or JSON files.
|
|
7
|
+
* Uses AppleScript to control Chrome with your real authenticated session.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { parseArgs } from "node:util";
|
|
11
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { chromeJS, navigateTo, sleep } from "../src/browser.mjs";
|
|
14
|
+
import { getProvider, listProviders } from "../src/providers/index.mjs";
|
|
15
|
+
import { formatAsMarkdown, formatAsJSON } from "../src/formatter.mjs";
|
|
16
|
+
|
|
17
|
+
// ─── CLI argument parsing ───────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const { values: args } = parseArgs({
|
|
20
|
+
options: {
|
|
21
|
+
provider: { type: "string", short: "p", default: "gemini" },
|
|
22
|
+
output: { type: "string", short: "o", default: "./ai-chats" },
|
|
23
|
+
format: { type: "string", short: "f", default: "markdown" },
|
|
24
|
+
delay: { type: "string", short: "d", default: "3000" },
|
|
25
|
+
verbose: { type: "boolean", short: "v", default: false },
|
|
26
|
+
help: { type: "boolean", short: "h", default: false },
|
|
27
|
+
},
|
|
28
|
+
strict: true,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (args.help) {
|
|
32
|
+
const providers = listProviders();
|
|
33
|
+
console.log(`
|
|
34
|
+
ai-chat-export — Export AI chat conversations to Markdown/JSON
|
|
35
|
+
|
|
36
|
+
USAGE
|
|
37
|
+
npx ai-chat-export [options]
|
|
38
|
+
|
|
39
|
+
OPTIONS
|
|
40
|
+
-p, --provider <name> AI provider: ${providers.join(", ")} (default: gemini)
|
|
41
|
+
-o, --output <dir> Output directory (default: ./ai-chats)
|
|
42
|
+
-f, --format <type> Output format: markdown, json, or both (default: markdown)
|
|
43
|
+
-d, --delay <ms> Delay between chats in ms (default: 3000)
|
|
44
|
+
-v, --verbose Enable verbose/debug logging
|
|
45
|
+
-h, --help Show this help message
|
|
46
|
+
|
|
47
|
+
PREREQUISITES
|
|
48
|
+
1. macOS (AppleScript requirement)
|
|
49
|
+
2. Google Chrome open and signed in to the AI provider
|
|
50
|
+
3. Chrome > View > Developer > Allow JavaScript from Apple Events (enabled)
|
|
51
|
+
|
|
52
|
+
EXAMPLES
|
|
53
|
+
npx ai-chat-export
|
|
54
|
+
npx ai-chat-export --provider gemini --format json
|
|
55
|
+
npx ai-chat-export --format both --output ~/my-exports
|
|
56
|
+
npx ai-chat-export --delay 5000 --verbose
|
|
57
|
+
`);
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const FORMAT = args.format;
|
|
62
|
+
const DELAY = parseInt(args.delay, 10) || 3000;
|
|
63
|
+
const VERBOSE = args.verbose;
|
|
64
|
+
|
|
65
|
+
if (!["markdown", "json", "both"].includes(FORMAT)) {
|
|
66
|
+
console.error(`Invalid format "${FORMAT}". Use: markdown, json, or both`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (VERBOSE) {
|
|
71
|
+
process.env.GEMINI_VERBOSE = "1";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
function sanitizeFilename(name) {
|
|
77
|
+
return name
|
|
78
|
+
.replace(/[/\\?%*:|"<>]/g, "-")
|
|
79
|
+
.replace(/\s+/g, " ")
|
|
80
|
+
.trim()
|
|
81
|
+
.slice(0, 200);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function log(msg) {
|
|
85
|
+
if (VERBOSE) console.log(msg);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
async function main() {
|
|
91
|
+
const provider = getProvider(args.provider);
|
|
92
|
+
|
|
93
|
+
console.log(`ai-chat-export (${provider.displayName})`);
|
|
94
|
+
console.log("==================\n");
|
|
95
|
+
|
|
96
|
+
if (process.platform !== "darwin") {
|
|
97
|
+
console.error(
|
|
98
|
+
"Error: This tool requires macOS (AppleScript). See README for details."
|
|
99
|
+
);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const OUTPUT_DIR = join(process.cwd(), args.output);
|
|
104
|
+
await mkdir(OUTPUT_DIR, { recursive: true });
|
|
105
|
+
console.log(`Provider: ${provider.displayName}`);
|
|
106
|
+
console.log(`Output: ${OUTPUT_DIR}`);
|
|
107
|
+
console.log(`Format: ${FORMAT}`);
|
|
108
|
+
console.log(`Delay: ${DELAY}ms\n`);
|
|
109
|
+
|
|
110
|
+
// Navigate to provider if needed
|
|
111
|
+
const currentURL = chromeJS("window.location.href");
|
|
112
|
+
log(`Current URL: ${currentURL}`);
|
|
113
|
+
|
|
114
|
+
if (!currentURL.includes(new URL(provider.url).hostname)) {
|
|
115
|
+
console.log(`Navigating to ${provider.displayName}...`);
|
|
116
|
+
await navigateTo(provider.url);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Verify authentication
|
|
120
|
+
if (!provider.checkSignedIn()) {
|
|
121
|
+
console.error(
|
|
122
|
+
`Error: Not signed in to ${provider.displayName}. Please sign in first.`
|
|
123
|
+
);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
console.log(`Signed in to ${provider.displayName}.\n`);
|
|
127
|
+
|
|
128
|
+
// Collect chats
|
|
129
|
+
console.log("Collecting chat links...");
|
|
130
|
+
const chats = await provider.collectChats({ verbose: VERBOSE });
|
|
131
|
+
|
|
132
|
+
console.log(`Found ${chats.length} conversations to export.\n`);
|
|
133
|
+
|
|
134
|
+
if (chats.length === 0) {
|
|
135
|
+
console.log("No conversations found. Exiting.");
|
|
136
|
+
process.exit(0);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Export each chat
|
|
140
|
+
let exported = 0;
|
|
141
|
+
let failed = 0;
|
|
142
|
+
|
|
143
|
+
for (let i = 0; i < chats.length; i++) {
|
|
144
|
+
const chat = chats[i];
|
|
145
|
+
const progress = `[${i + 1}/${chats.length}]`;
|
|
146
|
+
const title = chat.text || `Chat ${i + 1}`;
|
|
147
|
+
|
|
148
|
+
console.log(`${progress} ${title}`);
|
|
149
|
+
log(` URL: ${chat.href}`);
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
await navigateTo(chat.href);
|
|
153
|
+
const messages = await provider.extractMessages();
|
|
154
|
+
|
|
155
|
+
if (messages.length === 0) {
|
|
156
|
+
console.log(` ⚠ No messages extracted, skipping`);
|
|
157
|
+
failed++;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
log(` ${messages.length} messages extracted`);
|
|
162
|
+
|
|
163
|
+
const filename = sanitizeFilename(title) || `chat-${i + 1}`;
|
|
164
|
+
|
|
165
|
+
if (FORMAT === "markdown" || FORMAT === "both") {
|
|
166
|
+
const md = formatAsMarkdown(title, chat.href, messages);
|
|
167
|
+
await writeFile(join(OUTPUT_DIR, `${filename}.md`), md, "utf-8");
|
|
168
|
+
log(` Saved ${filename}.md`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (FORMAT === "json" || FORMAT === "both") {
|
|
172
|
+
const json = formatAsJSON(title, chat.href, messages);
|
|
173
|
+
await writeFile(join(OUTPUT_DIR, `${filename}.json`), json, "utf-8");
|
|
174
|
+
log(` Saved ${filename}.json`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
exported++;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.error(` ✗ ${err.message}`);
|
|
180
|
+
failed++;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (i < chats.length - 1) await sleep(DELAY);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
console.log("\n==================");
|
|
187
|
+
console.log(`Export complete!`);
|
|
188
|
+
console.log(` Exported: ${exported}`);
|
|
189
|
+
console.log(` Failed: ${failed}`);
|
|
190
|
+
console.log(` Output: ${OUTPUT_DIR}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
main().catch((err) => {
|
|
194
|
+
console.error(`\nFatal error: ${err.message}`);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-chat-export",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Export AI chat conversations (Gemini, ChatGPT, Copilot, DeepSeek) to Markdown and JSON. Uses AppleScript to control Chrome — no API keys needed.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ai-chat-export": "bin/ai-chat-export.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
".claude-plugin/",
|
|
13
|
+
"skills/",
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "node bin/ai-chat-export.mjs"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"ai-chat-export",
|
|
22
|
+
"gemini",
|
|
23
|
+
"chatgpt",
|
|
24
|
+
"copilot",
|
|
25
|
+
"deepseek",
|
|
26
|
+
"chat-export",
|
|
27
|
+
"conversation-export",
|
|
28
|
+
"applescript",
|
|
29
|
+
"chrome",
|
|
30
|
+
"markdown"
|
|
31
|
+
],
|
|
32
|
+
"author": "Kapil Tundwal",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/ktundwal/ai-chat-export.git"
|
|
40
|
+
},
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/ktundwal/ai-chat-export/issues"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/ktundwal/ai-chat-export#readme"
|
|
45
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ai-chat-export
|
|
3
|
+
description: Export AI chat conversations to Markdown and/or JSON. Use when the user wants to save, export, or backup their chats from Gemini, ChatGPT, Copilot, or DeepSeek.
|
|
4
|
+
argument-hint: "[--provider gemini] [--format markdown|json|both] [--output dir]"
|
|
5
|
+
allowed-tools: Bash, Read
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Export AI chat conversations from Chrome using the `ai-chat-export` CLI tool.
|
|
9
|
+
|
|
10
|
+
## Prerequisites
|
|
11
|
+
|
|
12
|
+
Before running, verify with the user:
|
|
13
|
+
1. We're on macOS (AppleScript requirement)
|
|
14
|
+
2. Google Chrome is open and signed in to the relevant AI provider
|
|
15
|
+
3. Chrome > View > Developer > Allow JavaScript from Apple Events is enabled
|
|
16
|
+
|
|
17
|
+
If any prerequisite is missing, guide the user through setup.
|
|
18
|
+
|
|
19
|
+
## Running the Export
|
|
20
|
+
|
|
21
|
+
Install and run the tool. Pass through any arguments the user provided:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx ai-chat-export $ARGUMENTS
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
If no arguments were provided, run with defaults (exports Gemini chats):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx ai-chat-export
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Available Options
|
|
34
|
+
|
|
35
|
+
| Option | Short | Default | Description |
|
|
36
|
+
|--------|-------|---------|-------------|
|
|
37
|
+
| `--provider <name>` | `-p` | `gemini` | AI provider to export from |
|
|
38
|
+
| `--output <dir>` | `-o` | `./ai-chats` | Output directory |
|
|
39
|
+
| `--format <type>` | `-f` | `markdown` | `markdown`, `json`, or `both` |
|
|
40
|
+
| `--delay <ms>` | `-d` | `3000` | Delay between chats in ms |
|
|
41
|
+
| `--verbose` | `-v` | | Debug logging |
|
|
42
|
+
| `--help` | `-h` | | Show help |
|
|
43
|
+
|
|
44
|
+
### Supported Providers
|
|
45
|
+
|
|
46
|
+
| Provider | Status |
|
|
47
|
+
|----------|--------|
|
|
48
|
+
| `gemini` | Available |
|
|
49
|
+
| `chatgpt` | Coming soon |
|
|
50
|
+
| `copilot` | Coming soon |
|
|
51
|
+
| `deepseek` | Coming soon |
|
|
52
|
+
|
|
53
|
+
## After Export
|
|
54
|
+
|
|
55
|
+
Once complete, summarize:
|
|
56
|
+
- Which provider was exported
|
|
57
|
+
- How many chats were exported
|
|
58
|
+
- Where the output files are located
|
|
59
|
+
- Any failures that occurred
|
package/src/browser.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser.mjs — AppleScript ↔ Chrome bridge
|
|
3
|
+
*
|
|
4
|
+
* Controls Google Chrome via AppleScript to execute JavaScript
|
|
5
|
+
* in the active tab. This is the core mechanism that lets us
|
|
6
|
+
* interact with Gemini using the user's real authenticated session.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execSync } from "child_process";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Execute JavaScript in Chrome's active tab via AppleScript.
|
|
13
|
+
* Returns the string result.
|
|
14
|
+
*/
|
|
15
|
+
export function chromeJS(js) {
|
|
16
|
+
const escaped = js.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
17
|
+
const script = `tell application "Google Chrome" to execute active tab of front window javascript "${escaped}"`;
|
|
18
|
+
try {
|
|
19
|
+
const result = execSync(`osascript -e '${script.replace(/'/g, "'\\''")}'`, {
|
|
20
|
+
encoding: "utf-8",
|
|
21
|
+
timeout: 30000,
|
|
22
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
23
|
+
});
|
|
24
|
+
return result.trim();
|
|
25
|
+
} catch (err) {
|
|
26
|
+
if (process.env.GEMINI_VERBOSE) {
|
|
27
|
+
console.error(` JS execution error: ${err.message.slice(0, 200)}`);
|
|
28
|
+
}
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Navigate Chrome's active tab to a URL and wait for it to load.
|
|
35
|
+
*/
|
|
36
|
+
export async function navigateTo(url) {
|
|
37
|
+
const escaped = url.replace(/"/g, '\\"');
|
|
38
|
+
execSync(
|
|
39
|
+
`osascript -e 'tell application "Google Chrome" to set URL of active tab of front window to "${escaped}"'`,
|
|
40
|
+
{ encoding: "utf-8", timeout: 10000 }
|
|
41
|
+
);
|
|
42
|
+
await sleep(3000);
|
|
43
|
+
for (let i = 0; i < 10; i++) {
|
|
44
|
+
const loading = chromeJS("document.readyState");
|
|
45
|
+
if (loading === "complete") break;
|
|
46
|
+
await sleep(1000);
|
|
47
|
+
}
|
|
48
|
+
await sleep(2000);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check if the user is signed in to Gemini.
|
|
53
|
+
* Returns true if signed in, false otherwise.
|
|
54
|
+
*/
|
|
55
|
+
export function checkSignedIn() {
|
|
56
|
+
const result = chromeJS(
|
|
57
|
+
`document.querySelector('a[aria-label="Sign in"]') === null ? 'yes' : 'no'`
|
|
58
|
+
);
|
|
59
|
+
return result === "yes";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function sleep(ms) {
|
|
63
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
64
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* formatter.mjs — Output formatting for exported chats
|
|
3
|
+
*
|
|
4
|
+
* Converts extracted messages into Markdown or JSON format.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Format messages as a Markdown document.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} title - Chat title
|
|
11
|
+
* @param {string} url - Original Gemini URL
|
|
12
|
+
* @param {Array<{role: string, content: string}>} messages
|
|
13
|
+
* @returns {string} Markdown content
|
|
14
|
+
*/
|
|
15
|
+
export function formatAsMarkdown(title, url, messages) {
|
|
16
|
+
let md = `# ${title}\n\n`;
|
|
17
|
+
for (const msg of messages) {
|
|
18
|
+
if (!msg.content) continue;
|
|
19
|
+
md += `## ${msg.role}\n\n${msg.content}\n\n---\n\n`;
|
|
20
|
+
}
|
|
21
|
+
return md;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Format messages as a JSON document.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} title - Chat title
|
|
28
|
+
* @param {string} url - Original Gemini URL
|
|
29
|
+
* @param {Array<{role: string, content: string}>} messages
|
|
30
|
+
* @returns {string} Pretty-printed JSON
|
|
31
|
+
*/
|
|
32
|
+
export function formatAsJSON(title, url, messages) {
|
|
33
|
+
return JSON.stringify(
|
|
34
|
+
{
|
|
35
|
+
title,
|
|
36
|
+
url,
|
|
37
|
+
exportedAt: new Date().toISOString(),
|
|
38
|
+
messages: messages
|
|
39
|
+
.filter((m) => m.content)
|
|
40
|
+
.map(({ role, content }) => ({ role, content })),
|
|
41
|
+
},
|
|
42
|
+
null,
|
|
43
|
+
2
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gemini.mjs — Google Gemini provider
|
|
3
|
+
*
|
|
4
|
+
* Implements chat discovery and message extraction for gemini.google.com.
|
|
5
|
+
* Uses AppleScript to control Chrome via the shared browser bridge.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { chromeJS } from "../browser.mjs";
|
|
9
|
+
import { sleep } from "../browser.mjs";
|
|
10
|
+
|
|
11
|
+
export const name = "gemini";
|
|
12
|
+
export const displayName = "Google Gemini";
|
|
13
|
+
export const url = "https://gemini.google.com/app";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Check if the user is signed in to Gemini.
|
|
17
|
+
*/
|
|
18
|
+
export function checkSignedIn() {
|
|
19
|
+
const result = chromeJS(
|
|
20
|
+
`document.querySelector('a[aria-label="Sign in"]') === null ? 'yes' : 'no'`
|
|
21
|
+
);
|
|
22
|
+
return result === "yes";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Open the sidebar menu.
|
|
27
|
+
*/
|
|
28
|
+
async function ensureSidebarOpen() {
|
|
29
|
+
chromeJS(`
|
|
30
|
+
(function() {
|
|
31
|
+
const btn = document.querySelector('button[aria-label="Main menu"]');
|
|
32
|
+
if (btn) btn.click();
|
|
33
|
+
})()
|
|
34
|
+
`);
|
|
35
|
+
await sleep(2000);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Scroll the sidebar to load all lazy-loaded conversations.
|
|
40
|
+
* Gemini only renders chats as you scroll.
|
|
41
|
+
*/
|
|
42
|
+
async function scrollSidebarToLoadAll({ verbose = false } = {}) {
|
|
43
|
+
if (verbose) console.log("Scrolling sidebar to load all conversations...");
|
|
44
|
+
|
|
45
|
+
let stableRounds = 0;
|
|
46
|
+
for (let i = 0; i < 100; i++) {
|
|
47
|
+
const countBefore = chromeJS(
|
|
48
|
+
`document.querySelectorAll('a[href*="/app/"]').length`
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
chromeJS(`
|
|
52
|
+
(function() {
|
|
53
|
+
const candidates = [
|
|
54
|
+
document.querySelector('side-navigation-content'),
|
|
55
|
+
document.querySelector('bard-sidenav'),
|
|
56
|
+
document.querySelector('[role="navigation"]'),
|
|
57
|
+
document.querySelector('.side-nav-container'),
|
|
58
|
+
document.querySelector('mat-sidenav'),
|
|
59
|
+
];
|
|
60
|
+
for (const el of candidates) {
|
|
61
|
+
if (el && el.scrollHeight > el.clientHeight) {
|
|
62
|
+
el.scrollTop = el.scrollHeight;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const container = document.querySelector('bard-sidenav-container');
|
|
67
|
+
if (container) {
|
|
68
|
+
for (const child of container.querySelectorAll('*')) {
|
|
69
|
+
if (child.scrollHeight > child.clientHeight + 10) {
|
|
70
|
+
child.scrollTop = child.scrollHeight;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
})()
|
|
76
|
+
`);
|
|
77
|
+
await sleep(1500);
|
|
78
|
+
|
|
79
|
+
const countAfter = chromeJS(
|
|
80
|
+
`document.querySelectorAll('a[href*="/app/"]').length`
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
if (countAfter === countBefore) {
|
|
84
|
+
stableRounds++;
|
|
85
|
+
if (stableRounds >= 5) {
|
|
86
|
+
if (verbose) console.log(` Sidebar fully loaded — ${countAfter} links found`);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
stableRounds = 0;
|
|
91
|
+
if (verbose) console.log(` Scrolling... ${countAfter} links so far`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Collect all chat links from the sidebar.
|
|
98
|
+
* Filters out non-chat links and deduplicates.
|
|
99
|
+
*
|
|
100
|
+
* @returns {Promise<Array<{href: string, text: string}>>}
|
|
101
|
+
*/
|
|
102
|
+
export async function collectChats({ verbose = false } = {}) {
|
|
103
|
+
await ensureSidebarOpen();
|
|
104
|
+
await scrollSidebarToLoadAll({ verbose });
|
|
105
|
+
|
|
106
|
+
const linksJSON = chromeJS(`
|
|
107
|
+
JSON.stringify(
|
|
108
|
+
Array.from(document.querySelectorAll('a[href*="/app/"]'))
|
|
109
|
+
.map(a => ({ href: a.href, text: (a.textContent || '').trim().split('\\n')[0].trim() }))
|
|
110
|
+
.filter(l =>
|
|
111
|
+
!l.href.endsWith('/app') &&
|
|
112
|
+
!l.href.endsWith('/app/') &&
|
|
113
|
+
!l.href.includes('/download') &&
|
|
114
|
+
!l.href.includes('/settings') &&
|
|
115
|
+
!l.href.includes('/extensions') &&
|
|
116
|
+
!l.href.includes('accounts.google.com')
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
`);
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const links = JSON.parse(linksJSON);
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
return links.filter((l) => {
|
|
125
|
+
if (seen.has(l.href)) return false;
|
|
126
|
+
seen.add(l.href);
|
|
127
|
+
return true;
|
|
128
|
+
});
|
|
129
|
+
} catch {
|
|
130
|
+
console.error("Failed to parse chat links from sidebar");
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Extract messages from the currently loaded chat page.
|
|
137
|
+
* Tries 4 strategies in order — Gemini's DOM changes over time.
|
|
138
|
+
*
|
|
139
|
+
* @returns {Promise<Array<{role: string, content: string}>>}
|
|
140
|
+
*/
|
|
141
|
+
export async function extractMessages() {
|
|
142
|
+
await sleep(2000);
|
|
143
|
+
|
|
144
|
+
// Scroll conversation to load all messages
|
|
145
|
+
chromeJS(`
|
|
146
|
+
(function() {
|
|
147
|
+
const main = document.querySelector('.conversation-container') ||
|
|
148
|
+
document.querySelector('main') ||
|
|
149
|
+
document.scrollingElement;
|
|
150
|
+
if (main) {
|
|
151
|
+
let lastH = 0;
|
|
152
|
+
function scrollDown() {
|
|
153
|
+
main.scrollTop = main.scrollHeight;
|
|
154
|
+
if (main.scrollHeight !== lastH) {
|
|
155
|
+
lastH = main.scrollHeight;
|
|
156
|
+
setTimeout(scrollDown, 500);
|
|
157
|
+
} else {
|
|
158
|
+
main.scrollTop = 0;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
scrollDown();
|
|
162
|
+
}
|
|
163
|
+
})()
|
|
164
|
+
`);
|
|
165
|
+
await sleep(3000);
|
|
166
|
+
|
|
167
|
+
const messagesJSON = chromeJS(`
|
|
168
|
+
JSON.stringify((function() {
|
|
169
|
+
const results = [];
|
|
170
|
+
|
|
171
|
+
// Strategy 1: Custom elements (user-query / model-response)
|
|
172
|
+
const turns = document.querySelectorAll(
|
|
173
|
+
'user-query, model-response, .conversation-turn, [class*="turn-container"]'
|
|
174
|
+
);
|
|
175
|
+
if (turns.length > 0) {
|
|
176
|
+
for (const turn of turns) {
|
|
177
|
+
const tag = turn.tagName.toLowerCase();
|
|
178
|
+
const isUser = tag === 'user-query' ||
|
|
179
|
+
turn.classList.contains('user-turn') ||
|
|
180
|
+
turn.querySelector('[class*="user"]') !== null;
|
|
181
|
+
const text = turn.innerText?.trim();
|
|
182
|
+
if (text && text.length > 1) {
|
|
183
|
+
results.push({ role: isUser ? 'User' : 'Gemini', content: text });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (results.length > 0) return results;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Strategy 2: Query text + response container patterns
|
|
190
|
+
const queryEls = document.querySelectorAll(
|
|
191
|
+
'.query-text, [class*="query-content"], [class*="user-query"], user-query'
|
|
192
|
+
);
|
|
193
|
+
const responseEls = document.querySelectorAll(
|
|
194
|
+
'.response-container, .model-response-text, [class*="model-response"], model-response, message-content'
|
|
195
|
+
);
|
|
196
|
+
if (queryEls.length > 0 || responseEls.length > 0) {
|
|
197
|
+
const allTurns = [];
|
|
198
|
+
queryEls.forEach(el => allTurns.push({
|
|
199
|
+
role: 'User', content: el.innerText?.trim(),
|
|
200
|
+
y: el.getBoundingClientRect().top
|
|
201
|
+
}));
|
|
202
|
+
responseEls.forEach(el => allTurns.push({
|
|
203
|
+
role: 'Gemini', content: el.innerText?.trim(),
|
|
204
|
+
y: el.getBoundingClientRect().top
|
|
205
|
+
}));
|
|
206
|
+
allTurns.sort((a, b) => a.y - b.y);
|
|
207
|
+
const cleaned = allTurns
|
|
208
|
+
.filter(t => t.content && t.content.length > 1)
|
|
209
|
+
.map(({role, content}) => ({role, content}));
|
|
210
|
+
if (cleaned.length > 0) return cleaned;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Strategy 3: data-message-author-role attributes
|
|
214
|
+
const dataEls = document.querySelectorAll('[data-message-author-role]');
|
|
215
|
+
if (dataEls.length > 0) {
|
|
216
|
+
for (const el of dataEls) {
|
|
217
|
+
const role = el.getAttribute('data-message-author-role');
|
|
218
|
+
const text = el.innerText?.trim();
|
|
219
|
+
if (text) results.push({ role: role === 'user' ? 'User' : 'Gemini', content: text });
|
|
220
|
+
}
|
|
221
|
+
if (results.length > 0) return results;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Strategy 4: Conversation container children
|
|
225
|
+
const container = document.querySelector('.conversation-container') ||
|
|
226
|
+
document.querySelector('[class*="conversation"]') ||
|
|
227
|
+
document.querySelector('main');
|
|
228
|
+
if (container) {
|
|
229
|
+
const children = Array.from(container.children);
|
|
230
|
+
for (let i = 0; i < children.length; i++) {
|
|
231
|
+
const el = children[i];
|
|
232
|
+
const text = el.innerText?.trim();
|
|
233
|
+
if (!text || text.length < 2) continue;
|
|
234
|
+
const cls = (el.className || '') + ' ' + (el.tagName || '');
|
|
235
|
+
const isUser = /user|query|human|prompt|request/i.test(cls);
|
|
236
|
+
results.push({ role: isUser ? 'User' : 'Gemini', content: text });
|
|
237
|
+
}
|
|
238
|
+
if (results.length > 0) return results;
|
|
239
|
+
|
|
240
|
+
// Last resort: grab all text
|
|
241
|
+
const text = container.innerText?.trim();
|
|
242
|
+
if (text) return [{ role: 'Unknown', content: text }];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return results;
|
|
246
|
+
})())
|
|
247
|
+
`);
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
return JSON.parse(messagesJSON);
|
|
251
|
+
} catch {
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* providers/index.mjs — Provider registry
|
|
3
|
+
*
|
|
4
|
+
* Each provider exports: { name, displayName, url, checkSignedIn, collectChats, extractMessages }
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as gemini from "./gemini.mjs";
|
|
8
|
+
|
|
9
|
+
const providers = {
|
|
10
|
+
gemini,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get a provider by name.
|
|
15
|
+
* @param {string} name
|
|
16
|
+
* @returns {object} provider module
|
|
17
|
+
*/
|
|
18
|
+
export function getProvider(name) {
|
|
19
|
+
const provider = providers[name];
|
|
20
|
+
if (!provider) {
|
|
21
|
+
const available = Object.keys(providers).join(", ");
|
|
22
|
+
throw new Error(`Unknown provider "${name}". Available: ${available}`);
|
|
23
|
+
}
|
|
24
|
+
return provider;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* List all available provider names.
|
|
29
|
+
* @returns {string[]}
|
|
30
|
+
*/
|
|
31
|
+
export function listProviders() {
|
|
32
|
+
return Object.keys(providers);
|
|
33
|
+
}
|