390 lines
13 KiB
JavaScript
390 lines
13 KiB
JavaScript
const DEER_CLIPPER_DEFAULT_PROMPT =
|
|
"\u8bf7\u57fa\u4e8e\u521a\u521a\u53d1\u9001\u7684\u7f51\u9875\u5185\u5bb9\uff0c\u5148\u7528\u4e2d\u6587\u603b\u7ed3\u8981\u70b9\uff0c\u5e76\u7b49\u5f85\u6211\u7ee7\u7eed\u63d0\u95ee\u3002";
|
|
|
|
const ZNCM_EXTENSION_CONFIG = globalThis.ZNCM_WEB_CLIPPER_CONFIG || {};
|
|
const ZNCM_DEFAULT_WEB_BASE_URL = String(ZNCM_EXTENSION_CONFIG.defaultWebBaseUrl || "").trim().replace(/\/+$/, "");
|
|
const ZNCM_DEFAULT_API_BASE_URL = String(ZNCM_EXTENSION_CONFIG.defaultApiBaseUrl || "").trim().replace(/\/+$/, "");
|
|
|
|
const DEER_CLIPPER_DEFAULTS = {
|
|
apiBaseUrl: ZNCM_DEFAULT_API_BASE_URL,
|
|
webBaseUrl: ZNCM_DEFAULT_WEB_BASE_URL,
|
|
autoDiscoverServiceUrl: true,
|
|
lastServiceSyncAt: "",
|
|
lastServiceSyncSource: "",
|
|
accessToken: "",
|
|
tokenSyncEnabled: true,
|
|
lastTokenSyncAt: "",
|
|
lastTokenSyncSource: "",
|
|
defaultPrompt: DEER_CLIPPER_DEFAULT_PROMPT,
|
|
pageAssistantEnabled: true,
|
|
taskPollingEnabled: true,
|
|
workerId: "",
|
|
taskPollIntervalSeconds: 5,
|
|
taskPageLoadTimeoutSeconds: 30,
|
|
closeTaskTabs: false,
|
|
};
|
|
|
|
function deerNormalizeBaseUrl(value) {
|
|
return String(value || "").trim().replace(/\/+$/, "");
|
|
}
|
|
|
|
function deerAuthHeaders(settings) {
|
|
const token = String(settings.accessToken || "").trim();
|
|
if (!token) {
|
|
return {};
|
|
}
|
|
return {
|
|
Authorization: /^bearer\s+/i.test(token) ? token : `Bearer ${token}`,
|
|
};
|
|
}
|
|
|
|
function deerMakeWorkerId() {
|
|
return `zncm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
}
|
|
|
|
function deerExtractAuthTokenFromPage() {
|
|
const storageKeys = ["deerflow.auth"];
|
|
const tokenFieldNames = new Set(["access_token", "accessToken", "token", "id_token", "idToken"]);
|
|
|
|
function looksLikeToken(value) {
|
|
const text = String(value || "").trim();
|
|
return text.length > 20 && !/\s/.test(text);
|
|
}
|
|
|
|
function findToken(value, depth = 0) {
|
|
if (value == null || depth > 4) {
|
|
return "";
|
|
}
|
|
if (typeof value === "string") {
|
|
const text = value.trim();
|
|
if (!text) {
|
|
return "";
|
|
}
|
|
if ((text.startsWith("{") && text.endsWith("}")) || (text.startsWith("[") && text.endsWith("]"))) {
|
|
try {
|
|
return findToken(JSON.parse(text), depth + 1);
|
|
} catch {
|
|
return looksLikeToken(text) ? text : "";
|
|
}
|
|
}
|
|
return looksLikeToken(text) ? text : "";
|
|
}
|
|
if (typeof value !== "object") {
|
|
return "";
|
|
}
|
|
for (const [key, nested] of Object.entries(value)) {
|
|
if (tokenFieldNames.has(key) && looksLikeToken(nested)) {
|
|
return String(nested).trim();
|
|
}
|
|
}
|
|
for (const nested of Object.values(value)) {
|
|
const token = findToken(nested, depth + 1);
|
|
if (token) {
|
|
return token;
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function scanStorage(storage, storageName) {
|
|
for (const key of storageKeys) {
|
|
const token = findToken(storage.getItem(key));
|
|
if (token) {
|
|
return { token, source: `${storageName}:${key}` };
|
|
}
|
|
}
|
|
|
|
for (let index = 0; index < storage.length; index += 1) {
|
|
const key = storage.key(index);
|
|
if (!key || !/(auth|token|session)/i.test(key)) {
|
|
continue;
|
|
}
|
|
const token = findToken(storage.getItem(key));
|
|
if (token) {
|
|
return { token, source: `${storageName}:${key}` };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return scanStorage(window.localStorage, "localStorage") || scanStorage(window.sessionStorage, "sessionStorage");
|
|
} catch (error) {
|
|
return { token: "", source: "", error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
}
|
|
|
|
function deerExtractCurrentPage() {
|
|
const maxLocalChars = 220000;
|
|
|
|
function cleanText(value) {
|
|
return String(value || "")
|
|
.replace(/\s+\n/g, "\n")
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim();
|
|
}
|
|
|
|
function escapeCell(value) {
|
|
return cleanText(value).replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
}
|
|
|
|
function tableToMarkdown(table, index) {
|
|
const rows = Array.from(table.rows || []).map((row) =>
|
|
Array.from(row.cells || []).map((cell) => escapeCell(cell.innerText)),
|
|
);
|
|
if (rows.length === 0 || rows.every((row) => row.length === 0)) {
|
|
return "";
|
|
}
|
|
const width = Math.max(...rows.map((row) => row.length));
|
|
const normalized = rows.map((row) => {
|
|
const next = row.slice();
|
|
while (next.length < width) next.push("");
|
|
return next;
|
|
});
|
|
const header = normalized[0];
|
|
const divider = Array.from({ length: width }, () => "---");
|
|
const body = normalized.slice(1);
|
|
return [
|
|
`### Table ${index + 1}`,
|
|
"",
|
|
`| ${header.join(" | ")} |`,
|
|
`| ${divider.join(" | ")} |`,
|
|
...body.map((row) => `| ${row.join(" | ")} |`),
|
|
].join("\n");
|
|
}
|
|
|
|
const clone = document.body ? document.body.cloneNode(true) : null;
|
|
if (clone) {
|
|
clone.querySelectorAll("script, style, noscript, svg, canvas, iframe").forEach((node) => node.remove());
|
|
}
|
|
|
|
const title = document.title || "";
|
|
const url = location.href;
|
|
const description = document.querySelector('meta[name="description"]')?.getAttribute("content") || "";
|
|
const main = clone?.querySelector("article, main, [role='main']") || clone;
|
|
const text = cleanText(main?.innerText || document.body?.innerText || "");
|
|
const tables = Array.from(document.querySelectorAll("table"))
|
|
.map((table, index) => tableToMarkdown(table, index))
|
|
.filter(Boolean);
|
|
|
|
const parts = [];
|
|
if (description.trim()) {
|
|
parts.push(`## Meta Description\n\n${cleanText(description)}`);
|
|
}
|
|
if (text) {
|
|
parts.push(`## Page Text\n\n${text}`);
|
|
}
|
|
if (tables.length > 0) {
|
|
parts.push(`## Tables\n\n${tables.join("\n\n")}`);
|
|
}
|
|
|
|
let content = parts.join("\n\n");
|
|
if (content.length > maxLocalChars) {
|
|
content = `${content.slice(0, maxLocalChars)}\n\n[Truncated locally by zncm Web Clipper]`;
|
|
}
|
|
|
|
return { title, url, content, contentType: "markdown" };
|
|
}
|
|
|
|
// Injected into the page to build an indexed list of interactive elements plus
|
|
// the visible page text. Each interactive element is tagged with
|
|
// `data-deer-index` so a follow-up deerPerformAction call can find it by index.
|
|
function deerObservePage(maxElements) {
|
|
const limit = Math.max(10, Number(maxElements || 150));
|
|
const DEER_ATTR = "data-deer-index";
|
|
|
|
document.querySelectorAll(`[${DEER_ATTR}]`).forEach((node) => node.removeAttribute(DEER_ATTR));
|
|
|
|
function isVisible(el) {
|
|
if (!(el instanceof Element)) return false;
|
|
const rect = el.getBoundingClientRect();
|
|
if (rect.width <= 1 && rect.height <= 1) return false;
|
|
const style = window.getComputedStyle(el);
|
|
if (style.visibility === "hidden" || style.display === "none" || style.opacity === "0") return false;
|
|
if (el.disabled) return false;
|
|
return true;
|
|
}
|
|
|
|
function labelFor(el) {
|
|
const aria = el.getAttribute("aria-label");
|
|
if (aria && aria.trim()) return aria.trim();
|
|
const placeholder = el.getAttribute("placeholder");
|
|
const title = el.getAttribute("title");
|
|
const name = el.getAttribute("name");
|
|
const text = (el.innerText || el.textContent || "").trim();
|
|
if (text) return text.replace(/\s+/g, " ");
|
|
if (placeholder && placeholder.trim()) return placeholder.trim();
|
|
if (title && title.trim()) return title.trim();
|
|
if (el.value && String(el.value).trim()) return String(el.value).trim();
|
|
if (name && name.trim()) return name.trim();
|
|
const alt = el.querySelector?.("img[alt]")?.getAttribute("alt");
|
|
if (alt && alt.trim()) return alt.trim();
|
|
return "";
|
|
}
|
|
|
|
const selector = [
|
|
"a[href]",
|
|
"button",
|
|
"input:not([type=hidden])",
|
|
"select",
|
|
"textarea",
|
|
"[role=button]",
|
|
"[role=link]",
|
|
"[role=tab]",
|
|
"[role=menuitem]",
|
|
"[role=checkbox]",
|
|
"[role=radio]",
|
|
"[role=switch]",
|
|
"[contenteditable=true]",
|
|
"[onclick]",
|
|
].join(",");
|
|
|
|
const seen = new Set();
|
|
const elements = [];
|
|
let index = 0;
|
|
for (const el of document.querySelectorAll(selector)) {
|
|
if (index >= limit) break;
|
|
if (seen.has(el) || !isVisible(el)) continue;
|
|
seen.add(el);
|
|
el.setAttribute(DEER_ATTR, String(index));
|
|
const tag = el.tagName.toLowerCase();
|
|
let label = labelFor(el);
|
|
if (label.length > 200) label = label.slice(0, 200);
|
|
const entry = { index, tag, label };
|
|
const type = el.getAttribute("type") || el.getAttribute("role");
|
|
if (type) entry.type = type;
|
|
if (tag === "input" || tag === "textarea" || tag === "select") {
|
|
const value = String(el.value || "");
|
|
if (value) entry.value = value.slice(0, 80);
|
|
}
|
|
if (tag === "a") {
|
|
const href = el.getAttribute("href") || "";
|
|
if (href) entry.href = href.slice(0, 200);
|
|
}
|
|
elements.push(entry);
|
|
index += 1;
|
|
}
|
|
|
|
// Inline text extraction so this function stays self-contained when injected
|
|
// via chrome.scripting.executeScript (no dependency on other content-script fns).
|
|
let text = "";
|
|
try {
|
|
const clone = document.body ? document.body.cloneNode(true) : null;
|
|
if (clone) {
|
|
clone.querySelectorAll(`script, style, noscript, svg, canvas, iframe, [${DEER_ATTR}-skip]`).forEach((node) => node.remove());
|
|
}
|
|
const main = clone?.querySelector("article, main, [role='main']") || clone;
|
|
text = String(main?.innerText || document.body?.innerText || "")
|
|
.replace(/\s+\n/g, "\n")
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim();
|
|
} catch {
|
|
text = "";
|
|
}
|
|
|
|
return {
|
|
url: location.href,
|
|
title: document.title || "",
|
|
elements,
|
|
text,
|
|
};
|
|
}
|
|
|
|
// Injected into the page to perform a single action on the element previously
|
|
// tagged by deerObservePage (by index). Returns {status, message}.
|
|
async function deerPerformAction(action) {
|
|
const DEER_ATTR = "data-deer-index";
|
|
const type = String(action?.type || "").toLowerCase();
|
|
|
|
function findByIndex(idx) {
|
|
if (idx === undefined || idx === null || idx < 0) return null;
|
|
return document.querySelector(`[${DEER_ATTR}="${idx}"]`);
|
|
}
|
|
|
|
function fireInput(el, value) {
|
|
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
|
|
if (setter) {
|
|
setter.call(el, value);
|
|
} else {
|
|
el.value = value;
|
|
}
|
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
}
|
|
|
|
try {
|
|
if (type === "observe" || type === "extract") {
|
|
return { status: "ok", message: type === "extract" ? "Page text re-read." : "Observed page." };
|
|
}
|
|
|
|
if (type === "scroll") {
|
|
const el = findByIndex(action.index);
|
|
if (el) {
|
|
el.scrollIntoView({ block: "center", behavior: "instant" });
|
|
return { status: "ok", message: "Scrolled element into view." };
|
|
}
|
|
window.scrollBy(0, Math.round(window.innerHeight * 0.85));
|
|
return { status: "ok", message: "Scrolled down one viewport." };
|
|
}
|
|
|
|
if (type === "wait") {
|
|
const selector = String(action.wait_for || "").trim();
|
|
const timeoutMs = Math.max(500, Number(action.timeout_ms || 8000));
|
|
if (!selector) {
|
|
await new Promise((resolve) => setTimeout(resolve, Math.min(timeoutMs, 3000)));
|
|
return { status: "ok", message: "Waited." };
|
|
}
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
if (document.querySelector(selector)) {
|
|
return { status: "ok", message: `Element ${selector} appeared.` };
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
}
|
|
return { status: "timeout", message: `Element ${selector} did not appear within ${timeoutMs}ms.` };
|
|
}
|
|
|
|
if (type === "click") {
|
|
const el = findByIndex(action.index);
|
|
if (!el) return { status: "error", message: `No element with index ${action.index}. Re-observe the page.` };
|
|
el.scrollIntoView({ block: "center", behavior: "instant" });
|
|
if (typeof el.focus === "function") el.focus();
|
|
el.click();
|
|
return { status: "ok", message: `Clicked element ${action.index}.` };
|
|
}
|
|
|
|
if (type === "type") {
|
|
const el = findByIndex(action.index);
|
|
if (!el) return { status: "error", message: `No element with index ${action.index}. Re-observe the page.` };
|
|
el.scrollIntoView({ block: "center", behavior: "instant" });
|
|
if (typeof el.focus === "function") el.focus();
|
|
const value = String(action.text ?? "");
|
|
if (el.isContentEditable) {
|
|
el.textContent = value;
|
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
} else {
|
|
fireInput(el, value);
|
|
}
|
|
if (action.submit) {
|
|
const enterInit = { bubbles: true, cancelable: true, key: "Enter", code: "Enter", keyCode: 13, which: 13 };
|
|
el.dispatchEvent(new KeyboardEvent("keydown", enterInit));
|
|
el.dispatchEvent(new KeyboardEvent("keyup", enterInit));
|
|
const form = el.form || el.closest("form");
|
|
if (form && typeof form.requestSubmit === "function") {
|
|
try {
|
|
form.requestSubmit();
|
|
} catch {
|
|
form.submit?.();
|
|
}
|
|
}
|
|
}
|
|
return { status: "ok", message: `Typed into element ${action.index}${action.submit ? " and submitted" : ""}.` };
|
|
}
|
|
|
|
return { status: "error", message: `Unsupported action type: ${type}` };
|
|
} catch (error) {
|
|
return { status: "error", message: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
}
|