decant-core 1.4.0 → 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/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.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",