decant-core 1.3.1 → 1.5.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/claude.js CHANGED
@@ -70,6 +70,39 @@ function getCurrentBranch(data) {
70
70
  return branch;
71
71
  }
72
72
 
73
+ const EXT_TO_LANG = {
74
+ py: "python",
75
+ js: "javascript",
76
+ jsx: "jsx",
77
+ ts: "typescript",
78
+ tsx: "tsx",
79
+ md: "markdown",
80
+ html: "html",
81
+ css: "css",
82
+ json: "json",
83
+ sh: "bash",
84
+ bash: "bash",
85
+ yml: "yaml",
86
+ yaml: "yaml",
87
+ sql: "sql",
88
+ java: "java",
89
+ rb: "ruby",
90
+ go: "go",
91
+ rs: "rust",
92
+ c: "c",
93
+ cpp: "cpp",
94
+ txt: "text",
95
+ };
96
+
97
+ const MIME_TO_LANG = {
98
+ "application/vnd.ant.react": "jsx",
99
+ "text/html": "html",
100
+ "image/svg+xml": "svg",
101
+ "application/vnd.ant.mermaid": "mermaid",
102
+ "text/markdown": "markdown",
103
+ "application/vnd.ant.code": "text",
104
+ };
105
+
73
106
  function extractArtifactsFromText(text) {
74
107
  const artifactRegex = /<antArtifact[^>]*>([\s\S]*?)<\/antArtifact>/g;
75
108
  const artifacts = [];
@@ -90,53 +123,139 @@ function extractArtifactsFromText(text) {
90
123
  return artifacts;
91
124
  }
92
125
 
93
- function extractArtifacts(message) {
126
+ function collectArtifacts(messages) {
127
+ const artifacts = new Map();
128
+ for (const m of messages) {
129
+ if (!Array.isArray(m?.content)) continue;
130
+ for (const block of m.content) {
131
+ if (block.type !== "tool_use" || block.name !== "artifacts") continue;
132
+ const input = block.input || {};
133
+ const id = input.id || "__artifact__";
134
+ let a = artifacts.get(id);
135
+ if (!a) {
136
+ a = { content: "" };
137
+ artifacts.set(id, a);
138
+ }
139
+
140
+ if (input.command === "update") {
141
+ if (
142
+ typeof input.old_str === "string" &&
143
+ typeof input.new_str === "string"
144
+ ) {
145
+ if (a.content.includes(input.old_str)) {
146
+ a.content = a.content.replace(input.old_str, () => input.new_str);
147
+ } else {
148
+ console.warn(
149
+ `[AI Exporter] Artifact "${a.title || id}": update could not be applied (source text not found).`,
150
+ );
151
+ }
152
+ }
153
+ } else if (typeof input.content === "string") {
154
+ a.content = input.content;
155
+ }
156
+
157
+ if (input.title) a.title = input.title;
158
+ if (input.type) a.type = input.type;
159
+ if (input.language) a.language = input.language;
160
+ a.lastBlock = block;
161
+ if (input.version_uuid) a.lastVersionUuid = input.version_uuid;
162
+ }
163
+ }
164
+ return artifacts;
165
+ }
166
+
167
+ function extractArtifacts(message, foldedArtifacts = new Map()) {
94
168
  const artifacts = [];
95
169
  if (message.content && Array.isArray(message.content)) {
96
170
  for (const content of message.content) {
97
- if (
98
- content.type === "tool_use" &&
99
- (content.name === "artifacts" || content.name === "create_file") &&
100
- content.display_content
101
- ) {
102
- const displayContent = content.display_content;
103
- if (displayContent.type === "code_block" && displayContent.code) {
104
- const filename = displayContent.filename || "artifact";
105
- const title = filename
106
- .split("/")
107
- .pop()
108
- .replace(/\.[^.]+$/, "");
109
- artifacts.push({
110
- title: title || "Artifact",
111
- language: displayContent.language || "text",
112
- content: displayContent.code.trim(),
113
- });
114
- } else if (
115
- displayContent.type === "json_block" &&
116
- displayContent.json_block
117
- ) {
118
- try {
119
- const data = JSON.parse(displayContent.json_block);
120
- if (data.filename) {
121
- const filename = data.filename;
122
- const title = filename
123
- .split("/")
124
- .pop()
125
- .replace(/\.[^.]+$/, "");
126
- artifacts.push({
127
- title: title || "Artifact",
128
- language: data.language || "text",
129
- content: (data.code || "").trim(),
130
- });
171
+ if (content.type === "tool_use") {
172
+ const input = content.input || {};
173
+ if (content.name === "artifacts") {
174
+ const id = input.id || "__artifact__";
175
+ const folded = foldedArtifacts.get(id);
176
+
177
+ // Only emit at the final edit block for this artifact
178
+ if (folded && folded.lastBlock && folded.lastBlock !== content) {
179
+ continue;
180
+ }
181
+ if (
182
+ folded &&
183
+ folded.lastVersionUuid &&
184
+ input.version_uuid &&
185
+ input.version_uuid !== folded.lastVersionUuid
186
+ ) {
187
+ continue;
188
+ }
189
+
190
+ const title = input.title || (folded && folded.title) || "Artifact";
191
+ const lang =
192
+ input.language ||
193
+ (folded && folded.language) ||
194
+ MIME_TO_LANG[input.type || (folded && folded.type)] ||
195
+ "text";
196
+ const code =
197
+ (folded && folded.content) || input.content || input.new_str || "";
198
+ if (code) {
199
+ artifacts.push({
200
+ title,
201
+ language: lang,
202
+ content: code.trim(),
203
+ });
204
+ }
205
+ } else if (content.name === "create_file") {
206
+ let code = "";
207
+ let filename = "file";
208
+ let lang = "";
209
+
210
+ if (typeof input.file_text === "string" && input.file_text) {
211
+ code = input.file_text.trim();
212
+ filename = String(input.path || "file")
213
+ .split("/")
214
+ .pop();
215
+ const ext = filename.includes(".")
216
+ ? filename.split(".").pop().toLowerCase()
217
+ : "";
218
+ lang = EXT_TO_LANG[ext] || ext || "text";
219
+ } else if (content.display_content) {
220
+ const displayContent = content.display_content;
221
+ if (displayContent.type === "code_block" && displayContent.code) {
222
+ filename = displayContent.filename || "artifact";
223
+ code = displayContent.code.trim();
224
+ lang = displayContent.language || "text";
225
+ } else if (
226
+ displayContent.type === "json_block" &&
227
+ displayContent.json_block
228
+ ) {
229
+ try {
230
+ const data = JSON.parse(displayContent.json_block);
231
+ if (data.filename) {
232
+ filename = data.filename;
233
+ code = (data.code || "").trim();
234
+ lang = data.language || "text";
235
+ }
236
+ } catch (e) {
237
+ console.warn(
238
+ "[AI Exporter] Failed to parse tool use artifact json:",
239
+ e,
240
+ );
241
+ }
131
242
  }
132
- } catch (e) {
133
- console.warn(
134
- "[AI Exporter] Failed to parse tool use artifact json:",
135
- e,
136
- );
243
+ }
244
+
245
+ if (code) {
246
+ const title = filename
247
+ .split("/")
248
+ .pop()
249
+ .replace(/\.[^.]+$/, "");
250
+ artifacts.push({
251
+ title: title || "Artifact",
252
+ language: lang,
253
+ content: code,
254
+ });
137
255
  }
138
256
  }
139
257
  }
258
+
140
259
  if (content.text) {
141
260
  artifacts.push(...extractArtifactsFromText(content.text));
142
261
  }
@@ -148,6 +267,68 @@ function extractArtifacts(message) {
148
267
  return artifacts;
149
268
  }
150
269
 
270
+ function unrollInteractiveElements(root, doc) {
271
+ if (!root || !doc) return;
272
+
273
+ // Group slide titles by their common card container
274
+ const titleEls = Array.from(root.querySelectorAll(".text-title"));
275
+ if (titleEls.length > 0) {
276
+ const containerMap = new Map();
277
+ for (const titleEl of titleEls) {
278
+ let container = titleEl.parentElement;
279
+ while (container && container !== root) {
280
+ if (
281
+ container.classList.contains("@container") ||
282
+ container.classList.contains("bg-surface-2") ||
283
+ container.querySelector(
284
+ '[aria-label*="Go to step"], [aria-current="step"]',
285
+ )
286
+ ) {
287
+ break;
288
+ }
289
+ container = container.parentElement;
290
+ }
291
+ if (container && container !== root) {
292
+ if (!containerMap.has(container)) {
293
+ containerMap.set(container, []);
294
+ }
295
+ containerMap.get(container).push(titleEl);
296
+ }
297
+ }
298
+
299
+ for (const [container, titles] of containerMap.entries()) {
300
+ const slides = [];
301
+ titles.forEach((titleEl, idx) => {
302
+ const title = titleEl.textContent.trim();
303
+ const bodyEl =
304
+ titleEl.nextElementSibling ||
305
+ titleEl.parentElement.querySelector(".text-body");
306
+ const body = bodyEl ? bodyEl.textContent.trim() : "";
307
+ if (title) slides.push({ index: idx + 1, title, body });
308
+ });
309
+
310
+ if (slides.length > 0) {
311
+ const replacement = doc.createElement("div");
312
+ replacement.className = "unrolled-interactive-steps";
313
+ slides.forEach((slide) => {
314
+ const h3 = doc.createElement("h3");
315
+ h3.textContent = `${slide.index}. ${slide.title}`;
316
+ replacement.appendChild(h3);
317
+ if (slide.body) {
318
+ const p = doc.createElement("p");
319
+ p.textContent = slide.body;
320
+ replacement.appendChild(p);
321
+ }
322
+ });
323
+ container.replaceWith(replacement);
324
+ }
325
+ }
326
+ }
327
+
328
+ // Remove any remaining buttons
329
+ root.querySelectorAll("button").forEach((btn) => btn.remove());
330
+ }
331
+
151
332
  export class ClaudeParser extends ChatParser {
152
333
  name = "Claude";
153
334
  constructor() {
@@ -189,6 +370,31 @@ export class ClaudeParser extends ChatParser {
189
370
  }
190
371
 
191
372
  const branch = getCurrentBranch(data);
373
+ const foldedArtifacts = collectArtifacts(branch);
374
+
375
+ const toolResultMap = new Map();
376
+ for (const msg of branch) {
377
+ if (Array.isArray(msg?.content)) {
378
+ for (const block of msg.content) {
379
+ if (block.type === "tool_result") {
380
+ const toolUseId = block.tool_use_id || block.id;
381
+ let answers = block.toolUseResult?.answers || {};
382
+ if (
383
+ (!answers || Object.keys(answers).length === 0) &&
384
+ typeof block.content === "string"
385
+ ) {
386
+ try {
387
+ const parsed = JSON.parse(block.content);
388
+ answers = parsed.answers || parsed;
389
+ } catch {
390
+ answers = { result: block.content };
391
+ }
392
+ }
393
+ toolResultMap.set(toolUseId, answers);
394
+ }
395
+ }
396
+ }
397
+ }
192
398
 
193
399
  const convTitle = data.name || title;
194
400
 
@@ -209,6 +415,47 @@ export class ClaudeParser extends ChatParser {
209
415
  if (cleanText) {
210
416
  contentStr += `${cleanText}\n\n`;
211
417
  }
418
+ } else if (block.type === "tool_use") {
419
+ const input = block.input || {};
420
+ if (
421
+ block.name === "visualize:show_widget" &&
422
+ input.widget_code
423
+ ) {
424
+ const widgetTitle = input.title || "Interactive Widget";
425
+ contentStr += `> **Interactive Widget: ${widgetTitle}**\n\n\`\`\`jsx\n${input.widget_code.trim()}\n\`\`\`\n\n`;
426
+ } else if (block.name === "repl" && input.code) {
427
+ contentStr += `**Analyzed data**\n\n\`\`\`javascript\n${input.code.trim()}\n\`\`\`\n\n`;
428
+ } else if (
429
+ block.name === "AskUserQuestion" &&
430
+ Array.isArray(input.questions) &&
431
+ input.questions.length > 0
432
+ ) {
433
+ const qCount = input.questions.length;
434
+ const countLabel =
435
+ qCount === 1
436
+ ? "Asked 1 question"
437
+ : `Asked ${qCount} questions`;
438
+ let qStr = `> **${countLabel}:**\n`;
439
+ const answers = toolResultMap.get(block.id) || {};
440
+ for (const q of input.questions) {
441
+ const qText = q.question || q.text || "";
442
+ const ans = answers[qText];
443
+ let ansStr = "";
444
+ if (typeof ans === "string") {
445
+ ansStr = ans;
446
+ } else if (Array.isArray(ans)) {
447
+ ansStr = ans.join(", ");
448
+ } else if (ans && typeof ans === "object") {
449
+ ansStr = JSON.stringify(ans);
450
+ } else if (ans != null) {
451
+ ansStr = String(ans);
452
+ }
453
+ qStr += ansStr
454
+ ? `> - **${qText}** — ${ansStr}\n`
455
+ : `> - ${qText}\n`;
456
+ }
457
+ contentStr += `${qStr}\n`;
458
+ }
212
459
  }
213
460
  }
214
461
  } else if (message.text) {
@@ -245,13 +492,37 @@ export class ClaudeParser extends ChatParser {
245
492
  }
246
493
  }
247
494
 
495
+ // Append files (images/documents)
496
+ if (message.files && Array.isArray(message.files)) {
497
+ for (const file of message.files) {
498
+ const name = file.file_name || "file";
499
+ if (
500
+ file.file_kind === "image" &&
501
+ (file.preview_url || file.preview_asset?.url)
502
+ ) {
503
+ const url = file.preview_url || file.preview_asset.url;
504
+ contentStr += `\n\n**Attachment: ${name}**\n\n![${name}](${url})\n\n`;
505
+ } else if (
506
+ file.file_kind === "document" &&
507
+ file.document_asset?.url
508
+ ) {
509
+ const url = file.document_asset.url;
510
+ const pages = file.document_asset.page_count;
511
+ const pageInfo = pages
512
+ ? ` · ${pages} page${pages === 1 ? "" : "s"}`
513
+ : "";
514
+ contentStr += `\n\n**Attachment: [${name}](${url})** _(document${pageInfo})_\n\n`;
515
+ }
516
+ }
517
+ }
518
+
248
519
  contentStr = contentStr.trim();
249
520
  if (contentStr) {
250
521
  messages.push({ role, content: contentStr });
251
522
  }
252
523
 
253
524
  // Extract and push artifacts
254
- const artifacts = extractArtifacts(message);
525
+ const artifacts = extractArtifacts(message, foldedArtifacts);
255
526
  for (const artifact of artifacts) {
256
527
  let artContent = "";
257
528
  const artTitle = artifact.title || "Artifact";
@@ -356,13 +627,10 @@ export class ClaudeParser extends ChatParser {
356
627
  return !overlapsWithError;
357
628
  });
358
629
 
359
- const combined = [...new Set([...strictCandidates, ...validFallbacks])];
360
-
361
- const allElements = combined.sort((a, b) => {
362
- return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING
363
- ? -1
364
- : 1;
365
- });
630
+ const combinedSet = new Set([...strictCandidates, ...validFallbacks]);
631
+ const allElements = Array.from(
632
+ document.querySelectorAll(`${strictSelectors}, ${fallbackSelectors}`),
633
+ ).filter((el) => combinedSet.has(el));
366
634
 
367
635
  const artifactElements = document.querySelectorAll(".artifact-block-cell");
368
636
  const artifactMap = new Map();
@@ -375,7 +643,7 @@ export class ClaudeParser extends ChatParser {
375
643
  if (el.matches('[data-testid="user-message"]')) {
376
644
  role = "User";
377
645
  const clone = el.cloneNode(true);
378
- clone.querySelectorAll("button").forEach((btn) => btn.remove());
646
+ unrollInteractiveElements(clone, el.ownerDocument || document);
379
647
  content = convertToMarkdown(clone);
380
648
  } else if (
381
649
  el.matches(".font-claude-message") ||
@@ -384,7 +652,7 @@ export class ClaudeParser extends ChatParser {
384
652
  ) {
385
653
  role = "Claude";
386
654
  const clone = el.cloneNode(true);
387
- clone.querySelectorAll("button").forEach((btn) => btn.remove());
655
+ unrollInteractiveElements(clone, el.ownerDocument || document);
388
656
  content = convertToMarkdown(clone);
389
657
  } else if (el.matches(".artifact-block-cell")) {
390
658
  role = "Claude Artifact";
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.3.1",
3
+ "version": "1.5.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",