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.
@@ -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
@@ -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
+ }