decant-core 1.4.0 → 1.6.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/ai/chatgpt.js CHANGED
@@ -759,6 +759,19 @@ export class ChatGPTParser extends ChatParser {
759
759
  }
760
760
  });
761
761
 
762
+ clone
763
+ .querySelectorAll(
764
+ '[data-testid="webpage-citation-pill"], [data-testid*="citation-pill"]',
765
+ )
766
+ .forEach((pill) => {
767
+ pill.querySelectorAll("img").forEach((img) => img.remove());
768
+ pill.querySelectorAll("span").forEach((span) => {
769
+ if (/^\+\d+$/.test(span.textContent.trim())) {
770
+ span.remove();
771
+ }
772
+ });
773
+ });
774
+
762
775
  noiseSelectors.forEach((selector) => {
763
776
  clone.querySelectorAll(selector).forEach((node) => node.remove());
764
777
  });
@@ -783,11 +796,13 @@ export class ChatGPTParser extends ChatParser {
783
796
  }
784
797
 
785
798
  extractMountedMessages() {
786
- const articles = Array.from(document.querySelectorAll("article"));
799
+ const turns = getConversationTurns(document);
787
800
  const containers =
788
- articles.length > 0
789
- ? articles
790
- : Array.from(document.querySelectorAll("[data-message-author-role]"));
801
+ turns.length > 0
802
+ ? turns
803
+ : Array.from(
804
+ document.querySelectorAll("article, [data-message-author-role]"),
805
+ );
791
806
 
792
807
  return containers
793
808
  .map((container) => this.extractMessage(container))
package/ai/gemini.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ChatParser } from "./base.js";
2
2
  import { convertToMarkdown } from "../utils/html-to-markdown.js";
3
+ import { normalizeLatexMath } from "../utils/latex-math.js";
3
4
 
4
5
  const GEMINI_RPC_ID = "hNvQHb";
5
6
  const DEFAULT_BARD_PATH = "/_/BardChatUi";
@@ -94,23 +95,7 @@ export class GeminiParser extends ChatParser {
94
95
 
95
96
  const mode = options.parserMode || "auto";
96
97
 
97
- // 1. Prefer DOM extraction first when on a live page with conversation containers
98
- if (typeof document !== "undefined" && document.querySelector) {
99
- const hasDomMessages = document.querySelector(
100
- ".conversation-container, user-query, model-response, deep-research-immersive-panel",
101
- );
102
- if (hasDomMessages && mode !== "api") {
103
- const domResult = this.parseFromDom(currentUrl, options);
104
- if (domResult && domResult.messages && domResult.messages.length > 0) {
105
- console.log(
106
- `[Gemini Parser] Successfully parsed ${domResult.messages.length} messages from DOM`,
107
- );
108
- return domResult;
109
- }
110
- }
111
- }
112
-
113
- // 2. Attempt API / RPC extraction if DOM parsing didn't find messages or mode is API
98
+ // 1. Attempt API / RPC extraction first when not explicitly in 'dom' mode
114
99
  if (mode !== "dom" && typeof fetch === "function") {
115
100
  try {
116
101
  const convoId = this.getConversationId(currentUrl);
@@ -149,7 +134,7 @@ export class GeminiParser extends ChatParser {
149
134
  }
150
135
  }
151
136
 
152
- // Fall back to robust DOM parsing
137
+ // 2. Fall back to robust DOM parsing
153
138
  return this.parseFromDom(currentUrl, options);
154
139
  }
155
140
 
@@ -175,14 +160,17 @@ export class GeminiParser extends ChatParser {
175
160
  `&_reqid=${encodeURIComponent(reqId)}` +
176
161
  `&rt=c`;
177
162
 
163
+ const formattedConvoId = convoId.startsWith("c_")
164
+ ? convoId
165
+ : `c_${convoId}`;
178
166
  const allItems = [];
179
167
  let cursor = null;
180
168
  let pageCount = 0;
181
169
 
182
- while (pageCount < 50) {
170
+ while (pageCount < 250) {
183
171
  pageCount++;
184
172
  const payloadArg = JSON.stringify([
185
- `c_${convoId}`,
173
+ formattedConvoId,
186
174
  100,
187
175
  cursor,
188
176
  1,
@@ -227,11 +215,11 @@ export class GeminiParser extends ChatParser {
227
215
  const continueCursor = payload[1] || null;
228
216
 
229
217
  if (items.length > 0) {
230
- // Items are in reverse chronological order from API
218
+ // Items are in reverse chronological order from API; reverse to maintain oldest-first order
231
219
  allItems.unshift(...items.slice().reverse());
232
220
  }
233
221
 
234
- if (!continueCursor || items.length < 100) {
222
+ if (!continueCursor) {
235
223
  break;
236
224
  }
237
225
  cursor = continueCursor;
@@ -241,7 +229,158 @@ export class GeminiParser extends ChatParser {
241
229
  return null;
242
230
  }
243
231
 
232
+ return this.formatApiResult(allItems, currentUrl, options);
233
+ }
234
+
235
+ enrichMessagesWithDomAttachments(messages) {
236
+ try {
237
+ if (typeof document === "undefined" || !document.querySelectorAll) {
238
+ return messages;
239
+ }
240
+ const parentContainers = document.querySelectorAll(
241
+ ".conversation-container",
242
+ );
243
+ const domTurns = [];
244
+
245
+ if (parentContainers.length > 0) {
246
+ parentContainers.forEach((container, idx) => {
247
+ const rawId = (container.id || "").replace(/^r_/, "");
248
+ const fileElements = container.querySelectorAll(
249
+ "user-query-file-preview, .file-preview, .attachment-preview, [data-testid='file-preview']",
250
+ );
251
+ const fileNames = [];
252
+ fileElements.forEach((fe) => {
253
+ const name = (fe.textContent || "").trim();
254
+ if (name && !fileNames.includes(name)) fileNames.push(name);
255
+ });
256
+
257
+ const userQuery = container.querySelector("user-query") || container;
258
+ const clone = userQuery.cloneNode(true);
259
+ clone
260
+ .querySelectorAll(
261
+ "user-query-file-preview, .file-preview, .attachment-preview, button, .edit-button, model-response",
262
+ )
263
+ .forEach((el) => el.remove());
264
+ const queryText = (
265
+ clone.innerText !== undefined
266
+ ? clone.innerText
267
+ : clone.textContent || ""
268
+ )
269
+ .replace(/^You said\s*/i, "")
270
+ .trim();
271
+
272
+ if (fileNames.length > 0) {
273
+ domTurns.push({
274
+ turnId: rawId,
275
+ queryText,
276
+ domIndex: idx,
277
+ totalDom: parentContainers.length,
278
+ fileNames,
279
+ });
280
+ }
281
+ });
282
+ } else {
283
+ const userQueries = document.querySelectorAll("user-query");
284
+ userQueries.forEach((uq, idx) => {
285
+ const fileElements = uq.querySelectorAll(
286
+ "user-query-file-preview, .file-preview, .attachment-preview, [data-testid='file-preview']",
287
+ );
288
+ const fileNames = [];
289
+ fileElements.forEach((fe) => {
290
+ const name = (fe.textContent || "").trim();
291
+ if (name && !fileNames.includes(name)) fileNames.push(name);
292
+ });
293
+
294
+ const clone = uq.cloneNode(true);
295
+ clone
296
+ .querySelectorAll(
297
+ "user-query-file-preview, .file-preview, .attachment-preview, button, .edit-button",
298
+ )
299
+ .forEach((el) => el.remove());
300
+ const queryText = (
301
+ clone.innerText !== undefined
302
+ ? clone.innerText
303
+ : clone.textContent || ""
304
+ )
305
+ .replace(/^You said\s*/i, "")
306
+ .trim();
307
+
308
+ if (fileNames.length > 0) {
309
+ domTurns.push({
310
+ domIndex: idx,
311
+ queryText,
312
+ totalDom: userQueries.length,
313
+ fileNames,
314
+ });
315
+ }
316
+ });
317
+ }
318
+
319
+ if (domTurns.length === 0) return messages;
320
+
321
+ const userMessages = messages.filter((m) => m.role === "User");
322
+
323
+ for (const domTurn of domTurns) {
324
+ let matchedMsg = null;
325
+
326
+ // 1. Match by exact turnId if present
327
+ if (domTurn.turnId) {
328
+ matchedMsg = userMessages.find((m) => m.turnId === domTurn.turnId);
329
+ }
330
+
331
+ // 2. Fallback: match by queryText if present and non-empty
332
+ if (!matchedMsg && domTurn.queryText) {
333
+ matchedMsg = userMessages.find(
334
+ (m) =>
335
+ m.content &&
336
+ (m.content.includes(domTurn.queryText) ||
337
+ domTurn.queryText.includes(m.content)),
338
+ );
339
+ }
340
+
341
+ // 3. Fallback: match by trailing turn ordinal (mounted DOM elements align with latest turns)
342
+ if (!matchedMsg && typeof domTurn.domIndex === "number") {
343
+ const offset = userMessages.length - domTurn.totalDom;
344
+ const targetIndex =
345
+ offset >= 0 ? offset + domTurn.domIndex : domTurn.domIndex;
346
+ matchedMsg = userMessages[targetIndex];
347
+ }
348
+
349
+ if (matchedMsg) {
350
+ const attachBlock =
351
+ `\n\n**Attachments:**\n` +
352
+ domTurn.fileNames.map((fn) => `- ${fn}`).join("\n");
353
+ if (!matchedMsg.content.includes("**Attachments:**")) {
354
+ matchedMsg.content = matchedMsg.content
355
+ ? `${matchedMsg.content}${attachBlock}`
356
+ : `**Attachments:**\n` +
357
+ domTurn.fileNames.map((fn) => `- ${fn}`).join("\n");
358
+ }
359
+ }
360
+ }
361
+ } catch (e) {
362
+ console.warn(
363
+ "[Gemini Parser] Failed to enrich messages with DOM attachments:",
364
+ e,
365
+ );
366
+ } finally {
367
+ // Clean internal turnId tracking before returning messages
368
+ for (const m of messages) {
369
+ delete m.turnId;
370
+ }
371
+ }
372
+
373
+ return messages;
374
+ }
375
+
376
+ formatApiResult(allItems, currentUrl, options = {}) {
377
+ if (!Array.isArray(allItems) || allItems.length === 0) {
378
+ return null;
379
+ }
380
+
244
381
  const messages = this.convertApiItemsToMessages(allItems, options);
382
+ this.enrichMessagesWithDomAttachments(messages);
383
+
245
384
  let title = this.extractTitleFromPage();
246
385
  if (!title || title === "Gemini Conversation") {
247
386
  const firstUserMsg = messages.find((m) => m.role === "User");
@@ -296,17 +435,28 @@ export class GeminiParser extends ChatParser {
296
435
  return null;
297
436
  }
298
437
 
438
+ extractTurnId(item) {
439
+ try {
440
+ const raw = item[0]?.[1] || item[1]?.[1] || "";
441
+ return String(raw).replace(/^r_/, "");
442
+ } catch {
443
+ return "";
444
+ }
445
+ }
446
+
299
447
  convertApiItemsToMessages(items, options = {}) {
300
448
  const messages = [];
301
449
 
302
450
  for (const item of items) {
303
451
  if (!Array.isArray(item)) continue;
304
452
 
453
+ const turnId = this.extractTurnId(item);
305
454
  const userText = this.findUserTextInApiItem(item);
306
455
  if (userText) {
307
456
  messages.push({
308
457
  role: "User",
309
458
  content: userText.trim(),
459
+ turnId,
310
460
  });
311
461
  }
312
462
 
@@ -314,7 +464,8 @@ export class GeminiParser extends ChatParser {
314
464
  if (modelText) {
315
465
  messages.push({
316
466
  role: "Model",
317
- content: modelText.trim(),
467
+ content: normalizeLatexMath(modelText.trim()),
468
+ turnId,
318
469
  });
319
470
  }
320
471
  }
@@ -324,6 +475,7 @@ export class GeminiParser extends ChatParser {
324
475
 
325
476
  findUserTextInApiItem(item) {
326
477
  try {
478
+ if (typeof item[2]?.[0]?.[0] === "string") return item[2][0][0];
327
479
  if (typeof item[2]?.[0] === "string") return item[2][0];
328
480
  if (typeof item[1]?.[0] === "string" && !Array.isArray(item[1][0]))
329
481
  return item[1][0];
@@ -336,6 +488,25 @@ export class GeminiParser extends ChatParser {
336
488
 
337
489
  findModelTextInApiItem(item) {
338
490
  try {
491
+ // 1. Candidate responses in item[3]
492
+ if (Array.isArray(item[3])) {
493
+ const candidates = Array.isArray(item[3][0]) ? item[3][0] : item[3];
494
+ for (const cand of candidates) {
495
+ if (!Array.isArray(cand)) continue;
496
+ // Shape: ["rc_...", ["markdown text", ...], ...]
497
+ if (Array.isArray(cand[1]) && typeof cand[1][0] === "string") {
498
+ return cand[1][0];
499
+ }
500
+ if (typeof cand[1] === "string") {
501
+ return cand[1];
502
+ }
503
+ if (typeof cand[0] === "string" && cand[0].length > 50) {
504
+ return cand[0];
505
+ }
506
+ }
507
+ }
508
+
509
+ // 2. Fallback candidate in item[1]
339
510
  if (Array.isArray(item[1])) {
340
511
  const candidate = item[1][0];
341
512
  if (typeof candidate === "string") return candidate;
package/ai/perplexity.js CHANGED
@@ -1,5 +1,191 @@
1
1
  import { ChatParser } from "./base.js";
2
2
  import { convertToMarkdown } from "../utils/html-to-markdown.js";
3
+ import { normalizeLatexMath } from "../utils/latex-math.js";
4
+
5
+ export function getThreadSlug(url) {
6
+ if (!url || typeof url !== "string") return null;
7
+ const match = url.match(/\/search\/([a-zA-Z0-9_-]+)/);
8
+ if (match) return match[1];
9
+ const matchPage = url.match(/\/page\/([a-zA-Z0-9_-]+)/);
10
+ if (matchPage) return matchPage[1];
11
+ return null;
12
+ }
13
+
14
+ export function extractAssistantAnswer(entry) {
15
+ if (!entry) return "";
16
+ // 1. Check blocks -> plan_block.goals for markdown answer
17
+ if (Array.isArray(entry.blocks)) {
18
+ for (const b of entry.blocks) {
19
+ if (b.intended_usage === "plan" && b.plan_block?.goals) {
20
+ for (const g of b.plan_block.goals) {
21
+ if (g.description && g.description.length > 100) {
22
+ return g.description.trim();
23
+ }
24
+ }
25
+ }
26
+ if (b.answer_block?.answer) {
27
+ return b.answer_block.answer.trim();
28
+ }
29
+ if (b.markdown_block?.markdown) {
30
+ return b.markdown_block.markdown.trim();
31
+ }
32
+ }
33
+ }
34
+ // 2. Check direct answer or text fields
35
+ if (entry.answer && typeof entry.answer === "string") {
36
+ return entry.answer.trim();
37
+ }
38
+ if (entry.text && typeof entry.text === "string") {
39
+ try {
40
+ const parsed = JSON.parse(entry.text);
41
+ if (parsed.answer) return parsed.answer.trim();
42
+ } catch {
43
+ return entry.text.trim();
44
+ }
45
+ }
46
+ return "";
47
+ }
48
+
49
+ export function formatApiResult(threadData, currentUrl, fallbackTitle) {
50
+ const title =
51
+ threadData.thread_title ||
52
+ threadData.first_entry?.query_str ||
53
+ fallbackTitle ||
54
+ "Perplexity Search";
55
+
56
+ const allEntries = [];
57
+ if (threadData.first_entry) {
58
+ allEntries.push(threadData.first_entry);
59
+ }
60
+ if (Array.isArray(threadData.entries)) {
61
+ allEntries.push(...threadData.entries);
62
+ }
63
+ if (threadData.latest_entry) {
64
+ allEntries.push(threadData.latest_entry);
65
+ }
66
+
67
+ // Deduplicate entries by uuid / backend_uuid
68
+ const seenUuids = new Set();
69
+ const uniqueEntries = [];
70
+ for (const entry of allEntries) {
71
+ const id = entry.uuid || entry.backend_uuid || entry.query_str;
72
+ if (id && !seenUuids.has(id)) {
73
+ seenUuids.add(id);
74
+ uniqueEntries.push(entry);
75
+ }
76
+ }
77
+
78
+ const messages = [];
79
+ for (const entry of uniqueEntries) {
80
+ const prompt = (entry.query_str || "").trim();
81
+ const timestamp =
82
+ entry.entry_created_datetime || entry.updated_datetime || undefined;
83
+
84
+ if (prompt) {
85
+ const userMsg = { role: "User", content: prompt };
86
+ if (timestamp) userMsg.timestamp = timestamp;
87
+ messages.push(userMsg);
88
+ }
89
+
90
+ const rawAnswer = extractAssistantAnswer(entry);
91
+ if (rawAnswer) {
92
+ const normalizedAnswer = normalizeLatexMath(rawAnswer);
93
+ const assistantMsg = { role: "Perplexity", content: normalizedAnswer };
94
+ if (timestamp) assistantMsg.timestamp = timestamp;
95
+ messages.push(assistantMsg);
96
+ }
97
+ }
98
+
99
+ const effectiveUrl =
100
+ currentUrl ||
101
+ (threadData.first_entry?.thread_url_slug
102
+ ? `https://www.perplexity.ai/search/${threadData.first_entry.thread_url_slug}`
103
+ : "");
104
+
105
+ const metadata = {
106
+ Source: "Perplexity",
107
+ Date: new Date().toLocaleString(),
108
+ Link: effectiveUrl,
109
+ Method: "API",
110
+ };
111
+
112
+ return { title, messages, url: effectiveUrl, metadata };
113
+ }
114
+
115
+ export async function getPerplexityAccount() {
116
+ if (typeof window === "undefined") return null;
117
+
118
+ const isUuid = (str) =>
119
+ typeof str === "string" &&
120
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
121
+ str.trim(),
122
+ );
123
+
124
+ // 1. Check sessionStorage ('pplx-active-account')
125
+ try {
126
+ const sessionAcc = window.sessionStorage?.getItem("pplx-active-account");
127
+ if (sessionAcc && isUuid(sessionAcc)) return sessionAcc.trim();
128
+ } catch {
129
+ // Ignore storage errors
130
+ }
131
+
132
+ // 2. Check localStorage ('pplx-last-active-account')
133
+ try {
134
+ const localAcc = window.localStorage?.getItem("pplx-last-active-account");
135
+ if (localAcc && isUuid(localAcc)) return localAcc.trim();
136
+ } catch {
137
+ // Ignore storage errors
138
+ }
139
+
140
+ // 3. Scan keys in sessionStorage and localStorage for pplx account UUIDs
141
+ try {
142
+ for (const storage of [window.sessionStorage, window.localStorage]) {
143
+ if (!storage) continue;
144
+ for (let i = 0; i < storage.length; i++) {
145
+ const key = storage.key(i);
146
+ if (key && (key.includes("pplx") || key.includes("account"))) {
147
+ const val = storage.getItem(key);
148
+ if (val && isUuid(val)) return val.trim();
149
+ }
150
+ }
151
+ }
152
+ } catch {
153
+ // Ignore storage scanning errors
154
+ }
155
+
156
+ // 4. Check document.cookie if available
157
+ try {
158
+ const match = document.cookie?.match(
159
+ /(?:pplx-last-active-account|pplx-active-account)=([0-9a-f-]{36})/i,
160
+ );
161
+ if (match && isUuid(match[1])) return match[1].trim();
162
+ } catch {
163
+ // Ignore cookie errors
164
+ }
165
+
166
+ // 5. Fallback: query https://www.perplexity.ai/api/auth/linked-accounts using session cookies
167
+ try {
168
+ if (typeof fetch === "function") {
169
+ const res = await fetch(
170
+ "https://www.perplexity.ai/api/auth/linked-accounts",
171
+ {
172
+ method: "GET",
173
+ credentials: "include",
174
+ headers: { Accept: "application/json" },
175
+ },
176
+ );
177
+ if (res.ok) {
178
+ const data = await res.json();
179
+ const accountId = data?.accounts?.[0]?.user_id;
180
+ if (accountId && isUuid(accountId)) return accountId.trim();
181
+ }
182
+ }
183
+ } catch {
184
+ // Ignore linked-accounts network error
185
+ }
186
+
187
+ return null;
188
+ }
3
189
 
4
190
  export class PerplexityParser extends ChatParser {
5
191
  name = "Perplexity";
@@ -7,7 +193,38 @@ export class PerplexityParser extends ChatParser {
7
193
  return url.includes("perplexity.ai");
8
194
  }
9
195
 
10
- async parse() {
196
+ async fetchThread(slug) {
197
+ const accountId = await getPerplexityAccount();
198
+ const url = `https://www.perplexity.ai/rest/thread/${slug}?with_parent_info=true&with_schematized_response=true&version=2.18&source=default&limit=100&offset=0&from_first=true`;
199
+ const headers = {
200
+ Accept: "application/json",
201
+ "x-app-apiclient": "default",
202
+ "x-app-apiversion": "2.18",
203
+ };
204
+ if (accountId) {
205
+ headers["x-pplx-account"] = accountId;
206
+ }
207
+
208
+ const response = await fetch(url, {
209
+ method: "GET",
210
+ credentials: "include",
211
+ headers,
212
+ });
213
+ if (!response.ok) {
214
+ throw new Error(
215
+ `Perplexity API request failed with status ${response.status}`,
216
+ );
217
+ }
218
+ return response.json();
219
+ }
220
+
221
+ async parse(options = {}) {
222
+ const parserMode = options.parserMode || "prefer_api";
223
+ const currentUrl =
224
+ typeof window !== "undefined" && window.location
225
+ ? window.location.href || ""
226
+ : "";
227
+
11
228
  const rawTitle =
12
229
  document.querySelector(".share-title-section h1")?.textContent ||
13
230
  document.querySelector("h1")?.textContent ||
@@ -15,6 +232,25 @@ export class PerplexityParser extends ChatParser {
15
232
  "Perplexity Search";
16
233
  const title = rawTitle.trim().replace(/\s+/g, " ");
17
234
 
235
+ // 1. Attempt API-first extraction when in prefer_api mode
236
+ if (parserMode !== "prefer_dom") {
237
+ const slug = getThreadSlug(currentUrl);
238
+ if (slug) {
239
+ try {
240
+ const apiData = await this.fetchThread(slug);
241
+ if (apiData && (apiData.entries?.length || apiData.first_entry)) {
242
+ return formatApiResult(apiData, currentUrl, title);
243
+ }
244
+ } catch (err) {
245
+ console.warn(
246
+ "[AI Exporter] Perplexity API fetch failed, falling back to DOM:",
247
+ err,
248
+ );
249
+ }
250
+ }
251
+ }
252
+
253
+ // 2. DOM extraction fallback
18
254
  const messages = [];
19
255
 
20
256
  // Perplexity Container
@@ -23,6 +259,7 @@ export class PerplexityParser extends ChatParser {
23
259
 
24
260
  // Candidates for User Messages
25
261
  const userSelectors = [
262
+ ".group\\/user-bubble",
26
263
  "h1.group\\/query",
27
264
  ".group\\/query",
28
265
  ".whitespace-pre-line.text-pretty",
@@ -40,43 +277,58 @@ export class PerplexityParser extends ChatParser {
40
277
  const elements = threadContainer.querySelectorAll(selectorString);
41
278
 
42
279
  // Helper to determine role
43
- const getRole = (el) => {
280
+ const isUser = (el) => {
44
281
  for (const s of userSelectors) {
45
- if (el.matches(s)) return "User";
282
+ if (el.matches(s)) return true;
46
283
  }
284
+ return false;
285
+ };
286
+
287
+ const isAssistant = (el) => {
47
288
  for (const s of assistantSelectors) {
48
- if (el.matches(s)) return "Perplexity";
289
+ if (el.matches(s)) return true;
49
290
  }
50
- return "Unknown";
291
+ return false;
51
292
  };
52
293
 
53
- const seenText = new Set();
54
-
55
294
  elements.forEach((el) => {
56
- // Perplexity nests things. Avoid duplicates if we selected a parent and a child.
57
- // Also avoid "Related" section queries if possible (usually separate container, check parents?)
58
-
59
295
  // Check if inside "related" or "sources"
60
296
  if (el.closest('[class*="related"], [class*="sources"]')) return;
61
297
 
62
- const role = getRole(el);
63
- let text = convertToMarkdown(el);
64
- text = text.trim();
298
+ if (isUser(el)) {
299
+ if (el.parentElement?.closest(".group\\/user-bubble, .group\\/query")) {
300
+ return;
301
+ }
302
+
303
+ // Extract timestamp if present in bubble
304
+ const timeEl = el.querySelector(
305
+ ".text-tertiary, [class*='text-tertiary']",
306
+ );
307
+ const timestamp = timeEl ? timeEl.textContent.trim() : undefined;
65
308
 
66
- if (!text || seenText.has(text)) return;
309
+ // Clean user text container
310
+ const textEl = el.querySelector(".whitespace-pre-line") || el;
311
+ const text = convertToMarkdown(textEl).trim();
312
+ if (!text) return;
67
313
 
68
- // Perplexity specific cleanup
69
- // Remove "Sources" label text if it gets captured?
70
- // Usually .prose contains the markdown answer.
314
+ const msgObj = { role: "User", content: text };
315
+ if (timestamp) {
316
+ msgObj.timestamp = timestamp;
317
+ }
318
+ messages.push(msgObj);
319
+ } else if (isAssistant(el)) {
320
+ // Avoid duplicate nested assistant containers (e.g. .prose inside div[id^="markdown-content-"])
321
+ if (el.parentElement?.closest('div[id^="markdown-content-"], .prose')) {
322
+ return;
323
+ }
71
324
 
72
- seenText.add(text);
73
- messages.push({ role, content: text });
325
+ const text = convertToMarkdown(el).trim();
326
+ if (!text) return;
327
+
328
+ messages.push({ role: "Perplexity", content: text });
329
+ }
74
330
  });
75
331
 
76
- const currentUrl =
77
- typeof window !== "undefined" && window.location
78
- ? window.location.href || ""
79
- : "";
80
332
  const metadata = {
81
333
  Source: "Perplexity",
82
334
  Date: new Date().toLocaleString(),
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "decant-core",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Shared AI chat platform parsers and detection for Covai browser extensions.",
5
5
  "type": "module",
6
+ "engines": {
7
+ "node": ">=26.0.0"
8
+ },
6
9
  "main": "ai/index.js",
7
10
  "exports": {
8
11
  ".": "./ai/index.js",