227 lines
7.9 KiB
JavaScript
227 lines
7.9 KiB
JavaScript
(function initializeClipperCore(global) {
|
|
const DEFAULT_API_URLS = ["http://127.0.0.1:19827", "http://localhost:19827"];
|
|
const MAX_EXTRACTED_CONTENT_CHARS = 1_000_000;
|
|
const TRUNCATION_NOTICE = "\n\n[LLM Wiki Clipper: page content truncated at 1,000,000 characters.]";
|
|
|
|
function limitExtractedContent(content) {
|
|
const value = String(content || "");
|
|
if (value.length <= MAX_EXTRACTED_CONTENT_CHARS) return value;
|
|
return `${value.slice(0, MAX_EXTRACTED_CONTENT_CHARS)}${TRUNCATION_NOTICE}`;
|
|
}
|
|
|
|
function normalizeServerUrl(value) {
|
|
let candidate = String(value || "").trim();
|
|
if (!candidate) return DEFAULT_API_URLS[0];
|
|
if (!/^https?:\/\//i.test(candidate)) candidate = `http://${candidate}`;
|
|
const parsed = new URL(candidate);
|
|
if (!/^https?:$/.test(parsed.protocol) || parsed.username || parsed.password) {
|
|
throw new Error("Use an http(s) address without embedded credentials");
|
|
}
|
|
if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
|
throw new Error("Enter only the server origin, without a path, query, or fragment");
|
|
}
|
|
if (!parsed.port) parsed.port = "19827";
|
|
return parsed.origin;
|
|
}
|
|
|
|
async function loadSettings() {
|
|
const saved = await chrome.storage.local.get([
|
|
"serverUrl",
|
|
"accessToken",
|
|
"preferredProjectPath",
|
|
]);
|
|
let serverUrl;
|
|
try {
|
|
serverUrl = normalizeServerUrl(saved.serverUrl || DEFAULT_API_URLS[0]);
|
|
} catch {
|
|
serverUrl = DEFAULT_API_URLS[0];
|
|
}
|
|
return {
|
|
serverUrl,
|
|
accessToken: String(saved.accessToken || ""),
|
|
preferredProjectPath: String(saved.preferredProjectPath || ""),
|
|
};
|
|
}
|
|
|
|
function requestHeaders(accessToken, options) {
|
|
const headers = new Headers(options?.headers || {});
|
|
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
|
|
return headers;
|
|
}
|
|
|
|
async function clipFetch(path, options, connection) {
|
|
const method = String(options?.method || "GET").toUpperCase();
|
|
const serverUrl = normalizeServerUrl(connection?.serverUrl || DEFAULT_API_URLS[0]);
|
|
// A POST is never retried because the first request may have reached the
|
|
// Clip Server even when its response was lost.
|
|
const isDefaultLocalAddress = DEFAULT_API_URLS.includes(serverUrl);
|
|
const urls = method === "GET" && isDefaultLocalAddress
|
|
? [serverUrl, ...DEFAULT_API_URLS.filter((url) => url !== serverUrl)]
|
|
: [serverUrl];
|
|
let lastError;
|
|
|
|
for (const baseUrl of urls) {
|
|
try {
|
|
const response = await fetch(`${baseUrl}${path}`, {
|
|
...options,
|
|
headers: requestHeaders(connection?.accessToken, options),
|
|
});
|
|
return { response, baseUrl };
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
}
|
|
throw lastError || new Error("Unable to connect to LLM Wiki");
|
|
}
|
|
|
|
// This function is serialized into the active tab by chrome.scripting, so it
|
|
// must remain self-contained and must not capture extension-scope variables.
|
|
function extractReadablePage() {
|
|
try {
|
|
const documentClone = document.cloneNode(true);
|
|
const reader = new window.Readability(documentClone);
|
|
const article = reader.parse();
|
|
if (!article || !article.content) {
|
|
return { error: "Readability could not extract content" };
|
|
}
|
|
|
|
const turndown = new window.TurndownService({
|
|
headingStyle: "atx",
|
|
codeBlockStyle: "fenced",
|
|
bulletListMarker: "-",
|
|
});
|
|
turndown.addRule("tableCell", {
|
|
filter: ["th", "td"],
|
|
replacement: (content) => ` ${content.trim()} |`,
|
|
});
|
|
turndown.addRule("tableRow", {
|
|
filter: "tr",
|
|
replacement: (content) => `|${content}\n`,
|
|
});
|
|
turndown.addRule("table", {
|
|
filter: "table",
|
|
replacement: (content) => {
|
|
const lines = content.trim().split("\n");
|
|
if (lines.length > 0) {
|
|
const columns = (lines[0].match(/\|/g) || []).length - 1;
|
|
lines.splice(1, 0, `|${" --- |".repeat(columns)}`);
|
|
}
|
|
return `\n\n${lines.join("\n")}\n\n`;
|
|
},
|
|
});
|
|
turndown.addRule("removeSmallImages", {
|
|
filter: (node) => {
|
|
if (node.nodeName !== "IMG") return false;
|
|
const width = parseInt(node.getAttribute("width") || "999");
|
|
const height = parseInt(node.getAttribute("height") || "999");
|
|
return width < 10 || height < 10;
|
|
},
|
|
replacement: () => "",
|
|
});
|
|
|
|
return {
|
|
title: article.title || document.title || "Untitled",
|
|
content: turndown.turndown(article.content),
|
|
excerpt: article.excerpt || "",
|
|
};
|
|
} catch (error) {
|
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
}
|
|
|
|
function extractFallbackPage() {
|
|
const clone = document.body?.cloneNode(true);
|
|
if (!clone) return "";
|
|
["script", "style", "nav", "header", "footer", ".sidebar", ".ad", ".comments"]
|
|
.forEach((selector) => clone.querySelectorAll(selector).forEach((element) => element.remove()));
|
|
return clone.innerText
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0)
|
|
.join("\n\n");
|
|
}
|
|
|
|
async function extractActiveTab(commandTab) {
|
|
// Chrome passes the exact shortcut target to commands.onCommand together
|
|
// with the temporary activeTab grant. Popup callers do not have that value
|
|
// and intentionally resolve their own currently active tab instead.
|
|
const tab = commandTab?.id
|
|
? commandTab
|
|
: (await chrome.tabs.query({ active: true, currentWindow: true }))[0];
|
|
if (!tab?.id) throw new Error("No active browser tab");
|
|
if (!/^https?:\/\//i.test(tab.url || "")) {
|
|
throw new Error("This browser page cannot be clipped");
|
|
}
|
|
|
|
await chrome.scripting.executeScript({
|
|
target: { tabId: tab.id },
|
|
files: ["Readability.js", "Turndown.js"],
|
|
});
|
|
const results = await chrome.scripting.executeScript({
|
|
target: { tabId: tab.id },
|
|
func: extractReadablePage,
|
|
});
|
|
const extracted = results?.[0]?.result;
|
|
let content = extracted?.content || "";
|
|
if (!content) {
|
|
const fallback = await chrome.scripting.executeScript({
|
|
target: { tabId: tab.id },
|
|
func: extractFallbackPage,
|
|
});
|
|
content = fallback?.[0]?.result || "";
|
|
}
|
|
if (!content.trim()) throw new Error(extracted?.error || "Failed to extract page content");
|
|
content = limitExtractedContent(content);
|
|
|
|
return {
|
|
title: extracted?.title || tab.title || "Untitled",
|
|
url: tab.url || "",
|
|
content,
|
|
excerpt: extracted?.excerpt || "",
|
|
};
|
|
}
|
|
|
|
async function loadProjects(connection) {
|
|
const { response, baseUrl } = await clipFetch("/projects", { method: "GET" }, connection);
|
|
if (response.status === 401) throw new Error("Access token required or invalid");
|
|
const data = await response.json();
|
|
if (!response.ok || !data.ok) throw new Error(data.error || "Failed to load projects");
|
|
return { projects: data.projects || [], baseUrl };
|
|
}
|
|
|
|
function selectProject(projects, preferredProjectPath) {
|
|
return projects.find((project) => project.path === preferredProjectPath)
|
|
|| projects.find((project) => project.current)
|
|
|| projects[0]
|
|
|| null;
|
|
}
|
|
|
|
async function submitClip(page, projectPath, connection) {
|
|
const { response, baseUrl } = await clipFetch("/clip", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
title: page.title,
|
|
url: page.url,
|
|
content: page.content,
|
|
projectPath,
|
|
}),
|
|
}, connection);
|
|
const data = await response.json();
|
|
if (!response.ok || !data.ok) throw new Error(data.error || `Clip failed: HTTP ${response.status}`);
|
|
return { data, baseUrl };
|
|
}
|
|
|
|
global.LLMWikiClipper = Object.freeze({
|
|
DEFAULT_API_URLS,
|
|
MAX_EXTRACTED_CONTENT_CHARS,
|
|
normalizeServerUrl,
|
|
loadSettings,
|
|
clipFetch,
|
|
extractActiveTab,
|
|
loadProjects,
|
|
selectProject,
|
|
submitClip,
|
|
});
|
|
})(globalThis);
|