mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-01 15:30:52 +08:00
1298 lines
38 KiB
JavaScript
1298 lines
38 KiB
JavaScript
// 使用 IIFE 避免重复注入时的变量冲突
|
|
(function () {
|
|
// 检查是否已经注入过
|
|
if (
|
|
window.__LIME_CONTENT_SCRIPT_LOADED__ ||
|
|
window.__PROXYCAST_CONTENT_SCRIPT_LOADED__
|
|
) {
|
|
return;
|
|
}
|
|
window.__LIME_CONTENT_SCRIPT_LOADED__ = true;
|
|
window.__PROXYCAST_CONTENT_SCRIPT_LOADED__ = true;
|
|
|
|
let refCounter = 0;
|
|
const REF_ATTR = "lime-id";
|
|
|
|
function nextRefId() {
|
|
refCounter += 1;
|
|
return `lime-${refCounter}`;
|
|
}
|
|
|
|
function resetRefs() {
|
|
refCounter = 0;
|
|
document.querySelectorAll(`[${REF_ATTR}]`).forEach((el) => {
|
|
el.removeAttribute(REF_ATTR);
|
|
});
|
|
}
|
|
|
|
function normalizeText(value) {
|
|
return String(value || "")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function isElementVisible(element) {
|
|
if (!element) {
|
|
return false;
|
|
}
|
|
const style = window.getComputedStyle(element);
|
|
if (
|
|
style.display === "none" ||
|
|
style.visibility === "hidden" ||
|
|
style.opacity === "0"
|
|
) {
|
|
return false;
|
|
}
|
|
const rect = element.getBoundingClientRect();
|
|
return rect.width > 0 && rect.height > 0;
|
|
}
|
|
|
|
function isInteractiveElement(element) {
|
|
if (!element || !isElementVisible(element)) {
|
|
return false;
|
|
}
|
|
|
|
const tag = element.tagName.toLowerCase();
|
|
const role = element.getAttribute("role");
|
|
if (
|
|
["a", "button", "input", "textarea", "select", "option"].includes(tag)
|
|
) {
|
|
return true;
|
|
}
|
|
if (
|
|
role &&
|
|
[
|
|
"button",
|
|
"link",
|
|
"checkbox",
|
|
"radio",
|
|
"menuitem",
|
|
"tab",
|
|
"switch",
|
|
"option",
|
|
"searchbox",
|
|
"textbox",
|
|
"combobox",
|
|
].includes(role)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
if (element.hasAttribute("onclick")) {
|
|
return true;
|
|
}
|
|
|
|
const style = window.getComputedStyle(element);
|
|
return style.cursor === "pointer";
|
|
}
|
|
|
|
function interactiveLabel(element) {
|
|
const tag = element.tagName.toLowerCase();
|
|
const role = element.getAttribute("role");
|
|
const text = normalizeText(
|
|
element.innerText ||
|
|
element.value ||
|
|
element.placeholder ||
|
|
element.getAttribute("aria-label") ||
|
|
element.title ||
|
|
element.name ||
|
|
element.id,
|
|
);
|
|
|
|
if (tag === "a") {
|
|
return `链接: ${text || "无标题链接"}`;
|
|
}
|
|
if (tag === "button" || role === "button") {
|
|
return `按钮: ${text || "无标题按钮"}`;
|
|
}
|
|
if (tag === "input" || tag === "textarea") {
|
|
return `输入框: ${text || "未命名输入框"}`;
|
|
}
|
|
if (tag === "select") {
|
|
return `下拉框: ${text || "未命名下拉框"}`;
|
|
}
|
|
return `可交互元素: ${text || tag}`;
|
|
}
|
|
|
|
function buildMarkdown() {
|
|
resetRefs();
|
|
|
|
const lines = [];
|
|
lines.push(`# ${document.title || "Untitled"}`);
|
|
lines.push(`URL: ${window.location.href}`);
|
|
lines.push("");
|
|
|
|
const bodyText = normalizeText(
|
|
document.body ? document.body.innerText : "",
|
|
);
|
|
if (bodyText) {
|
|
lines.push("## 页面文本");
|
|
lines.push(bodyText.slice(0, 6000));
|
|
lines.push("");
|
|
}
|
|
|
|
lines.push("## 可交互元素");
|
|
const allElements = Array.from(document.querySelectorAll("*")).filter(
|
|
isInteractiveElement,
|
|
);
|
|
|
|
for (const element of allElements.slice(0, 300)) {
|
|
const refId = nextRefId();
|
|
element.setAttribute(REF_ATTR, refId);
|
|
lines.push(`- [${interactiveLabel(element)}](${refId})`);
|
|
}
|
|
|
|
return lines.join("\n").trim();
|
|
}
|
|
|
|
function findElement(target) {
|
|
if (!target || typeof target !== "string") {
|
|
return null;
|
|
}
|
|
const trimmed = target.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
let element = document.querySelector(
|
|
`[${REF_ATTR}="${CSS.escape(trimmed)}"]`,
|
|
);
|
|
if (element) {
|
|
return element;
|
|
}
|
|
|
|
try {
|
|
element = document.querySelector(trimmed);
|
|
if (element) {
|
|
return element;
|
|
}
|
|
} catch (_) {}
|
|
|
|
element = Array.from(
|
|
document.querySelectorAll(
|
|
"button,a,input,textarea,select,[role='button']",
|
|
),
|
|
).find(
|
|
(candidate) =>
|
|
normalizeText(
|
|
candidate.innerText || candidate.value || candidate.placeholder || "",
|
|
) === trimmed,
|
|
);
|
|
if (element) {
|
|
return element;
|
|
}
|
|
|
|
element = Array.from(document.querySelectorAll("[aria-label]")).find(
|
|
(candidate) =>
|
|
normalizeText(candidate.getAttribute("aria-label")) === trimmed,
|
|
);
|
|
return element || null;
|
|
}
|
|
|
|
function scrollPage(text) {
|
|
const input = normalizeText(text);
|
|
let direction = "down";
|
|
let amount = 500;
|
|
|
|
if (input.includes(":")) {
|
|
const parts = input.split(":");
|
|
direction = normalizeText(parts[0]) || "down";
|
|
const parsed = Number(parts[1]);
|
|
if (!Number.isNaN(parsed) && parsed > 0) {
|
|
amount = parsed;
|
|
}
|
|
}
|
|
|
|
if (direction === "up") {
|
|
window.scrollBy(0, -amount);
|
|
} else if (direction === "left") {
|
|
window.scrollBy(-amount, 0);
|
|
} else if (direction === "right") {
|
|
window.scrollBy(amount, 0);
|
|
} else {
|
|
window.scrollBy(0, amount);
|
|
}
|
|
}
|
|
|
|
const siteAdapterHelpers = {
|
|
sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
},
|
|
resolveRoot(value) {
|
|
if (value && typeof value === "object" && "nodeType" in value) {
|
|
return value;
|
|
}
|
|
return document.body;
|
|
},
|
|
text(value) {
|
|
return String(value?.textContent || "")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
},
|
|
absoluteUrl(value) {
|
|
try {
|
|
return new URL(value, window.location.href).toString();
|
|
} catch {
|
|
return "";
|
|
}
|
|
},
|
|
uniqueBy(items, getKey) {
|
|
const seen = new Set();
|
|
return items.filter((item) => {
|
|
const key = getKey(item);
|
|
if (!key || seen.has(key)) {
|
|
return false;
|
|
}
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
},
|
|
async waitFor(test, timeoutMs = 12000, intervalMs = 250) {
|
|
const startedAt = Date.now();
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
const value = await test();
|
|
if (value) {
|
|
return value;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
}
|
|
return null;
|
|
},
|
|
async waitForDomStable(options = {}) {
|
|
const root = siteAdapterHelpers.resolveRoot(options.root);
|
|
const stableMs = Math.max(100, Number(options.stableMs) || 500);
|
|
const timeoutMs = Math.max(stableMs, Number(options.timeoutMs) || 2500);
|
|
|
|
if (!root || typeof MutationObserver === "undefined") {
|
|
await siteAdapterHelpers.sleep(Math.min(timeoutMs, stableMs));
|
|
return true;
|
|
}
|
|
|
|
return await new Promise((resolve) => {
|
|
let finished = false;
|
|
let settleTimer = null;
|
|
let timeoutTimer = null;
|
|
|
|
const finish = () => {
|
|
if (finished) {
|
|
return;
|
|
}
|
|
finished = true;
|
|
if (settleTimer) clearTimeout(settleTimer);
|
|
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
observer.disconnect();
|
|
resolve(true);
|
|
};
|
|
|
|
const schedule = () => {
|
|
if (settleTimer) clearTimeout(settleTimer);
|
|
settleTimer = setTimeout(finish, stableMs);
|
|
};
|
|
|
|
const observer = new MutationObserver(() => {
|
|
schedule();
|
|
});
|
|
|
|
observer.observe(root, {
|
|
childList: true,
|
|
subtree: true,
|
|
attributes: true,
|
|
characterData: true,
|
|
});
|
|
|
|
timeoutTimer = setTimeout(finish, timeoutMs);
|
|
schedule();
|
|
});
|
|
},
|
|
async scrollUntilSettled(options = {}) {
|
|
const root = siteAdapterHelpers.resolveRoot(options.root);
|
|
const maxScrolls = Math.max(1, Number(options.maxScrolls) || 8);
|
|
const delayMs = Math.max(80, Number(options.delayMs) || 350);
|
|
const settleRounds = Math.max(1, Number(options.settleRounds) || 2);
|
|
const viewportFactor = Math.max(
|
|
0.2,
|
|
Number(options.viewportFactor) || 0.9,
|
|
);
|
|
const getSnapshot =
|
|
typeof options.getSnapshot === "function" ? options.getSnapshot : () => ({});
|
|
const scrollingElement =
|
|
document.scrollingElement || document.documentElement || document.body;
|
|
let lastSignature = "";
|
|
let lastScrollY = Math.round(window.scrollY);
|
|
let stableCount = 0;
|
|
let scrolls = 0;
|
|
|
|
while (scrolls < maxScrolls) {
|
|
const stepPx = Math.max(
|
|
320,
|
|
Number(options.stepPx) || Math.round(window.innerHeight * viewportFactor),
|
|
);
|
|
window.scrollBy(0, stepPx);
|
|
await siteAdapterHelpers.sleep(delayMs);
|
|
await siteAdapterHelpers.waitForDomStable({
|
|
root,
|
|
stableMs: Math.min(600, delayMs),
|
|
timeoutMs: Math.max(1200, delayMs * 4),
|
|
});
|
|
|
|
let snapshot = {};
|
|
try {
|
|
snapshot = getSnapshot() || {};
|
|
} catch {
|
|
snapshot = {};
|
|
}
|
|
|
|
const signature = JSON.stringify({
|
|
scrollY: Math.round(window.scrollY),
|
|
scrollHeight: Math.round(scrollingElement?.scrollHeight || 0),
|
|
...snapshot,
|
|
});
|
|
|
|
if (
|
|
signature === lastSignature ||
|
|
Math.round(window.scrollY) === lastScrollY
|
|
) {
|
|
stableCount += 1;
|
|
} else {
|
|
stableCount = 0;
|
|
}
|
|
|
|
lastSignature = signature;
|
|
lastScrollY = Math.round(window.scrollY);
|
|
scrolls += 1;
|
|
|
|
if (stableCount >= settleRounds) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return {
|
|
scrolls,
|
|
scrollY: Math.round(window.scrollY),
|
|
scrollHeight: Math.round(scrollingElement?.scrollHeight || 0),
|
|
stableCount,
|
|
};
|
|
},
|
|
async waitForImagesReady(rootOrOptions, maybeOptions = {}) {
|
|
const root =
|
|
rootOrOptions &&
|
|
typeof rootOrOptions === "object" &&
|
|
"nodeType" in rootOrOptions
|
|
? rootOrOptions
|
|
: siteAdapterHelpers.resolveRoot(maybeOptions.root);
|
|
const options =
|
|
rootOrOptions &&
|
|
typeof rootOrOptions === "object" &&
|
|
"nodeType" in rootOrOptions
|
|
? maybeOptions
|
|
: rootOrOptions || {};
|
|
const timeoutMs = Math.max(200, Number(options.timeoutMs) || 3000);
|
|
const intervalMs = Math.max(80, Number(options.intervalMs) || 200);
|
|
const stableRounds = Math.max(1, Number(options.stableRounds) || 2);
|
|
|
|
const collectCandidates = () => {
|
|
const target = siteAdapterHelpers.resolveRoot(root);
|
|
const entries = [];
|
|
const seen = new Set();
|
|
const push = (element) => {
|
|
if (!element || seen.has(element)) {
|
|
return;
|
|
}
|
|
seen.add(element);
|
|
entries.push(element);
|
|
};
|
|
|
|
if (target?.matches?.("img, [role='img'], [style*='background-image']")) {
|
|
push(target);
|
|
}
|
|
target
|
|
?.querySelectorAll?.("img, [role='img'], [style*='background-image']")
|
|
?.forEach?.((element) => push(element));
|
|
|
|
const details = entries.map((element) => {
|
|
const inlineStyle = element.getAttribute?.("style") || "";
|
|
const computedStyle =
|
|
typeof getComputedStyle === "function"
|
|
? getComputedStyle(element).backgroundImage || ""
|
|
: "";
|
|
const bgMatch = String(`${inlineStyle};${computedStyle}`).match(
|
|
/url\((['"]?)(.*?)\1\)/,
|
|
);
|
|
const source =
|
|
element.currentSrc ||
|
|
element.getAttribute?.("src") ||
|
|
element.getAttribute?.("data-src") ||
|
|
element.getAttribute?.("data-image-url") ||
|
|
element.getAttribute?.("srcset") ||
|
|
bgMatch?.[2] ||
|
|
"";
|
|
return String(source || "").trim();
|
|
});
|
|
|
|
return {
|
|
total: details.length,
|
|
ready: details.filter(Boolean).length,
|
|
};
|
|
};
|
|
|
|
const startedAt = Date.now();
|
|
let previousSignature = "";
|
|
let stableCount = 0;
|
|
let lastSnapshot = collectCandidates();
|
|
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
lastSnapshot = collectCandidates();
|
|
const signature = JSON.stringify(lastSnapshot);
|
|
|
|
if (lastSnapshot.total > 0 && lastSnapshot.ready >= lastSnapshot.total) {
|
|
return lastSnapshot;
|
|
}
|
|
|
|
if (signature === previousSignature) {
|
|
stableCount += 1;
|
|
} else {
|
|
stableCount = 0;
|
|
}
|
|
previousSignature = signature;
|
|
|
|
if (stableCount >= stableRounds) {
|
|
return lastSnapshot;
|
|
}
|
|
|
|
await siteAdapterHelpers.sleep(intervalMs);
|
|
}
|
|
|
|
return lastSnapshot;
|
|
},
|
|
take(items, limit) {
|
|
return items.slice(0, Math.max(1, limit));
|
|
},
|
|
number(value, fallbackValue) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : fallbackValue;
|
|
},
|
|
looksLikeLoginWall() {
|
|
const text = (document.body?.textContent || "").slice(0, 4000);
|
|
return /(登录|登入|sign in|log in|继续访问|验证你是人类|扫码登录)/i.test(
|
|
text,
|
|
);
|
|
},
|
|
};
|
|
|
|
const generatedSiteAdapterRunners =
|
|
window.__LIME_SITE_ADAPTER_RUNNERS__ &&
|
|
typeof window.__LIME_SITE_ADAPTER_RUNNERS__ === "object" &&
|
|
!Array.isArray(window.__LIME_SITE_ADAPTER_RUNNERS__)
|
|
? window.__LIME_SITE_ADAPTER_RUNNERS__
|
|
: {};
|
|
|
|
const legacySiteAdapterRunners = {
|
|
"github/search": async (args, helpers) => {
|
|
const query = String(args.query || "").trim();
|
|
const limit = helpers.number(args.limit, 10);
|
|
await helpers.waitFor(
|
|
() => document.querySelectorAll("a.v-align-middle").length > 0,
|
|
12000,
|
|
300,
|
|
);
|
|
const links = Array.from(document.querySelectorAll("a.v-align-middle"));
|
|
const items = helpers.take(
|
|
helpers.uniqueBy(
|
|
links
|
|
.map((anchor) => {
|
|
const card = anchor.closest("li, div.search-title, div.Box-row");
|
|
return {
|
|
title: helpers.text(anchor),
|
|
url: helpers.absoluteUrl(anchor.getAttribute("href") || ""),
|
|
summary: helpers.text(card?.querySelector("p")),
|
|
};
|
|
})
|
|
.filter((item) => item.title && item.url),
|
|
(item) => item.url,
|
|
),
|
|
limit,
|
|
);
|
|
|
|
if (items.length === 0 && helpers.looksLikeLoginWall()) {
|
|
return {
|
|
ok: false,
|
|
error_code: "auth_required",
|
|
error_message: "GitHub 搜索结果为空,可能需要先登录后再访问。",
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
query,
|
|
items,
|
|
count: items.length,
|
|
},
|
|
source_url: location.href,
|
|
};
|
|
},
|
|
"github/issues": async (args, helpers) => {
|
|
const repo = String(args.repo || "").trim();
|
|
const limit = helpers.number(args.limit, 10);
|
|
await helpers.waitFor(
|
|
() =>
|
|
document.querySelectorAll(
|
|
'a[data-hovercard-type="issue"], a[id^="issue_"]',
|
|
).length > 0,
|
|
12000,
|
|
300,
|
|
);
|
|
const links = Array.from(
|
|
document.querySelectorAll(
|
|
'a[data-hovercard-type="issue"], a[id^="issue_"]',
|
|
),
|
|
);
|
|
const items = helpers.take(
|
|
helpers.uniqueBy(
|
|
links
|
|
.map((anchor) => {
|
|
const row = anchor.closest("div, li");
|
|
return {
|
|
title: helpers.text(anchor),
|
|
url: helpers.absoluteUrl(anchor.getAttribute("href") || ""),
|
|
status: helpers.text(
|
|
row?.querySelector(
|
|
'[aria-label*="Open"], [aria-label*="Closed"], .State',
|
|
),
|
|
),
|
|
summary: helpers.text(
|
|
row?.querySelector(
|
|
"span.color-fg-muted, .markdown-title + div",
|
|
),
|
|
),
|
|
};
|
|
})
|
|
.filter((item) => item.title && item.url),
|
|
(item) => item.url,
|
|
),
|
|
limit,
|
|
);
|
|
|
|
if (items.length === 0 && helpers.looksLikeLoginWall()) {
|
|
return {
|
|
ok: false,
|
|
error_code: "auth_required",
|
|
error_message: `GitHub 仓库 ${repo} 的 issue 列表为空,可能需要登录后访问。`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
repo,
|
|
items,
|
|
count: items.length,
|
|
},
|
|
source_url: location.href,
|
|
};
|
|
},
|
|
"zhihu/search": async (args, helpers) => {
|
|
const query = String(args.query || "").trim();
|
|
const limit = helpers.number(args.limit, 10);
|
|
const hrefTokens = ["/question/", "/answer/", "/zvideo/"];
|
|
const maxCandidates = Math.max(limit * 8, 40);
|
|
const main = document.querySelector("main") || document.body;
|
|
const collectCandidates = (maxCount) => {
|
|
if (!main) {
|
|
return [];
|
|
}
|
|
const results = [];
|
|
const seen = new Set();
|
|
for (const anchor of main.getElementsByTagName("a")) {
|
|
const href = anchor.getAttribute("href") || "";
|
|
if (!hrefTokens.some((token) => href.includes(token))) {
|
|
continue;
|
|
}
|
|
const title = helpers.text(anchor);
|
|
const url = helpers.absoluteUrl(href);
|
|
if (!title || !url || seen.has(url)) {
|
|
continue;
|
|
}
|
|
seen.add(url);
|
|
results.push({ anchor, title, url });
|
|
if (results.length >= maxCount) {
|
|
break;
|
|
}
|
|
}
|
|
return results;
|
|
};
|
|
await helpers.waitFor(() => collectCandidates(1)[0], 12000, 300);
|
|
const items = helpers.take(
|
|
collectCandidates(maxCandidates)
|
|
.map(({ anchor, title, url }) => {
|
|
const card = anchor.closest("div");
|
|
return {
|
|
title,
|
|
url,
|
|
summary: helpers.text(card?.querySelector("p")),
|
|
};
|
|
})
|
|
.filter((item) => item.title && item.url),
|
|
limit,
|
|
);
|
|
|
|
if (items.length === 0 && helpers.looksLikeLoginWall()) {
|
|
return {
|
|
ok: false,
|
|
error_code: "auth_required",
|
|
error_message: `知乎没有返回 "${query}" 的搜索结果,可能需要先登录。`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
query,
|
|
items,
|
|
count: items.length,
|
|
},
|
|
source_url: location.href,
|
|
};
|
|
},
|
|
"zhihu/hot": async (args, helpers) => {
|
|
const limit = helpers.number(args.limit, 10);
|
|
const hrefTokens = ["/question/"];
|
|
const maxCandidates = Math.max(limit * 6, 30);
|
|
const main = document.querySelector("main") || document.body;
|
|
const collectCandidates = (maxCount) => {
|
|
if (!main) {
|
|
return [];
|
|
}
|
|
const results = [];
|
|
const seen = new Set();
|
|
for (const anchor of main.getElementsByTagName("a")) {
|
|
const href = anchor.getAttribute("href") || "";
|
|
if (!hrefTokens.some((token) => href.includes(token))) {
|
|
continue;
|
|
}
|
|
const title = helpers.text(anchor);
|
|
const url = helpers.absoluteUrl(href);
|
|
if (!title || !url || seen.has(url)) {
|
|
continue;
|
|
}
|
|
seen.add(url);
|
|
results.push({ anchor, title, url });
|
|
if (results.length >= maxCount) {
|
|
break;
|
|
}
|
|
}
|
|
return results;
|
|
};
|
|
await helpers.waitFor(() => collectCandidates(1)[0], 12000, 300);
|
|
const items = helpers.take(
|
|
collectCandidates(maxCandidates)
|
|
.map(({ anchor, title, url }, index) => {
|
|
const card = anchor.closest("section, div");
|
|
return {
|
|
rank: index + 1,
|
|
title,
|
|
url,
|
|
summary: helpers.text(card?.querySelector("p")),
|
|
};
|
|
})
|
|
.filter((item) => item.title && item.url),
|
|
limit,
|
|
);
|
|
|
|
if (items.length === 0 && helpers.looksLikeLoginWall()) {
|
|
return {
|
|
ok: false,
|
|
error_code: "auth_required",
|
|
error_message: "当前页面没有返回知乎热榜内容,可能需要先登录知乎。",
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
items,
|
|
count: items.length,
|
|
},
|
|
source_url: location.href,
|
|
};
|
|
},
|
|
"bilibili/search": async (args, helpers) => {
|
|
const query = String(args.query || "").trim();
|
|
const limit = helpers.number(args.limit, 10);
|
|
await helpers.waitFor(
|
|
() =>
|
|
document.querySelectorAll(
|
|
'a[href*="/video/BV"], a[href*="//www.bilibili.com/video/"]',
|
|
).length > 0,
|
|
12000,
|
|
300,
|
|
);
|
|
const links = Array.from(
|
|
document.querySelectorAll(
|
|
'a[href*="/video/BV"], a[href*="//www.bilibili.com/video/"]',
|
|
),
|
|
);
|
|
const items = helpers.take(
|
|
helpers.uniqueBy(
|
|
links
|
|
.map((anchor) => {
|
|
const card = anchor.closest("div");
|
|
return {
|
|
title: helpers.text(anchor),
|
|
url: helpers.absoluteUrl(anchor.getAttribute("href") || ""),
|
|
author: helpers.text(
|
|
card?.querySelector(
|
|
".bili-video-card__info--author, .up-name, .bili-video-card__info--bottom .bili-video-card__info--author",
|
|
),
|
|
),
|
|
summary: helpers.text(
|
|
card?.querySelector(
|
|
".bili-video-card__info--desc, .des, .bili-video-card__info--title",
|
|
),
|
|
),
|
|
};
|
|
})
|
|
.filter((item) => item.title && item.url),
|
|
(item) => item.url,
|
|
),
|
|
limit,
|
|
);
|
|
|
|
if (items.length === 0 && helpers.looksLikeLoginWall()) {
|
|
return {
|
|
ok: false,
|
|
error_code: "auth_required",
|
|
error_message: `B 站没有返回 "${query}" 的搜索结果,可能需要先登录。`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
query,
|
|
items,
|
|
count: items.length,
|
|
},
|
|
source_url: location.href,
|
|
};
|
|
},
|
|
"36kr/newsflash": async (args, helpers) => {
|
|
const limit = helpers.number(args.limit, 10);
|
|
await helpers.waitFor(
|
|
() => document.querySelectorAll('a[href*="/p/"]').length > 0,
|
|
12000,
|
|
300,
|
|
);
|
|
const links = Array.from(document.querySelectorAll('a[href*="/p/"]'));
|
|
const items = helpers.take(
|
|
helpers.uniqueBy(
|
|
links
|
|
.map((anchor) => {
|
|
const card = anchor.closest("div, article");
|
|
return {
|
|
title: helpers.text(anchor),
|
|
url: helpers.absoluteUrl(anchor.getAttribute("href") || ""),
|
|
summary: helpers.text(card?.querySelector("p")),
|
|
};
|
|
})
|
|
.filter((item) => item.title && item.url),
|
|
(item) => item.url,
|
|
),
|
|
limit,
|
|
);
|
|
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
items,
|
|
count: items.length,
|
|
},
|
|
source_url: location.href,
|
|
};
|
|
},
|
|
"linux-do/hot": async (args, helpers) => {
|
|
const limit = helpers.number(args.limit, 10);
|
|
const allowedPeriods = new Set([
|
|
"all",
|
|
"daily",
|
|
"weekly",
|
|
"monthly",
|
|
"yearly",
|
|
]);
|
|
const period = String(args.period || "weekly").trim().toLowerCase();
|
|
const normalizedPeriod = allowedPeriods.has(period) ? period : "weekly";
|
|
|
|
try {
|
|
const response = await fetch(
|
|
"/top.json?period=" + encodeURIComponent(normalizedPeriod),
|
|
{ credentials: "include" },
|
|
);
|
|
if (!response.ok) {
|
|
return {
|
|
ok: false,
|
|
error_code: "auth_required",
|
|
error_message: "linux.do 热门话题暂不可用,可能需要先登录。",
|
|
};
|
|
}
|
|
const payload = await response.json();
|
|
const topics = Array.isArray(payload?.topic_list?.topics)
|
|
? payload.topic_list.topics
|
|
: [];
|
|
const categories = Array.isArray(payload?.topic_list?.categories)
|
|
? payload.topic_list.categories
|
|
: Array.isArray(payload?.categories)
|
|
? payload.categories
|
|
: [];
|
|
const categoryMap = new Map(
|
|
categories.map((category) => [
|
|
category?.id,
|
|
String(category?.name || "").trim(),
|
|
]),
|
|
);
|
|
|
|
const items = helpers
|
|
.take(
|
|
topics.map((topic, index) => ({
|
|
rank: index + 1,
|
|
title: String(topic?.title || "").trim(),
|
|
replies: Math.max(0, Number(topic?.posts_count || 1) - 1),
|
|
views: Number(topic?.views || 0),
|
|
likes: Number(topic?.like_count || 0),
|
|
category:
|
|
categoryMap.get(topic?.category_id) ||
|
|
String(topic?.category_id || "").trim(),
|
|
})),
|
|
limit,
|
|
)
|
|
.filter((item) => item.title);
|
|
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
period: normalizedPeriod,
|
|
items,
|
|
count: items.length,
|
|
},
|
|
source_url: location.href,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
error_code: "runtime_error",
|
|
error_message:
|
|
error instanceof Error
|
|
? error.message
|
|
: "读取 linux.do 热门话题失败。",
|
|
};
|
|
}
|
|
},
|
|
"linux-do/categories": async (args, helpers) => {
|
|
const limit = helpers.number(args.limit, 10);
|
|
|
|
try {
|
|
const response = await fetch("/categories.json", {
|
|
credentials: "include",
|
|
});
|
|
if (!response.ok) {
|
|
return {
|
|
ok: false,
|
|
error_code: "auth_required",
|
|
error_message: "linux.do 分类列表暂不可用,可能需要先登录。",
|
|
};
|
|
}
|
|
const payload = await response.json();
|
|
const categories = Array.isArray(payload?.category_list?.categories)
|
|
? payload.category_list.categories
|
|
: [];
|
|
|
|
const items = helpers
|
|
.take(
|
|
categories.map((category) => ({
|
|
name: String(category?.name || "").trim(),
|
|
slug: String(category?.slug || "").trim(),
|
|
id: category?.id ?? null,
|
|
topics: category?.topic_count ?? 0,
|
|
description: String(category?.description_text || "")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.slice(0, 80),
|
|
})),
|
|
limit,
|
|
)
|
|
.filter((item) => item.name);
|
|
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
items,
|
|
count: items.length,
|
|
},
|
|
source_url: location.href,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
error_code: "runtime_error",
|
|
error_message:
|
|
error instanceof Error
|
|
? error.message
|
|
: "读取 linux.do 分类列表失败。",
|
|
};
|
|
}
|
|
},
|
|
"smzdm/search": async (args, helpers) => {
|
|
const query = String(args.query || "").trim();
|
|
const limit = helpers.number(args.limit, 10);
|
|
const rowSelector = "li.feed-row-wide";
|
|
|
|
await helpers.waitFor(
|
|
() =>
|
|
document.querySelectorAll(rowSelector).length > 0 ||
|
|
/搜索结果|相关好价|什么值得买/i.test(
|
|
document.body?.textContent || "",
|
|
),
|
|
12000,
|
|
300,
|
|
);
|
|
|
|
const rows = Array.from(document.querySelectorAll(rowSelector));
|
|
const items = helpers.take(
|
|
helpers.uniqueBy(
|
|
rows
|
|
.map((row, index) => {
|
|
const titleAnchor =
|
|
row.querySelector("h5.feed-block-title > a") ||
|
|
row.querySelector("h5 > a");
|
|
const rawHref =
|
|
titleAnchor?.getAttribute("href") || titleAnchor?.href || "";
|
|
const url = helpers.absoluteUrl(rawHref);
|
|
const title = (
|
|
titleAnchor?.getAttribute("title") || helpers.text(titleAnchor)
|
|
).trim();
|
|
const price = helpers.text(row.querySelector(".z-highlight"));
|
|
const mall = helpers.text(
|
|
row.querySelector(".z-feed-foot-r .feed-block-extras span") ||
|
|
row.querySelector(".z-feed-foot-r span"),
|
|
);
|
|
const commentsText = helpers
|
|
.text(row.querySelector(".feed-btn-comment"))
|
|
.replace(/[^\d]/g, "");
|
|
return {
|
|
rank: index + 1,
|
|
title,
|
|
url,
|
|
price,
|
|
mall,
|
|
comments: commentsText ? Number(commentsText) : 0,
|
|
};
|
|
})
|
|
.filter((item) => item.title && item.url),
|
|
(item) => item.url,
|
|
),
|
|
limit,
|
|
);
|
|
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
query,
|
|
items,
|
|
count: items.length,
|
|
},
|
|
source_url: location.href,
|
|
};
|
|
},
|
|
"yahoo-finance/quote": async (args, helpers) => {
|
|
const symbol = String(args.symbol || "").trim().toUpperCase();
|
|
if (!symbol) {
|
|
return {
|
|
ok: false,
|
|
error_code: "invalid_args",
|
|
error_message: "symbol 不能为空。",
|
|
};
|
|
}
|
|
|
|
const normalizeTextValue = (value) => {
|
|
const text = String(value ?? "")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
return text || null;
|
|
};
|
|
const normalizeNumberValue = (value) => {
|
|
if (value === undefined || value === null || value === "") {
|
|
return null;
|
|
}
|
|
const text = String(value)
|
|
.replace(/,/g, "")
|
|
.replace(/%/g, "")
|
|
.trim();
|
|
const number = Number(text);
|
|
return Number.isFinite(number) ? number : null;
|
|
};
|
|
const buildResult = (item) => ({
|
|
ok: true,
|
|
data: {
|
|
symbol,
|
|
items: [item],
|
|
count: 1,
|
|
},
|
|
source_url: location.href,
|
|
});
|
|
|
|
try {
|
|
const chartUrl =
|
|
"https://query1.finance.yahoo.com/v8/finance/chart/" +
|
|
encodeURIComponent(symbol) +
|
|
"?interval=1d&range=1d";
|
|
const response = await fetch(chartUrl, { credentials: "include" });
|
|
if (response.ok) {
|
|
const payload = await response.json();
|
|
const chart = payload?.chart?.result?.[0];
|
|
if (chart) {
|
|
const meta = chart.meta || {};
|
|
const previousClose =
|
|
meta.previousClose ?? meta.chartPreviousClose ?? null;
|
|
const price = meta.regularMarketPrice ?? null;
|
|
const change =
|
|
price != null && previousClose != null
|
|
? price - previousClose
|
|
: null;
|
|
const changePercent =
|
|
change != null && previousClose
|
|
? Number(((change / previousClose) * 100).toFixed(2))
|
|
: null;
|
|
return buildResult({
|
|
symbol: meta.symbol || symbol,
|
|
name: meta.shortName || meta.longName || symbol,
|
|
price: price != null ? Number(price.toFixed(2)) : null,
|
|
change: change != null ? Number(change.toFixed(2)) : null,
|
|
changePercent,
|
|
open: chart.indicators?.quote?.[0]?.open?.[0] ?? null,
|
|
high: meta.regularMarketDayHigh ?? null,
|
|
low: meta.regularMarketDayLow ?? null,
|
|
volume: meta.regularMarketVolume ?? null,
|
|
marketCap: meta.marketCap ?? null,
|
|
});
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
await helpers.waitFor(
|
|
() =>
|
|
document.querySelector('[data-testid="qsp-price"]') ||
|
|
document.querySelector(
|
|
'fin-streamer[data-field="regularMarketPrice"]',
|
|
) ||
|
|
document.querySelector("h1"),
|
|
12000,
|
|
300,
|
|
);
|
|
|
|
const titleText = normalizeTextValue(document.querySelector("h1")?.textContent);
|
|
const daysRangeText = document.querySelector('[data-test="DAYS_RANGE-value"]')
|
|
?.textContent;
|
|
const daysRangeParts = typeof daysRangeText === "string"
|
|
? daysRangeText.split(" - ")
|
|
: [];
|
|
const item = {
|
|
symbol,
|
|
name: titleText
|
|
? titleText.replace(/\s*\([^)]+\)\s*$/, "").trim() || symbol
|
|
: symbol,
|
|
price: normalizeNumberValue(
|
|
document.querySelector('[data-testid="qsp-price"]')?.textContent ||
|
|
document.querySelector(
|
|
'fin-streamer[data-field="regularMarketPrice"]',
|
|
)?.textContent,
|
|
),
|
|
change: normalizeNumberValue(
|
|
document.querySelector('[data-testid="qsp-price-change"]')
|
|
?.textContent ||
|
|
document.querySelector(
|
|
'fin-streamer[data-field="regularMarketChange"]',
|
|
)?.textContent,
|
|
),
|
|
changePercent: normalizeNumberValue(
|
|
document.querySelector('[data-testid="qsp-price-change-percent"]')
|
|
?.textContent ||
|
|
document.querySelector(
|
|
'fin-streamer[data-field="regularMarketChangePercent"]',
|
|
)?.textContent,
|
|
),
|
|
open: normalizeNumberValue(
|
|
document.querySelector('[data-test="OPEN-value"]')?.textContent,
|
|
),
|
|
high: normalizeNumberValue(daysRangeParts[1]),
|
|
low: normalizeNumberValue(daysRangeParts[0]),
|
|
volume: normalizeNumberValue(
|
|
document.querySelector('[data-test="TD_VOLUME-value"]')?.textContent,
|
|
),
|
|
marketCap: normalizeTextValue(
|
|
document.querySelector('[data-test="MARKET_CAP-value"]')?.textContent,
|
|
),
|
|
};
|
|
|
|
if (item.price != null || item.name !== symbol) {
|
|
return buildResult(item);
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
symbol,
|
|
items: [],
|
|
count: 0,
|
|
},
|
|
source_url: location.href,
|
|
};
|
|
},
|
|
};
|
|
|
|
const siteAdapterRunners = {
|
|
...legacySiteAdapterRunners,
|
|
...generatedSiteAdapterRunners,
|
|
};
|
|
|
|
async function executeCommand(commandData) {
|
|
const command = String(commandData.command || "").trim();
|
|
const target = commandData.target;
|
|
const text = commandData.text;
|
|
const payload = commandData.payload;
|
|
|
|
switch (command) {
|
|
case "click": {
|
|
const element = findElement(target);
|
|
if (!element) {
|
|
return { status: "error", error: `未找到点击目标: ${target}` };
|
|
}
|
|
element.click();
|
|
return { status: "success", message: "click 执行成功" };
|
|
}
|
|
case "type": {
|
|
const element = findElement(target);
|
|
if (!element) {
|
|
return { status: "error", error: `未找到输入目标: ${target}` };
|
|
}
|
|
const value = text == null ? "" : String(text);
|
|
if ("value" in element) {
|
|
element.focus();
|
|
element.value = value;
|
|
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
} else {
|
|
element.textContent = value;
|
|
}
|
|
return { status: "success", message: "type 执行成功" };
|
|
}
|
|
case "scroll":
|
|
case "scroll_page": {
|
|
scrollPage(text);
|
|
return { status: "success", message: "scroll 执行成功" };
|
|
}
|
|
case "get_page_info": {
|
|
await sendPageInfo("get_page_info");
|
|
return { status: "success", message: "页面信息已回传" };
|
|
}
|
|
case "refresh_page": {
|
|
window.location.reload();
|
|
return { status: "success", message: "页面刷新中" };
|
|
}
|
|
case "go_back": {
|
|
window.history.back();
|
|
return { status: "success", message: "执行后退" };
|
|
}
|
|
case "go_forward": {
|
|
window.history.forward();
|
|
return { status: "success", message: "执行前进" };
|
|
}
|
|
case "run_adapter": {
|
|
const adapterName = String(payload?.adapter_name || "").trim();
|
|
if (!adapterName) {
|
|
return { status: "error", error: "run_adapter 缺少 adapter_name" };
|
|
}
|
|
const runner = siteAdapterRunners[adapterName];
|
|
if (typeof runner !== "function") {
|
|
return {
|
|
status: "error",
|
|
error: `当前扩展内未注册站点适配器: ${adapterName}`,
|
|
};
|
|
}
|
|
const adapterArgs =
|
|
payload && typeof payload.args === "object" && !Array.isArray(payload.args)
|
|
? payload.args
|
|
: {};
|
|
try {
|
|
const result = await runner(adapterArgs, siteAdapterHelpers);
|
|
return {
|
|
status: "success",
|
|
message: "run_adapter 执行成功",
|
|
data: result ?? null,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
status: "error",
|
|
error: error?.message || String(error),
|
|
};
|
|
}
|
|
}
|
|
default:
|
|
return { status: "error", error: `不支持的命令: ${command}` };
|
|
}
|
|
}
|
|
|
|
async function sendPageInfo(reason) {
|
|
const markdown = buildMarkdown();
|
|
const payload = {
|
|
type: "PAGE_INFO_UPDATE",
|
|
data: {
|
|
reason,
|
|
title: document.title || "",
|
|
url: window.location.href,
|
|
markdown,
|
|
},
|
|
};
|
|
await chrome.runtime.sendMessage(payload);
|
|
}
|
|
|
|
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
|
const type = request?.type;
|
|
|
|
if (type === "REQUEST_PAGE_CAPTURE") {
|
|
sendPageInfo(request?.data?.reason || "manual")
|
|
.then(() => sendResponse({ status: "success" }))
|
|
.catch((error) =>
|
|
sendResponse({
|
|
status: "error",
|
|
error: error?.message || String(error),
|
|
}),
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (type === "EXECUTE_COMMAND") {
|
|
executeCommand(request?.data || {})
|
|
.then(async (result) => {
|
|
if (request?.data?.wait_for_page_info === true) {
|
|
setTimeout(() => {
|
|
sendPageInfo("wait_for_page_info");
|
|
}, 400);
|
|
}
|
|
sendResponse(result);
|
|
})
|
|
.catch((error) =>
|
|
sendResponse({
|
|
status: "error",
|
|
error: error?.message || String(error),
|
|
}),
|
|
);
|
|
return true;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
setTimeout(() => {
|
|
sendPageInfo("content_script_ready").catch(() => {});
|
|
}, 800);
|
|
})(); // 结束 IIFE
|