agentlinkops 0.5.0 → 0.5.1
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.mcp.json +8 -0
- package/CHANGELOG.md +15 -1
- package/README.md +2 -1
- package/cli/citation.js +19 -1
- package/cli/client.js +1 -1
- package/cli/doctor.js +19 -0
- package/package.json +9 -1
- package/src/citations/browser/engine.js +196 -0
- package/src/citations/browser/gen/core/providers/_shared/google.js +4 -0
- package/src/citations/browser/gen/core/providers/_shared/rawSourcesDom.js +15 -0
- package/src/citations/browser/gen/core/providers/_shared/resetProviderPage.js +22 -0
- package/src/citations/browser/gen/core/providers/_shared/sourceUtils.js +8 -0
- package/src/citations/browser/gen/core/providers/ai-overview/index.js +70 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/expand.js +59 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/extractSources.js +60 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/pageLifecycle.js +18 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/session.js +62 -0
- package/src/citations/browser/gen/core/providers/chatgpt/index.js +28 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/dismissAuthModal.js +39 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/extractSources.js +66 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/pageLifecycle.js +11 -0
- package/src/citations/browser/gen/core/providers/claude/index.js +16 -0
- package/src/citations/browser/gen/core/providers/claude/lib/extractSources.js +54 -0
- package/src/citations/browser/gen/core/providers/claude/lib/pageLifecycle.js +9 -0
- package/src/citations/browser/gen/core/providers/gemini/index.js +40 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/extractSources.js +65 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/pageLifecycle.js +14 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/session.js +45 -0
- package/src/citations/browser/gen/core/providers/index.js +15 -0
- package/src/citations/browser/gen/core/providers/perplexity/index.js +31 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/extractSources.js +84 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/navigation.js +38 -0
- package/src/citations/browser/gen/core/providers/types.js +0 -0
- package/src/citations/browser/gen/lib/browser/camoufox.js +587 -0
- package/src/citations/browser/gen/lib/browser/display.js +147 -0
- package/src/citations/browser/gen/lib/browser/domOps.js +339 -0
- package/src/citations/browser/gen/lib/browser/humanBehavior.js +121 -0
- package/src/citations/browser/gen/lib/browser/navigate.js +46 -0
- package/src/citations/browser/gen/lib/browser/playwrightCompat.js +321 -0
- package/src/citations/browser/gen/lib/browser/proxy/forwarder.js +251 -0
- package/src/citations/browser/gen/lib/browser/runtimeTypes.js +0 -0
- package/src/citations/browser/gen/lib/extraction/sourceUtils.js +71 -0
- package/src/citations/browser/gen/lib/input/editor/assertNotBlocked.js +54 -0
- package/src/citations/browser/gen/lib/input/editor/clearInput.js +52 -0
- package/src/citations/browser/gen/lib/input/editor/findEditor.js +54 -0
- package/src/citations/browser/gen/lib/input/editor/findSendButton.js +21 -0
- package/src/citations/browser/gen/lib/input/editor/promptInput.js +117 -0
- package/src/citations/browser/gen/lib/input/markdown/converter.js +101 -0
- package/src/citations/browser/gen/lib/input/markdown/toMarkdown.js +14 -0
- package/src/citations/browser/gen/lib/input/response/detectBotPage.js +13 -0
- package/src/citations/browser/gen/lib/input/response/getText.js +10 -0
- package/src/citations/browser/gen/lib/input/response/isGenerating.js +59 -0
- package/src/citations/browser/gen/lib/input/response/waitForFinish.js +105 -0
- package/src/citations/browser/gen/lib/input/sources/findButton.js +41 -0
- package/src/citations/browser/gen/lib/input/sources/openPanel.js +11 -0
- package/src/citations/browser/gen/utils/agent-constants.js +87 -0
- package/src/citations/browser/shims/errors.js +26 -0
- package/src/citations/browser/shims/types.js +6 -0
- package/src/citations/browser/shims/utils.js +32 -0
- package/src/citations/contract.js +9 -2
- package/src/citations/runner.js +21 -5
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { ExternalServiceError } from "../../../../../shims/errors.js";
|
|
2
|
+
import { logger } from "../../../../../shims/utils.js";
|
|
3
|
+
import { navigateWithRetry } from "../../../../lib/browser/navigate.js";
|
|
4
|
+
const GOOGLE_CONSENT_SELECTOR = "button#L2AGLb, button#W0wltc, form[action*='consent.google.com'] button";
|
|
5
|
+
const SEARCH_RESULTS_WAIT_MS = 8e3;
|
|
6
|
+
const warmedPages = /* @__PURE__ */ new WeakSet();
|
|
7
|
+
async function dismissGoogleConsentDialog(page) {
|
|
8
|
+
const consentBtn = page.locator(GOOGLE_CONSENT_SELECTOR).first();
|
|
9
|
+
const visible = await consentBtn.isVisible({ timeout: 2500 }).catch(() => false);
|
|
10
|
+
if (!visible) return;
|
|
11
|
+
await consentBtn.click({ timeout: 4e3 }).catch(() => {
|
|
12
|
+
});
|
|
13
|
+
await page.waitForTimeout(1e3);
|
|
14
|
+
}
|
|
15
|
+
function assertAIOverviewPageNotBlocked(page) {
|
|
16
|
+
const url = page.url();
|
|
17
|
+
if (url.includes("/sorry/")) {
|
|
18
|
+
throw new ExternalServiceError(
|
|
19
|
+
"ai-overview",
|
|
20
|
+
"Google bot detection triggered (sorry page) \u2014 proxy IP blocked",
|
|
21
|
+
429
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
if (url.includes("accounts.google.com")) {
|
|
25
|
+
throw new ExternalServiceError(
|
|
26
|
+
"ai-overview",
|
|
27
|
+
"Google redirected to login page \u2014 session cookie missing or expired",
|
|
28
|
+
401
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function ensureAIOverviewGoogleSession(page) {
|
|
33
|
+
if (warmedPages.has(page)) return;
|
|
34
|
+
logger.log("[ai-overview] warming up Google cookies");
|
|
35
|
+
await navigateWithRetry(page, "https://www.google.com/", {
|
|
36
|
+
waitUntil: "domcontentloaded",
|
|
37
|
+
timeout: 3e4
|
|
38
|
+
});
|
|
39
|
+
assertAIOverviewPageNotBlocked(page);
|
|
40
|
+
await dismissGoogleConsentDialog(page);
|
|
41
|
+
warmedPages.add(page);
|
|
42
|
+
}
|
|
43
|
+
async function waitForAIOverviewSearchResults(page) {
|
|
44
|
+
const deadline = Date.now() + SEARCH_RESULTS_WAIT_MS;
|
|
45
|
+
while (Date.now() < deadline) {
|
|
46
|
+
if (page.url().includes("/search?")) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
assertAIOverviewPageNotBlocked(page);
|
|
50
|
+
await page.waitForTimeout(150);
|
|
51
|
+
}
|
|
52
|
+
throw new ExternalServiceError(
|
|
53
|
+
"ai-overview",
|
|
54
|
+
`Not on search results page after submission (url: ${page.url()})`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
export {
|
|
58
|
+
assertAIOverviewPageNotBlocked,
|
|
59
|
+
dismissGoogleConsentDialog,
|
|
60
|
+
ensureAIOverviewGoogleSession,
|
|
61
|
+
waitForAIOverviewSearchResults
|
|
62
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { extractSourcesFromChatgpt } from "./lib/extractSources.js";
|
|
2
|
+
import { dismissChatgptAuthModal } from "./lib/dismissAuthModal.js";
|
|
3
|
+
import { extractAssistantMarkdown } from "../../../lib/input/markdown/toMarkdown.js";
|
|
4
|
+
import { openSourcesPanel } from "../../../lib/input/sources/openPanel.js";
|
|
5
|
+
import { findSourcesButton } from "../../../lib/input/sources/findButton.js";
|
|
6
|
+
import { waitForAssistantToFinish } from "../../../lib/input/response/waitForFinish.js";
|
|
7
|
+
import { CHATGPT_URL, resetChatgptPage } from "./lib/pageLifecycle.js";
|
|
8
|
+
const chatgptConfig = {
|
|
9
|
+
url: CHATGPT_URL,
|
|
10
|
+
label: "ChatGPT",
|
|
11
|
+
displayName: "ChatGPT",
|
|
12
|
+
waitForResponse: (page) => waitForAssistantToFinish(page, "chatgpt"),
|
|
13
|
+
extractResponse: (page) => extractAssistantMarkdown(page, "chatgpt"),
|
|
14
|
+
beforePromptHook: (page) => dismissChatgptAuthModal(page, { waitForAppearanceMs: 500 }),
|
|
15
|
+
afterTypingHook: (page) => dismissChatgptAuthModal(page, { waitForAppearanceMs: 1500 }),
|
|
16
|
+
beforeSubmitHook: (page) => dismissChatgptAuthModal(page, { waitForAppearanceMs: 1500 }),
|
|
17
|
+
afterSubmitHook: (page) => dismissChatgptAuthModal(page, { waitForAppearanceMs: 1500 }),
|
|
18
|
+
beforeRetryHook: resetChatgptPage,
|
|
19
|
+
extractSources: async (page) => {
|
|
20
|
+
const btn = await findSourcesButton(page);
|
|
21
|
+
if (!btn) return [];
|
|
22
|
+
await openSourcesPanel(page, btn);
|
|
23
|
+
return extractSourcesFromChatgpt(page, btn);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
export {
|
|
27
|
+
chatgptConfig
|
|
28
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { logger } from "../../../../../shims/utils.js";
|
|
2
|
+
import {
|
|
3
|
+
canUseOsLevelInput,
|
|
4
|
+
clickLocatorLikeUser
|
|
5
|
+
} from "../../../../lib/browser/humanBehavior.js";
|
|
6
|
+
const AUTH_DIALOG_SELECTOR = '[role="dialog"][data-state="open"]';
|
|
7
|
+
const AUTH_DIALOG_TEXT_RE = /Thanks for trying ChatGPT|Log in or sign up/i;
|
|
8
|
+
const DISMISS_MODAL_TEXT_RE = /^Stay logged out$/i;
|
|
9
|
+
const MODAL_POLL_INTERVAL_MS = 200;
|
|
10
|
+
async function dismissChatgptAuthModal(page, options = {}) {
|
|
11
|
+
const waitForAppearanceMs = options.waitForAppearanceMs ?? 0;
|
|
12
|
+
const deadline = Date.now() + waitForAppearanceMs;
|
|
13
|
+
do {
|
|
14
|
+
const dialog = page.locator(AUTH_DIALOG_SELECTOR).filter({ hasText: AUTH_DIALOG_TEXT_RE }).first();
|
|
15
|
+
const dialogVisible = await dialog.isVisible().catch(() => false);
|
|
16
|
+
if (!dialogVisible) {
|
|
17
|
+
if (Date.now() >= deadline) return;
|
|
18
|
+
await page.waitForTimeout(MODAL_POLL_INTERVAL_MS);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const dismissTarget = dialog.getByText(DISMISS_MODAL_TEXT_RE).first();
|
|
22
|
+
const dismissVisible = await dismissTarget.isVisible().catch(() => false);
|
|
23
|
+
if (!dismissVisible) return;
|
|
24
|
+
logger.log("[chatgpt] dismissing auth modal via Stay logged out");
|
|
25
|
+
const clicked = await clickLocatorLikeUser(page, dismissTarget, {
|
|
26
|
+
timeout: 5e3
|
|
27
|
+
}).catch(() => false);
|
|
28
|
+
if (!clicked && canUseOsLevelInput(page)) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
await dialog.waitFor({ state: "hidden", timeout: 5e3 }).catch(() => {
|
|
32
|
+
});
|
|
33
|
+
await page.waitForTimeout(300);
|
|
34
|
+
return;
|
|
35
|
+
} while (Date.now() <= deadline);
|
|
36
|
+
}
|
|
37
|
+
export {
|
|
38
|
+
dismissChatgptAuthModal
|
|
39
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildSources,
|
|
3
|
+
clickButtonViaDispatch
|
|
4
|
+
} from "../../_shared/sourceUtils.js";
|
|
5
|
+
const CHATGPT_RAW_SOURCES_DOM_EXTRACTOR = String.raw`(_helpers) => {
|
|
6
|
+
const results = [];
|
|
7
|
+
|
|
8
|
+
const getLeafTexts = (anchor) => {
|
|
9
|
+
const texts = [];
|
|
10
|
+
|
|
11
|
+
for (const el of anchor.querySelectorAll("*")) {
|
|
12
|
+
const text = (el.textContent || "").trim();
|
|
13
|
+
if (!text) continue;
|
|
14
|
+
|
|
15
|
+
if (
|
|
16
|
+
Array.from(el.children).some(
|
|
17
|
+
(child) => (child.textContent || "").trim().length > 0,
|
|
18
|
+
)
|
|
19
|
+
) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
texts.push(text);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return Array.from(new Set(texts));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
for (const anchor of Array.from(
|
|
30
|
+
document.querySelectorAll(
|
|
31
|
+
'ul li > a[target="_blank"][rel*="noopener"][href^="http"]',
|
|
32
|
+
),
|
|
33
|
+
)) {
|
|
34
|
+
if (!(anchor instanceof HTMLAnchorElement)) continue;
|
|
35
|
+
|
|
36
|
+
const rawHref = anchor.href.replace(/#.*$/, "");
|
|
37
|
+
if (!rawHref) continue;
|
|
38
|
+
|
|
39
|
+
const texts = getLeafTexts(anchor);
|
|
40
|
+
if (texts.length === 0) continue;
|
|
41
|
+
const sorted = [...texts].sort((a, b) => a.length - b.length);
|
|
42
|
+
const citedText = sorted[sorted.length - 1] || "";
|
|
43
|
+
const title =
|
|
44
|
+
sorted.length >= 2 ? sorted[sorted.length - 2] || "" : sorted[0] || "";
|
|
45
|
+
|
|
46
|
+
results.push({
|
|
47
|
+
rawHref,
|
|
48
|
+
title,
|
|
49
|
+
citedText,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return results;
|
|
54
|
+
}`;
|
|
55
|
+
async function extractSourcesFromChatgpt(page, sourcesButton) {
|
|
56
|
+
const rawSources = await page.runDomOp("raw-sources", {
|
|
57
|
+
provider: "chatgpt"
|
|
58
|
+
});
|
|
59
|
+
if (!await clickButtonViaDispatch(page, sourcesButton)) return [];
|
|
60
|
+
await page.waitForTimeout(300);
|
|
61
|
+
return buildSources(rawSources, { provider: "chatgpt" });
|
|
62
|
+
}
|
|
63
|
+
export {
|
|
64
|
+
CHATGPT_RAW_SOURCES_DOM_EXTRACTOR,
|
|
65
|
+
extractSourcesFromChatgpt
|
|
66
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { resetProviderPage } from "../../_shared/resetProviderPage.js";
|
|
2
|
+
import { dismissChatgptAuthModal } from "./dismissAuthModal.js";
|
|
3
|
+
const CHATGPT_URL = "https://chatgpt.com/";
|
|
4
|
+
async function resetChatgptPage(page) {
|
|
5
|
+
await resetProviderPage(page, "chatgpt", CHATGPT_URL);
|
|
6
|
+
await dismissChatgptAuthModal(page, { waitForAppearanceMs: 1e3 });
|
|
7
|
+
}
|
|
8
|
+
export {
|
|
9
|
+
CHATGPT_URL,
|
|
10
|
+
resetChatgptPage
|
|
11
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { extractAssistantMarkdown } from "../../../lib/input/markdown/toMarkdown.js";
|
|
2
|
+
import { waitForAssistantToFinish } from "../../../lib/input/response/waitForFinish.js";
|
|
3
|
+
import { extractSourcesFromClaude } from "./lib/extractSources.js";
|
|
4
|
+
import { CLAUDE_URL, resetClaudePage } from "./lib/pageLifecycle.js";
|
|
5
|
+
const claudeConfig = {
|
|
6
|
+
url: CLAUDE_URL,
|
|
7
|
+
label: "Claude",
|
|
8
|
+
displayName: "Claude",
|
|
9
|
+
beforeRetryHook: resetClaudePage,
|
|
10
|
+
waitForResponse: (page) => waitForAssistantToFinish(page, "claude"),
|
|
11
|
+
extractResponse: (page) => extractAssistantMarkdown(page, "claude"),
|
|
12
|
+
extractSources: (page) => extractSourcesFromClaude(page)
|
|
13
|
+
};
|
|
14
|
+
export {
|
|
15
|
+
claudeConfig
|
|
16
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { toErrorMessage } from "../../../../../shims/errors.js";
|
|
2
|
+
import { logger, PROVIDER_MODEL_RESPONSE_SELECTORS } from "../../../../../shims/utils.js";
|
|
3
|
+
import { buildSources } from "../../_shared/sourceUtils.js";
|
|
4
|
+
const CLAUDE_RAW_SOURCES_DOM_EXTRACTOR = String.raw`(
|
|
5
|
+
{
|
|
6
|
+
getCachedRawSources,
|
|
7
|
+
setCachedRawSources,
|
|
8
|
+
findLatestResponseElement,
|
|
9
|
+
extractClaudeRawSourcesFromResponseElement,
|
|
10
|
+
},
|
|
11
|
+
selectors,
|
|
12
|
+
) => {
|
|
13
|
+
const cached = getCachedRawSources("claude");
|
|
14
|
+
if (cached) return cached;
|
|
15
|
+
|
|
16
|
+
const responseEl = findLatestResponseElement(selectors)?.element;
|
|
17
|
+
if (!responseEl) return [];
|
|
18
|
+
|
|
19
|
+
const rawSources = extractClaudeRawSourcesFromResponseElement(responseEl);
|
|
20
|
+
setCachedRawSources("claude", rawSources);
|
|
21
|
+
return rawSources;
|
|
22
|
+
}`;
|
|
23
|
+
async function fetchTitle(url) {
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(url);
|
|
26
|
+
const html = await res.text();
|
|
27
|
+
const match = html.match(/<title>(.*?)<\/title>/i);
|
|
28
|
+
return match?.[1]?.trim() || null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function extractSourcesFromClaude(page) {
|
|
34
|
+
try {
|
|
35
|
+
const rawSources = await page.runDomOp("raw-sources", {
|
|
36
|
+
provider: "claude",
|
|
37
|
+
selectors: PROVIDER_MODEL_RESPONSE_SELECTORS.claude || []
|
|
38
|
+
});
|
|
39
|
+
const rawSourcesWithFetchedTitles = await Promise.all(
|
|
40
|
+
rawSources.map(async (source) => ({
|
|
41
|
+
...source,
|
|
42
|
+
title: await fetchTitle(source.rawHref) || source.title
|
|
43
|
+
}))
|
|
44
|
+
);
|
|
45
|
+
return buildSources(rawSourcesWithFetchedTitles, { provider: "claude" });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
logger.error(`Failed to extract Claude sources: ${toErrorMessage(error)}`);
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export {
|
|
52
|
+
CLAUDE_RAW_SOURCES_DOM_EXTRACTOR,
|
|
53
|
+
extractSourcesFromClaude
|
|
54
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { resetProviderPage } from "../../_shared/resetProviderPage.js";
|
|
2
|
+
const CLAUDE_URL = "https://claude.ai/new";
|
|
3
|
+
async function resetClaudePage(page) {
|
|
4
|
+
await resetProviderPage(page, "claude", CLAUDE_URL);
|
|
5
|
+
}
|
|
6
|
+
export {
|
|
7
|
+
CLAUDE_URL,
|
|
8
|
+
resetClaudePage
|
|
9
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { extractAssistantMarkdown } from "../../../lib/input/markdown/toMarkdown.js";
|
|
2
|
+
import { openSourcesPanel } from "../../../lib/input/sources/openPanel.js";
|
|
3
|
+
import { findSourcesButton } from "../../../lib/input/sources/findButton.js";
|
|
4
|
+
import { waitForAssistantToFinish } from "../../../lib/input/response/waitForFinish.js";
|
|
5
|
+
import { extractSourcesFromGemini } from "./lib/extractSources.js";
|
|
6
|
+
import { GEMINI_URL, resetGeminiPage } from "./lib/pageLifecycle.js";
|
|
7
|
+
import {
|
|
8
|
+
handleGeminiConsentPage,
|
|
9
|
+
waitForGeminiConversationUrl
|
|
10
|
+
} from "./lib/session.js";
|
|
11
|
+
const geminiConfig = {
|
|
12
|
+
url: GEMINI_URL,
|
|
13
|
+
label: "Gemini",
|
|
14
|
+
displayName: "Gemini",
|
|
15
|
+
// Detect consent pages before attempting to locate the editor.
|
|
16
|
+
// consent.google.com has no Gemini composer, so without this check
|
|
17
|
+
// waitForEditorReady times out and misclassifies it as "no_editor".
|
|
18
|
+
postNavigationHook: async (page) => {
|
|
19
|
+
await handleGeminiConsentPage(page);
|
|
20
|
+
},
|
|
21
|
+
beforePromptHook: async (page) => {
|
|
22
|
+
await handleGeminiConsentPage(page);
|
|
23
|
+
},
|
|
24
|
+
checkSubmitSuccess: async (page, { preSubmitUrl }) => waitForGeminiConversationUrl(page, preSubmitUrl),
|
|
25
|
+
waitForResponse: (page) => waitForAssistantToFinish(page, "gemini"),
|
|
26
|
+
extractResponse: (page) => extractAssistantMarkdown(page, "gemini"),
|
|
27
|
+
beforeRetryHook: resetGeminiPage,
|
|
28
|
+
// No reset between prompts — session is reused in the same conversation.
|
|
29
|
+
// Navigating back to gemini.google.com on each prompt adds unnecessary
|
|
30
|
+
// round-trips and increases detection surface.
|
|
31
|
+
extractSources: async (page) => {
|
|
32
|
+
const btn = await findSourcesButton(page);
|
|
33
|
+
if (!btn) return [];
|
|
34
|
+
await openSourcesPanel(page, btn);
|
|
35
|
+
return extractSourcesFromGemini(page, btn);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
export {
|
|
39
|
+
geminiConfig
|
|
40
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildSources,
|
|
3
|
+
clickButtonViaDispatch
|
|
4
|
+
} from "../../_shared/sourceUtils.js";
|
|
5
|
+
const GEMINI_RAW_SOURCES_DOM_EXTRACTOR = String.raw`(_helpers) => {
|
|
6
|
+
const results = [];
|
|
7
|
+
|
|
8
|
+
const getLeafTexts = (anchor) => {
|
|
9
|
+
const texts = [];
|
|
10
|
+
|
|
11
|
+
for (const el of anchor.querySelectorAll("*")) {
|
|
12
|
+
const text = (el.textContent || "").trim();
|
|
13
|
+
if (!text) continue;
|
|
14
|
+
|
|
15
|
+
if (
|
|
16
|
+
Array.from(el.children).some(
|
|
17
|
+
(child) => (child.textContent || "").trim().length > 0,
|
|
18
|
+
)
|
|
19
|
+
) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
texts.push(text);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return Array.from(new Set(texts));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
for (const anchor of Array.from(
|
|
30
|
+
document.querySelectorAll(
|
|
31
|
+
'inline-source-card a[href^="http"]',
|
|
32
|
+
),
|
|
33
|
+
)) {
|
|
34
|
+
if (!(anchor instanceof HTMLAnchorElement)) continue;
|
|
35
|
+
|
|
36
|
+
const rawHref = anchor.href.replace(/#.*$/, "");
|
|
37
|
+
if (!rawHref) continue;
|
|
38
|
+
const texts = getLeafTexts(anchor);
|
|
39
|
+
if (texts.length === 0) continue;
|
|
40
|
+
const sorted = [...texts].sort((a, b) => a.length - b.length);
|
|
41
|
+
const citedText = sorted[sorted.length - 1] || "";
|
|
42
|
+
const title =
|
|
43
|
+
sorted.length >= 2 ? sorted[sorted.length - 2] || "" : sorted[0] || "";
|
|
44
|
+
|
|
45
|
+
results.push({
|
|
46
|
+
rawHref,
|
|
47
|
+
title,
|
|
48
|
+
citedText,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return results;
|
|
53
|
+
}`;
|
|
54
|
+
async function extractSourcesFromGemini(page, sourcesButton) {
|
|
55
|
+
const rawSources = await page.runDomOp("raw-sources", {
|
|
56
|
+
provider: "gemini"
|
|
57
|
+
});
|
|
58
|
+
if (!await clickButtonViaDispatch(page, sourcesButton)) return [];
|
|
59
|
+
await page.waitForTimeout(300);
|
|
60
|
+
return buildSources(rawSources, { provider: "gemini" });
|
|
61
|
+
}
|
|
62
|
+
export {
|
|
63
|
+
GEMINI_RAW_SOURCES_DOM_EXTRACTOR,
|
|
64
|
+
extractSourcesFromGemini
|
|
65
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { resetProviderPage } from "../../_shared/resetProviderPage.js";
|
|
2
|
+
import { handleGeminiConsentPage } from "./session.js";
|
|
3
|
+
const GEMINI_URL = "https://gemini.google.com/";
|
|
4
|
+
async function resetGeminiPage(page) {
|
|
5
|
+
await resetProviderPage(page, "gemini", GEMINI_URL, {
|
|
6
|
+
postNavigationHook: async (currentPage) => {
|
|
7
|
+
await handleGeminiConsentPage(currentPage);
|
|
8
|
+
}
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export {
|
|
12
|
+
GEMINI_URL,
|
|
13
|
+
resetGeminiPage
|
|
14
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ExternalServiceError } from "../../../../../shims/errors.js";
|
|
2
|
+
import { clickLocatorLikeUser } from "../../../../lib/browser/humanBehavior.js";
|
|
3
|
+
const GEMINI_CONSENT_SELECTOR = "button#L2AGLb, button#W0wltc, form[action*='consent.google.com'] button";
|
|
4
|
+
async function handleGeminiConsentPage(page) {
|
|
5
|
+
const url = page.url();
|
|
6
|
+
if (!url.includes("consent.google.com")) return;
|
|
7
|
+
const consentBtn = page.locator(GEMINI_CONSENT_SELECTOR).first();
|
|
8
|
+
const visible = await consentBtn.isVisible({ timeout: 3e3 }).catch(() => false);
|
|
9
|
+
if (visible) {
|
|
10
|
+
await clickLocatorLikeUser(page, consentBtn, { timeout: 4e3 }).catch(() => {
|
|
11
|
+
});
|
|
12
|
+
await page.waitForTimeout(1e3);
|
|
13
|
+
if (!page.url().includes("consent.google.com")) return;
|
|
14
|
+
}
|
|
15
|
+
throw new ExternalServiceError(
|
|
16
|
+
"gemini",
|
|
17
|
+
"Google consent page not dismissible \u2014 proxy IP requires Google consent",
|
|
18
|
+
429
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
function isGeminiAppUrl(rawUrl) {
|
|
22
|
+
try {
|
|
23
|
+
const url = new URL(rawUrl);
|
|
24
|
+
return url.hostname === "gemini.google.com" && url.pathname.startsWith("/app/") && url.pathname.length > "/app/".length;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function waitForGeminiConversationUrl(page, preSubmitUrl) {
|
|
30
|
+
if (isGeminiAppUrl(preSubmitUrl)) {
|
|
31
|
+
return void 0;
|
|
32
|
+
}
|
|
33
|
+
const deadline = Date.now() + 4e3;
|
|
34
|
+
while (Date.now() < deadline) {
|
|
35
|
+
if (isGeminiAppUrl(await page.getUrl().catch(() => page.url()))) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
await page.waitForTimeout(100);
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
export {
|
|
43
|
+
handleGeminiConsentPage,
|
|
44
|
+
waitForGeminiConversationUrl
|
|
45
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { aiOverviewConfig } from "./ai-overview/index.js";
|
|
2
|
+
import { chatgptConfig } from "./chatgpt/index.js";
|
|
3
|
+
import { claudeConfig } from "./claude/index.js";
|
|
4
|
+
import { geminiConfig } from "./gemini/index.js";
|
|
5
|
+
import { perplexityConfig } from "./perplexity/index.js";
|
|
6
|
+
const PROVIDER_CONFIGS = {
|
|
7
|
+
gemini: geminiConfig,
|
|
8
|
+
chatgpt: chatgptConfig,
|
|
9
|
+
perplexity: perplexityConfig,
|
|
10
|
+
claude: claudeConfig,
|
|
11
|
+
"ai-overview": aiOverviewConfig
|
|
12
|
+
};
|
|
13
|
+
export {
|
|
14
|
+
PROVIDER_CONFIGS
|
|
15
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { extractSourcesFromPerplexity } from "./lib/extractSources.js";
|
|
2
|
+
import { extractAssistantMarkdown } from "../../../lib/input/markdown/toMarkdown.js";
|
|
3
|
+
import { openSourcesPanel } from "../../../lib/input/sources/openPanel.js";
|
|
4
|
+
import { findSourcesButton } from "../../../lib/input/sources/findButton.js";
|
|
5
|
+
import { waitForAssistantToFinish } from "../../../lib/input/response/waitForFinish.js";
|
|
6
|
+
import {
|
|
7
|
+
PERPLEXITY_URL,
|
|
8
|
+
perplexityPostNavigationHook,
|
|
9
|
+
resetPerplexityPage,
|
|
10
|
+
waitForPerplexitySearchUrl
|
|
11
|
+
} from "./lib/navigation.js";
|
|
12
|
+
const perplexityConfig = {
|
|
13
|
+
url: PERPLEXITY_URL,
|
|
14
|
+
label: "Perplexity",
|
|
15
|
+
displayName: "Perplexity",
|
|
16
|
+
beforeRetryHook: resetPerplexityPage,
|
|
17
|
+
betweenPromptsHook: resetPerplexityPage,
|
|
18
|
+
checkSubmitSuccess: async (page, { preSubmitUrl }) => waitForPerplexitySearchUrl(page, preSubmitUrl),
|
|
19
|
+
waitForResponse: (page) => waitForAssistantToFinish(page, "perplexity"),
|
|
20
|
+
extractResponse: (page) => extractAssistantMarkdown(page, "perplexity"),
|
|
21
|
+
postNavigationHook: perplexityPostNavigationHook,
|
|
22
|
+
extractSources: async (page) => {
|
|
23
|
+
const btn = await findSourcesButton(page);
|
|
24
|
+
if (!btn) return [];
|
|
25
|
+
await openSourcesPanel(page, btn);
|
|
26
|
+
return extractSourcesFromPerplexity(page, btn);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
export {
|
|
30
|
+
perplexityConfig
|
|
31
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canUseOsLevelInput,
|
|
3
|
+
pressKeyLikeUser
|
|
4
|
+
} from "../../../../lib/browser/humanBehavior.js";
|
|
5
|
+
import {
|
|
6
|
+
buildSources,
|
|
7
|
+
clickButtonViaDispatch
|
|
8
|
+
} from "../../_shared/sourceUtils.js";
|
|
9
|
+
const PERPLEXITY_RAW_SOURCES_DOM_EXTRACTOR = String.raw`(_helpers) => {
|
|
10
|
+
const results = [];
|
|
11
|
+
const panel = document.querySelector(
|
|
12
|
+
'[role="tabpanel"][aria-labelledby*="citations"]',
|
|
13
|
+
);
|
|
14
|
+
if (!panel) return results;
|
|
15
|
+
|
|
16
|
+
const getCleanTexts = (anchor) => {
|
|
17
|
+
const texts = [];
|
|
18
|
+
|
|
19
|
+
for (const el of anchor.querySelectorAll("*")) {
|
|
20
|
+
const text = (el.textContent || "").trim();
|
|
21
|
+
if (!text) continue;
|
|
22
|
+
|
|
23
|
+
if (
|
|
24
|
+
Array.from(el.children).some(
|
|
25
|
+
(child) => (child.textContent || "").trim().length > 0,
|
|
26
|
+
)
|
|
27
|
+
) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
texts.push(text);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return Array.from(new Set(texts));
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
for (const anchor of Array.from(panel.querySelectorAll('a[href^="http"]'))) {
|
|
38
|
+
if (!(anchor instanceof HTMLAnchorElement)) continue;
|
|
39
|
+
|
|
40
|
+
const rawHref = anchor.href.replace(/#.*$/, "");
|
|
41
|
+
if (!rawHref) continue;
|
|
42
|
+
|
|
43
|
+
const texts = getCleanTexts(anchor);
|
|
44
|
+
if (texts.length === 0) continue;
|
|
45
|
+
|
|
46
|
+
const sorted = [...texts].sort((a, b) => a.length - b.length);
|
|
47
|
+
const citedText = sorted[sorted.length - 1] || "";
|
|
48
|
+
const title =
|
|
49
|
+
sorted.length >= 2 ? sorted[sorted.length - 2] || "" : sorted[0] || "";
|
|
50
|
+
|
|
51
|
+
results.push({
|
|
52
|
+
rawHref,
|
|
53
|
+
title,
|
|
54
|
+
citedText,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return results;
|
|
59
|
+
}`;
|
|
60
|
+
async function extractSourcesFromPerplexity(page, sourcesButton) {
|
|
61
|
+
const rawSources = await page.runDomOp("raw-sources", {
|
|
62
|
+
provider: "perplexity"
|
|
63
|
+
});
|
|
64
|
+
const clickedToClose = await clickButtonViaDispatch(page, sourcesButton).catch(
|
|
65
|
+
() => false
|
|
66
|
+
);
|
|
67
|
+
if (!clickedToClose) {
|
|
68
|
+
const escaped2 = await pressKeyLikeUser(page, "Escape").catch(() => false);
|
|
69
|
+
if (!escaped2 && canUseOsLevelInput(page)) {
|
|
70
|
+
return buildSources(rawSources, { provider: "perplexity" });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
await page.waitForTimeout(300);
|
|
74
|
+
const escaped = await pressKeyLikeUser(page, "Escape").catch(() => false);
|
|
75
|
+
if (!escaped && canUseOsLevelInput(page)) {
|
|
76
|
+
return buildSources(rawSources, { provider: "perplexity" });
|
|
77
|
+
}
|
|
78
|
+
await page.waitForTimeout(300);
|
|
79
|
+
return buildSources(rawSources, { provider: "perplexity" });
|
|
80
|
+
}
|
|
81
|
+
export {
|
|
82
|
+
PERPLEXITY_RAW_SOURCES_DOM_EXTRACTOR,
|
|
83
|
+
extractSourcesFromPerplexity
|
|
84
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { resetProviderPage } from "../../_shared/resetProviderPage.js";
|
|
2
|
+
const PERPLEXITY_URL = "https://www.perplexity.ai/";
|
|
3
|
+
function isPerplexitySearchUrl(rawUrl) {
|
|
4
|
+
try {
|
|
5
|
+
const url = new URL(rawUrl);
|
|
6
|
+
return url.hostname.endsWith("perplexity.ai") && url.pathname.startsWith("/search/") && url.pathname.length > "/search/".length;
|
|
7
|
+
} catch {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
async function waitForPerplexitySearchUrl(page, preSubmitUrl) {
|
|
12
|
+
if (isPerplexitySearchUrl(preSubmitUrl)) {
|
|
13
|
+
return void 0;
|
|
14
|
+
}
|
|
15
|
+
const deadline = Date.now() + 4e3;
|
|
16
|
+
while (Date.now() < deadline) {
|
|
17
|
+
if (isPerplexitySearchUrl(await page.getUrl().catch(() => page.url()))) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
await page.waitForTimeout(100);
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
async function perplexityPostNavigationHook(page) {
|
|
25
|
+
const delay = 1e3 + Math.floor(Math.random() * 1e3);
|
|
26
|
+
await page.waitForTimeout(delay);
|
|
27
|
+
}
|
|
28
|
+
async function resetPerplexityPage(page) {
|
|
29
|
+
await resetProviderPage(page, "perplexity", PERPLEXITY_URL, {
|
|
30
|
+
postNavigationHook: perplexityPostNavigationHook
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export {
|
|
34
|
+
PERPLEXITY_URL,
|
|
35
|
+
perplexityPostNavigationHook,
|
|
36
|
+
resetPerplexityPage,
|
|
37
|
+
waitForPerplexitySearchUrl
|
|
38
|
+
};
|
|
File without changes
|