AISummary-Cloudflare/public/summary.js

209 lines
6.2 KiB
JavaScript

const DEFAULT_POST_SELECTOR = ".post";
const DEFAULT_STYLE = "tldr";
function $(root, selector) {
const el = root.querySelector(selector);
if (!el) throw new Error(`Missing element: ${selector}`);
return el;
}
function compactText(text) {
return text.replace(/\s+/g, " ").trim();
}
function escapeHtml(s) {
return s
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
async function fetchJson(url, init) {
const res = await fetch(url, init);
const data = await res.json().catch(() => ({}));
return { res, data };
}
function renderMarkdownish(container, style, summary) {
if (style === "bullets") {
const lines = summary
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const items = lines
.map((l) => (l.startsWith("-") ? l.replace(/^-+\s*/, "") : l))
.filter(Boolean);
container.innerHTML = `<ul>${items
.map((i) => `<li>${escapeHtml(i)}</li>`)
.join("")}</ul>`;
return;
}
if (style === "outline") {
const blocks = summary.split("\n");
const html = [];
for (const line of blocks) {
const l = line.trimEnd();
if (!l) continue;
if (l.startsWith("###")) {
html.push(`<h4>${escapeHtml(l.replace(/^###\s*/, ""))}</h4>`);
} else {
html.push(`<p>${escapeHtml(l)}</p>`);
}
}
container.innerHTML = html.join("");
return;
}
container.textContent = summary;
}
async function ensureSummary(root) {
const widget = root;
const title = widget.dataset.title || "摘要";
const autoload =
widget.dataset.autoload !== "false" && widget.dataset.autoload !== "0";
if (widget.dataset.expanded == null) widget.dataset.expanded = "false";
const needsScaffold =
!widget.querySelector(".post-summary__status") ||
!widget.querySelector(".post-summary__content") ||
!widget.querySelector(".post-summary__styles");
if (needsScaffold) {
widget.innerHTML = `
<div class="post-summary__header">
<div class="post-summary__title"></div>
<div class="post-summary__styles" aria-label="Summary styles"></div>
</div>
<div class="post-summary__body">
<div class="post-summary__status post-summary__muted">等待生成…</div>
<div class="post-summary__content"></div>
</div>
`.trim();
const titleEl = widget.querySelector(".post-summary__title");
if (titleEl) titleEl.textContent = title;
} else {
const titleEl = widget.querySelector(".post-summary__title");
if (titleEl && !titleEl.textContent) titleEl.textContent = title;
}
const postSelector = widget.dataset.postSelector || DEFAULT_POST_SELECTOR;
const inferredBase = (() => {
try {
return new URL(import.meta.url).origin;
} catch {
return "";
}
})();
const apiBase = (widget.dataset.apiBase || inferredBase).replace(/\/$/, "");
const urlKey = widget.dataset.url || location.pathname + location.search;
const statusEl = $(widget, ".post-summary__status");
const contentEl = $(widget, ".post-summary__content");
const stylesEl = $(widget, ".post-summary__styles");
const updatePressed = (style) => {
for (const btn of stylesEl.querySelectorAll("button[data-style]")) {
btn.setAttribute(
"aria-pressed",
btn.dataset.style === style ? "true" : "false"
);
}
};
const setStyle = async (style) => {
widget.dataset.expanded = "true";
widget.dataset.style = style;
updatePressed(style);
statusEl.textContent = "读取缓存…";
contentEl.textContent = "";
const { res, data } = await fetchJson(
`${apiBase}/api/summary?url=${encodeURIComponent(urlKey)}&style=${encodeURIComponent(
style
)}`
);
if (res.ok && typeof data?.summary === "string" && data.summary) {
statusEl.textContent = data.cached ? "来自 KV 缓存" : "已生成";
renderMarkdownish(contentEl, style, data.summary || "");
return;
}
const isCacheMiss = res.status === 404 || data?.error === "Not found";
if (!isCacheMiss) {
statusEl.textContent = `请求失败:${res.status}`;
contentEl.textContent = data?.error || "Unknown error";
return;
}
const postEl = document.querySelector(postSelector);
if (!postEl) {
statusEl.textContent = `未找到文章元素:${postSelector}`;
return;
}
const rawText = compactText(postEl.innerText || postEl.textContent || "");
if (!rawText) {
statusEl.textContent = "文章内容为空";
return;
}
const MAX_CHARS = 20000;
const text = rawText.slice(0, MAX_CHARS);
statusEl.textContent = "AI 生成中…";
const created = await fetchJson(`${apiBase}/api/summary`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: urlKey, style, text }),
});
if (!created.res.ok) {
statusEl.textContent = `生成失败:${created.res.status}`;
contentEl.textContent = created.data?.error || "Unknown error";
return;
}
statusEl.textContent = created.data.cached ? "来自 KV 缓存" : "已生成";
renderMarkdownish(contentEl, style, created.data.summary || "");
};
stylesEl.innerHTML = "";
const styleList = [
{ key: "tldr", label: "TL;DR" },
{ key: "bullets", label: "要点" },
{ key: "outline", label: "结构化" },
{ key: "keywords", label: "关键词" },
];
for (const { key, label } of styleList) {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.style = key;
btn.textContent = label;
btn.addEventListener("click", () => setStyle(key));
stylesEl.appendChild(btn);
}
const initialStyle = widget.dataset.style || DEFAULT_STYLE;
widget.dataset.style = initialStyle;
updatePressed(initialStyle);
if (autoload) {
widget.dataset.expanded = "true";
await setStyle(initialStyle);
} else {
widget.dataset.expanded = "false";
statusEl.textContent = "点击上方风格生成摘要…";
contentEl.textContent = "";
}
}
for (const widget of document.querySelectorAll(".post-summary")) {
ensureSummary(widget).catch((err) => {
const statusEl = widget.querySelector(".post-summary__status");
if (statusEl) statusEl.textContent = `初始化失败:${err.message || err}`;
});
}