Skip to content

Commit edbd6ab

Browse files
committed
feat(gemini): enhance DOM scraping for conversation extraction; add error handling and improve message parsing
1 parent 6bf2313 commit edbd6ab

File tree

3 files changed

+183
-29
lines changed

3 files changed

+183
-29
lines changed

src/content-scripts/FloatingDial.tsx

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,14 @@ export const FloatingDial: React.FC<Props> = ({
408408
icon: <HardDriveDownload size={16} strokeWidth={2} />,
409409
label: "Save to Vault",
410410
onClick: handleSave,
411+
badge:
412+
provider === "gemini"
413+
? {
414+
text: "Limited",
415+
tooltip:
416+
"Gemini saving relies on DOM scraping and may miss messages or use wrong order. Review before relying on saved data.",
417+
}
418+
: undefined,
411419
},
412420
// Save All is not available for Gemini (intercept-only architecture)
413421
...(provider !== "gemini"
@@ -568,22 +576,45 @@ export const FloatingDial: React.FC<Props> = ({
568576
animation: `hacklm-dial-in 0.18s cubic-bezier(.34,1.56,.64,1) ${i * 0.06}s both`,
569577
}}
570578
>
571-
{/* Label */}
572-
<div
573-
style={{
574-
background: tok.surface,
575-
border: `1px solid ${tok.grid}`,
576-
borderRadius: 4,
577-
padding: "4px 10px",
578-
fontSize: 12,
579-
fontFamily: '"JetBrains Mono", monospace',
580-
color: tok.foreground,
581-
whiteSpace: "nowrap",
582-
boxShadow: "0 2px 8px rgba(0,0,0,0.12)",
583-
pointerEvents: "none",
584-
}}
585-
>
586-
{a.label}
579+
{/* Label (+ optional Limited badge) */}
580+
<div style={{ display: "flex", alignItems: "center", gap: 5 }}>
581+
{"badge" in a && a.badge && (
582+
<div
583+
title={a.badge.tooltip}
584+
style={{
585+
background: "rgba(234,179,8,0.15)",
586+
border: "1px solid rgba(234,179,8,0.55)",
587+
borderRadius: 4,
588+
padding: "3px 7px",
589+
fontSize: 10,
590+
fontFamily: '"JetBrains Mono", monospace',
591+
fontWeight: 600,
592+
color: "#ca8a04",
593+
whiteSpace: "nowrap",
594+
boxShadow: "0 2px 8px rgba(0,0,0,0.12)",
595+
letterSpacing: "0.04em",
596+
cursor: "help",
597+
}}
598+
>
599+
{a.badge.text}
600+
</div>
601+
)}
602+
<div
603+
style={{
604+
background: tok.surface,
605+
border: `1px solid ${tok.grid}`,
606+
borderRadius: 4,
607+
padding: "4px 10px",
608+
fontSize: 12,
609+
fontFamily: '"JetBrains Mono", monospace',
610+
color: tok.foreground,
611+
whiteSpace: "nowrap",
612+
boxShadow: "0 2px 8px rgba(0,0,0,0.12)",
613+
pointerEvents: "none",
614+
}}
615+
>
616+
{a.label}
617+
</div>
587618
</div>
588619

589620
{/* Action button */}

src/content-scripts/gemini.tsx

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,26 +51,55 @@ window.addEventListener("message", (event) => {
5151

5252
/**
5353
* Best-effort extraction of messages from Gemini's batch payload.
54-
* Google uses unkeyed nested arrays. We walk the structure looking
55-
* for arrays matching the pattern of [modelResponse, userQuery,...].
54+
* Google uses nested unkeyed arrays. We search for leaf arrays that
55+
* look like [string, string] pairs where the first element is a
56+
* non-trivial text block (model response) and match them to the
57+
* corresponding user turn above.
58+
*
59+
* Format observed: the outer array contains one or more
60+
* "AF_initDataCallback" blobs. Within those, conversation turns
61+
* are encoded as deeply nested arrays. We collect all string leaves
62+
* longer than 20 chars and interleave them as user/assistant pairs.
5663
*/
5764
function extractMessagesFromPayload(
5865
data: unknown
5966
): { role: "user" | "assistant"; text: string; timestamp?: string }[] {
60-
const messages: { role: "user" | "assistant"; text: string }[] = [];
67+
const collected: string[] = [];
6168

6269
function walk(node: unknown, depth = 0): void {
63-
if (depth > 20) return;
70+
if (depth > 30) return;
71+
if (typeof node === "string") {
72+
const s = node.trim();
73+
// Skip obviously non-conversational strings (short, URLs, IDs, etc.)
74+
if (s.length > 20 && !/^https?:\/\//.test(s) && !/^[a-zA-Z0-9_-]{8,}$/.test(s)) {
75+
collected.push(s);
76+
}
77+
return;
78+
}
6479
if (!Array.isArray(node)) return;
65-
6680
for (const child of node) {
67-
if (Array.isArray(child)) {
68-
walk(child, depth + 1);
69-
}
81+
walk(child, depth + 1);
7082
}
7183
}
7284

7385
walk(data);
86+
87+
if (collected.length === 0) return [];
88+
89+
// Deduplicate adjacent duplicates (Gemini repeats text in multiple fields)
90+
const deduped = collected.filter((s, i) => s !== collected[i - 1]);
91+
92+
// Interleave as user / assistant pairs.
93+
// Typically: user, model, user, model...
94+
// We can't know the true role order from this heuristic, so we
95+
// use alternating assignment starting with 'user'.
96+
const messages: { role: "user" | "assistant"; text: string }[] = [];
97+
for (let i = 0; i < deduped.length; i++) {
98+
messages.push({
99+
role: i % 2 === 0 ? "user" : "assistant",
100+
text: deduped[i],
101+
});
102+
}
74103
return messages;
75104
}
76105

src/extractors/gemini.ts

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,17 +54,111 @@ export function buildGeminiThread(data: GeminiInterceptedChat): Thread {
5454
};
5555
}
5656

57-
/* ── Extractor stub (actual data comes from content-script) ─ */
57+
/* ── DOM scraper (runs in content-script context) ─────────── */
58+
59+
/**
60+
* Scrape the visible Gemini conversation from the DOM.
61+
* Tries several selector generations – Google reuses build
62+
* hashes so class names change; custom element names are stable.
63+
*/
64+
function scrapeGeminiDOM(): GeminiInterceptedChat["messages"] {
65+
const messages: GeminiInterceptedChat["messages"] = [];
66+
67+
// Candidate query selectors, tried in order.
68+
// Each entry: [userSelector, modelSelector]
69+
const candidatePairs: [string, string][] = [
70+
["user-query", "model-response"],
71+
["[data-participant-type='user']", "[data-participant-type='model']"],
72+
[".user-query", ".model-response"],
73+
[".conversation-turn[data-turn-type='user']", ".conversation-turn[data-turn-type='model']"],
74+
];
75+
76+
// Walk conversation turn nodes in DOM order.
77+
// Build a combined list of {role, el} sorted by appearance.
78+
let pairs: { role: "user" | "assistant"; el: Element }[] = [];
79+
80+
for (const [userSel, modelSel] of candidatePairs) {
81+
const userEls = Array.from(document.querySelectorAll(userSel));
82+
const modelEls = Array.from(document.querySelectorAll(modelSel));
83+
if (userEls.length > 0 || modelEls.length > 0) {
84+
pairs = [
85+
...userEls.map((el) => ({ role: "user" as const, el })),
86+
...modelEls.map((el) => ({ role: "assistant" as const, el })),
87+
];
88+
break;
89+
}
90+
}
91+
92+
if (pairs.length === 0) return messages;
93+
94+
// Sort by DOM position so turns appear in conversation order.
95+
pairs.sort((a, b) => {
96+
const pos = a.el.compareDocumentPosition(b.el);
97+
return pos & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
98+
});
99+
100+
for (const { role, el } of pairs) {
101+
const text = extractTextFromTurn(el, role);
102+
if (text) messages.push({ role, text });
103+
}
104+
105+
return messages;
106+
}
107+
108+
/** Pull readable text from a single turn element. */
109+
function extractTextFromTurn(
110+
el: Element,
111+
role: "user" | "assistant"
112+
): string {
113+
const userSelectors = [
114+
".query-text",
115+
".user-query-text",
116+
".prompt-text",
117+
];
118+
const modelSelectors = [
119+
".response-container .markdown",
120+
".response-content .markdown",
121+
"message-content",
122+
".model-response-text",
123+
".placeholder.markdown-main-panel",
124+
".markdown",
125+
];
126+
127+
const candidates = role === "user" ? userSelectors : modelSelectors;
128+
129+
for (const sel of candidates) {
130+
const child = el.querySelector(sel);
131+
if (child?.textContent?.trim()) return child.textContent.trim();
132+
}
133+
134+
// Fallback: raw text content of the whole element.
135+
return el.textContent?.trim() ?? "";
136+
}
137+
138+
/* ── Extractor (fetchThread uses DOM scraping) ─────────────── */
58139

59140
export const geminiExtractor: Extractor = {
60141
async listConversations() {
61142
// Not feasible from the background – data arrives via content-script intercept
62143
return [];
63144
},
64145

65-
async fetchThread(_conversationId: string): Promise<Thread> {
66-
throw new Error(
67-
"Gemini threads must be intercepted from the content-script. Use GEMINI_INTERCEPTED_CHAT messages."
68-
);
146+
async fetchThread(conversationId: string): Promise<Thread> {
147+
const messages = scrapeGeminiDOM();
148+
149+
if (messages.length === 0) {
150+
throw new Error(
151+
"No Gemini conversation found on this page. " +
152+
"Make sure the chat has finished loading and try again."
153+
);
154+
}
155+
156+
return buildGeminiThread({
157+
conversationId,
158+
title:
159+
document.title.replace(/ [-|] Google Gemini$/i, "").trim() ||
160+
"Gemini Chat",
161+
messages,
162+
});
69163
},
70164
};

0 commit comments

Comments
 (0)