// ==UserScript==
// @name Folo 网站增强工具 (v13.4 flomo集成版)
// @namespace https://github.com/moonjoin/tampermonkey-scripts
// @version 13.4.4
// @description Folo 增强:Jina Reader + Readability + 启发式三级抓取 + AI 总结 + 自动总结 + 后续对话 + 多配置管理 + 坚果云 WebDAV 同步 + 复制对话 + 保存到 flomo
// @author 次元饺子
// @icon https://img.icons8.com/?size=100&id=90385&format=png&color=000000
// @match https://app.folo.is/*
// @grant GM_addStyle
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @connect *
// @run-at document-start
// @license MIT
// @downloadURL https://update.greasyfork.icu/scripts/576150/Folo%20%E7%BD%91%E7%AB%99%E5%A2%9E%E5%BC%BA%E5%B7%A5%E5%85%B7%20%28v134%20flomo%E9%9B%86%E6%88%90%E7%89%88%29.user.js
// @updateURL https://update.greasyfork.icu/scripts/576150/Folo%20%E7%BD%91%E7%AB%99%E5%A2%9E%E5%BC%BA%E5%B7%A5%E5%85%B7%20%28v134%20flomo%E9%9B%86%E6%88%90%E7%89%88%29.meta.js
// ==/UserScript==
(function() {
'use strict';
console.log("🚀 Folo 增强脚本 v13.4 (flomo集成版) 已启动");
// ==================== 0. 内联 Markdown 渲染器(含 GFM 表格) ====================
const _md = (function() {
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&').replace(//g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
function renderInline(text) {
let s = escapeHtml(text);
s = s.replace(/`([^`]+?)`/g, '$1');
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
'');
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
'$1');
s = s.replace(/\*\*([^\*]+?)\*\*/g, '$1');
s = s.replace(/__([^_]+?)__/g, '$1');
s = s.replace(/(^|[^\*])\*([^\*\n]+?)\*(?!\*)/g, '$1$2');
s = s.replace(/(^|[^_])_([^_\n]+?)_(?!_)/g, '$1$2');
s = s.replace(/~~([^~]+?)~~/g, '
$1');
return s;
}
// 解析表格行 "| a | b | c |" -> ["a","b","c"]
function parseTableRow(line) {
let s = line.trim();
if (s.startsWith('|')) s = s.slice(1);
if (s.endsWith('|')) s = s.slice(0, -1);
const cells = [];
let buf = '';
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === '\\' && s[i + 1] === '|') { buf += '|'; i++; continue; }
if (ch === '|') { cells.push(buf.trim()); buf = ''; continue; }
buf += ch;
}
cells.push(buf.trim());
return cells;
}
// 判断是否是分隔行 |---|:--:|---:|
function isTableSeparator(line) {
if (!/\|/.test(line)) return false;
const cells = parseTableRow(line);
if (cells.length === 0) return false;
return cells.every(c => /^:?-{1,}:?$/.test(c.trim()));
}
// 从分隔行解析每列对齐方式
function parseAligns(sepLine) {
return parseTableRow(sepLine).map(c => {
const t = c.trim();
const left = t.startsWith(':');
const right = t.endsWith(':');
if (left && right) return 'center';
if (right) return 'right';
if (left) return 'left';
return '';
});
}
return function parse(md) {
if (!md) return '';
md = String(md).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const lines = md.split('\n');
let html = '', i = 0, inCode = false, codeLang = '', codeBuf = [];
let listStack = [];
function closeAllLists() { while (listStack.length) html += '' + listStack.pop().type + '>'; }
while (i < lines.length) {
const line = lines[i];
const fence = line.match(/^```(\w*)\s*$/);
if (fence) {
if (!inCode) { closeAllLists(); inCode = true; codeLang = fence[1] || ''; codeBuf = []; }
else { html += '
' + escapeHtml(codeBuf.join('\n')) + ''; inCode = false; codeLang = ''; codeBuf = []; }
i++; continue;
}
if (inCode) { codeBuf.push(line); i++; continue; }
if (/^\s*$/.test(line)) { closeAllLists(); i++; continue; }
// GFM 表格识别
if (/\|/.test(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
closeAllLists();
const headers = parseTableRow(line);
const aligns = parseAligns(lines[i + 1]);
i += 2;
const rows = [];
while (i < lines.length && /\|/.test(lines[i]) && !/^\s*$/.test(lines[i])) {
if (/^```/.test(lines[i]) || /^#{1,6}\s+/.test(lines[i])) break;
rows.push(parseTableRow(lines[i]));
i++;
}
let t = '| ${renderInline(h)} | `; }); t += '
|---|
| ${renderInline(cell)} | `; } t += '
' + parse(buf.join('\n')) + ''; continue; } const ul = line.match(/^(\s*)[-*+]\s+(.*)$/); const ol = line.match(/^(\s*)\d+\.\s+(.*)$/); if (ul || ol) { const m = ul || ol; const type = ul ? 'ul' : 'ol'; const indent = m[1].length; const content = m[2]; while (listStack.length && listStack[listStack.length - 1].indent > indent) html += '' + listStack.pop().type + '>'; if (listStack.length && listStack[listStack.length - 1].indent === indent && listStack[listStack.length - 1].type !== type) html += '' + listStack.pop().type + '>'; if (!listStack.length || listStack[listStack.length - 1].indent < indent) { html += '<' + type + '>
' + renderInline(pBuf.join(' ').trim()) + '
'; } if (inCode) html += '' + escapeHtml(codeBuf.join('\n')) + '';
closeAllLists();
return html;
};
})();
// ==================== 1. 工具函数 ====================
function normalizeApiUrl(url) {
if (!url) return "";
let cleanUrl = url.trim();
if (cleanUrl.endsWith('#')) return cleanUrl.slice(0, -1);
if (cleanUrl.includes('/chat/completions')) return cleanUrl;
if (cleanUrl.endsWith('/')) return cleanUrl + 'chat/completions';
return cleanUrl + '/v1/chat/completions';
}
function getModelsUrl(chatUrl) { return chatUrl.replace(/\/chat\/completions$/, '/models'); }
function getCleanArticleText(articleNode) {
if (!articleNode) return "";
const clone = articleNode.cloneNode(true);
clone.querySelectorAll('.custom-copy-btn, #my-custom-ai-wrapper').forEach(el => el.remove());
clone.querySelectorAll('button').forEach(el => el.remove());
clone.querySelectorAll('a').forEach(a => {
if (a.innerText.includes("阅读完整话题")) {
if (a.parentElement && a.parentElement.tagName === 'P') a.parentElement.remove();
else a.remove();
}
});
const metaRegex = /^\s*\d+\s*个帖子\s*[\-—]\s*\d+\s*位参与者/i;
clone.querySelectorAll('p').forEach(p => { if (metaRegex.test(p.innerText)) p.remove(); });
return clone.innerText.trim();
}
function getOriginalUrl(articleNode) {
if (!articleNode) return null;
const titleLink = articleNode.querySelector('a[target="_blank"][class*="text-[1.7rem]"]')
|| document.querySelector('a[target="_blank"][class*="text-[1.7rem]"]');
if (titleLink && titleLink.href && /^https?:\/\//.test(titleLink.href)) return titleLink.href;
const firstExternal = articleNode.querySelector('a[target="_blank"][href^="http"]');
if (firstExternal) return firstExternal.href;
return null;
}
function getArticleTitle(articleNode) {
if (!articleNode) return "文章";
const titleEl = articleNode.querySelector('a[class*="text-[1.7rem]"]')
|| document.querySelector('a[class*="text-[1.7rem]"]');
if (titleEl) return titleEl.innerText.trim();
return document.title || "文章";
}
// ==================== 2. 三级抓取策略 ====================
function fetchViaJinaReader(url) {
return new Promise((resolve, reject) => {
const jinaUrl = "https://r.jina.ai/" + url;
GM_xmlhttpRequest({
method: "GET",
url: jinaUrl,
headers: {
"Accept": "text/plain",
"X-Return-Format": "markdown",
"X-Timeout": "20"
},
timeout: 30000,
onload: (res) => {
if (res.status >= 200 && res.status < 300 && res.responseText && res.responseText.length > 100) {
let md = res.responseText;
let title = "";
const titleMatch = md.match(/^Title:\s*(.+)$/m);
if (titleMatch) title = titleMatch[1].trim();
md = md.replace(/^Title:\s*.+\n/m, '')
.replace(/^URL Source:\s*.+\n/m, '')
.replace(/^Published Time:\s*.+\n/m, '')
.replace(/^Markdown Content:\s*\n?/m, '')
.trim();
resolve({ title: title, text: md, length: md.length, method: 'Jina Reader 🌟' });
} else {
reject(new Error(`Jina HTTP ${res.status}`));
}
},
onerror: () => reject(new Error("Jina 网络错误")),
ontimeout: () => reject(new Error("Jina 超时"))
});
});
}
let _Readability = null;
let _readabilityLoading = null;
function loadReadability() {
if (_Readability) return Promise.resolve(_Readability);
if (_readabilityLoading) return _readabilityLoading;
_readabilityLoading = new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: "https://cdn.jsdelivr.net/npm/@mozilla/readability@0.5.0/Readability.js",
timeout: 15000,
onload: (res) => {
if (res.status !== 200) return reject(new Error("Readability 下载失败"));
try {
const sandbox = { module: { exports: {} }, exports: {} };
const code = res.responseText + '\n;return (typeof Readability !== "undefined") ? Readability : (module.exports || exports);';
const fn = new Function('module', 'exports', code);
_Readability = fn(sandbox.module, sandbox.exports);
if (!_Readability) return reject(new Error("Readability 加载后为空"));
resolve(_Readability);
} catch (e) { reject(e); }
},
onerror: () => reject(new Error("Readability CDN 网络错误")),
ontimeout: () => reject(new Error("Readability CDN 超时"))
});
});
return _readabilityLoading;
}
function fetchOriginalHtml(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: url,
headers: {
"User-Agent": navigator.userAgent,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Referer": new URL(url).origin + "/"
},
timeout: 20000,
onload: (res) => {
if (res.status >= 200 && res.status < 400) resolve(res.responseText);
else reject(new Error(`HTTP ${res.status}`));
},
onerror: () => reject(new Error("网络错误")),
ontimeout: () => reject(new Error("请求超时"))
});
});
}
async function fetchViaReadability(url) {
const Readability = await loadReadability();
const html = await fetchOriginalHtml(url);
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
try {
if (!doc.querySelector('base')) {
const base = doc.createElement('base');
base.href = url;
doc.head && doc.head.insertBefore(base, doc.head.firstChild);
}
} catch(e){}
const article = new Readability(doc.cloneNode(true), { charThreshold: 200 }).parse();
if (!article || !article.textContent || article.textContent.length < 200) {
throw new Error("Readability 提取过短: " + (article ? article.textContent.length : 0));
}
const text = article.textContent.trim().replace(/\n{3,}/g, '\n\n');
return { title: article.title || "", text: text, length: text.length, method: 'Readability.js 📖' };
}
function extractArticleFromHtml(htmlString, sourceUrl) {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
try {
const base = doc.createElement('base');
base.href = sourceUrl;
doc.head && doc.head.appendChild(base);
} catch(e){}
let title = "";
const ogTitle = doc.querySelector('meta[property="og:title"]');
if (ogTitle) title = ogTitle.getAttribute('content') || "";
if (!title) { const h1 = doc.querySelector('h1'); if (h1) title = h1.innerText || h1.textContent || ""; }
if (!title && doc.title) title = doc.title;
const removeSelectors = [
'script', 'style', 'noscript', 'iframe', 'svg',
'nav', 'header', 'footer', 'aside',
'.nav', '.navbar', '.header', '.footer', '.sidebar', '.aside',
'.comment', '.comments', '#comments', '.comment-list',
'.advertisement', '.ads', '.ad', '.advert',
'.share', '.social', '.related', '.recommend', '.recommendation',
'.breadcrumb', '.pagination',
'[class*="sidebar"]', '[id*="sidebar"]',
'[class*="comment"]', '[id*="comment"]',
'[class*="recommend"]', '[class*="related"]'
];
removeSelectors.forEach(sel => { try { doc.querySelectorAll(sel).forEach(el => el.remove()); } catch(e){} });
const candidateSelectors = [
'article', '[itemprop="articleBody"]',
'.post-content', '.entry-content', '.article-content', '.article-body',
'.post-body', '.content-article', '.markdown-body', '.rich_media_content',
'#content', '#main-content', '#article', '#post', 'main'
];
let bestNode = null, bestScore = 0;
for (const sel of candidateSelectors) {
doc.querySelectorAll(sel).forEach(node => {
const text = (node.innerText || node.textContent || "").trim();
const score = text.length + node.querySelectorAll('p').length * 50;
if (score > bestScore) { bestScore = score; bestNode = node; }
});
}
if (!bestNode || bestScore < 200) {
doc.querySelectorAll('div, section').forEach(div => {
const text = (div.innerText || div.textContent || "").trim();
if (text.length < 200) return;
const links = div.querySelectorAll('a');
let linkTextLen = 0;
links.forEach(a => linkTextLen += (a.innerText || "").length);
const linkRatio = linkTextLen / text.length;
if (linkRatio > 0.5) return;
const pCount = div.querySelectorAll('p').length;
const score = text.length * (1 - linkRatio) + pCount * 30;
if (score > bestScore) { bestScore = score; bestNode = div; }
});
}
let bodyText = "";
if (bestNode) {
bestNode.querySelectorAll('script, style, noscript').forEach(el => el.remove());
bodyText = (bestNode.innerText || bestNode.textContent || "").trim();
bodyText = bodyText.replace(/\n{3,}/g, '\n\n').replace(/[ \t]+\n/g, '\n');
}
return { title: title.trim(), text: bodyText, length: bodyText.length, method: '启发式算法 🔧' };
}
async function fetchViaHeuristic(url) {
const html = await fetchOriginalHtml(url);
const parsed = extractArticleFromHtml(html, url);
if (!parsed.text || parsed.text.length < 200) {
throw new Error("启发式提取过短: " + parsed.length);
}
return parsed;
}
async function smartFetchArticle(url, strategies, onProgress) {
const errors = [];
for (const strat of strategies) {
try {
onProgress && onProgress(strat);
let result;
if (strat === 'jina') result = await fetchViaJinaReader(url);
else if (strat === 'readability') result = await fetchViaReadability(url);
else if (strat === 'heuristic') result = await fetchViaHeuristic(url);
else continue;
if (result && result.text && result.text.length >= 200) {
result.attemptedStrategies = errors.map(e => e.strat);
return result;
}
errors.push({ strat, err: '内容过短' });
} catch (e) {
console.warn(`[Folo增强] 策略 ${strat} 失败:`, e.message);
errors.push({ strat, err: e.message });
}
}
const errMsg = errors.map(e => `${e.strat}: ${e.err}`).join(' | ');
throw new Error("所有策略都失败:" + errMsg);
}
// ==================== 3. 配置管理 ====================
const DEFAULT_PROFILE = {
id: "default", name: "默认配置",
apiUrl: "https://api.openai.com", apiKey: "", model: "gpt-3.5-turbo",
prompt: "请简要总结以下文章内容,提取 3-5 个核心观点,使用中文回答:"
};
function getFetchFulltextEnabled() { return GM_getValue("ai_fetch_fulltext", true) !== false; }
function setFetchFulltextEnabled(v) { GM_setValue("ai_fetch_fulltext", !!v); }
function getMaxChars() { return GM_getValue("ai_max_chars", 12000); }
function setMaxChars(v) { GM_setValue("ai_max_chars", v); }
function getAutoSummarizeEnabled() { return GM_getValue("ai_auto_summarize", false) === true; }
function setAutoSummarizeEnabled(v) { GM_setValue("ai_auto_summarize", !!v); }
function getExtractStrategies() {
return GM_getValue("ai_extract_strategies", ['jina', 'readability', 'heuristic']);
}
function setExtractStrategies(arr) { GM_setValue("ai_extract_strategies", arr); }
function getFlomoApiUrl() { return GM_getValue("ai_flomo_api_url", ""); }
function setFlomoApiUrl(v) { GM_setValue("ai_flomo_api_url", String(v || "").trim()); }
function getProfiles() {
let profiles = GM_getValue("ai_profiles", []);
if (!profiles || profiles.length === 0) { profiles = [DEFAULT_PROFILE]; GM_setValue("ai_profiles", profiles); }
return profiles;
}
function getCurrentProfileId() { return GM_getValue("ai_current_profile_id", "default"); }
function getActiveConfig() {
const profiles = getProfiles();
return profiles.find(p => p.id === getCurrentProfileId()) || profiles[0];
}
function saveProfiles(profiles, activeId) {
GM_setValue("ai_profiles", profiles);
if (activeId) GM_setValue("ai_current_profile_id", activeId);
}
// ==================== 3.5. 坚果云 WebDAV 同步 ====================
const WEBDAV_BASE = 'https://dav.jianguoyun.com/dav/';
const WEBDAV_FOLDER = 'folo-sync';
const WEBDAV_FILENAME = 'folo-ai-sync.json';
const WEBDAV_FOLDER_URL = WEBDAV_BASE + WEBDAV_FOLDER + '/';
const WEBDAV_FILE_URL = WEBDAV_FOLDER_URL + WEBDAV_FILENAME;
function getWebDAVUser() { return GM_getValue("webdav_user", ""); }
function setWebDAVUser(v) { GM_setValue("webdav_user", v || ""); }
function getWebDAVPass() { return GM_getValue("webdav_pass", ""); }
function setWebDAVPass(v) { GM_setValue("webdav_pass", v || ""); }
function getWebDAVAuth() {
const user = getWebDAVUser();
const pass = getWebDAVPass();
if (!user || !pass) return null;
return 'Basic ' + btoa(unescape(encodeURIComponent(user + ':' + pass)));
}
function buildLocalSyncPayload() {
return {
version: 2,
updatedAt: new Date().toISOString(),
profiles: getProfiles(),
currentProfileId: getCurrentProfileId(),
extractStrategies: getExtractStrategies(),
autoSummarize: getAutoSummarizeEnabled(),
fetchFulltext: getFetchFulltextEnabled(),
maxChars: getMaxChars(),
flomoApiUrl: getFlomoApiUrl()
};
}
function applyRemotePayloadToLocal(remote) {
if (!remote || typeof remote !== 'object') throw new Error("云端数据格式错误");
if (Array.isArray(remote.profiles) && remote.profiles.length > 0) {
saveProfiles(remote.profiles, remote.currentProfileId || remote.profiles[0].id);
}
if (Array.isArray(remote.extractStrategies)) setExtractStrategies(remote.extractStrategies);
if (typeof remote.autoSummarize === 'boolean') setAutoSummarizeEnabled(remote.autoSummarize);
if (typeof remote.fetchFulltext === 'boolean') setFetchFulltextEnabled(remote.fetchFulltext);
if (typeof remote.maxChars === 'number') setMaxChars(remote.maxChars);
if (typeof remote.flomoApiUrl === 'string') setFlomoApiUrl(remote.flomoApiUrl);
}
function mergeProfiles(baseList, patchList) {
const map = new Map();
baseList.forEach(p => map.set(p.id, { ...p }));
patchList.forEach(p => {
if (map.has(p.id)) {
map.set(p.id, { ...map.get(p.id), ...p });
} else {
map.set(p.id, { ...p });
}
});
return Array.from(map.values());
}
function webdavRequest(method, url, opts) {
opts = opts || {};
return new Promise((resolve, reject) => {
const auth = getWebDAVAuth();
if (!auth) return reject(new Error("请先填写坚果云账号和应用密码"));
const headers = { 'Authorization': auth };
if (opts.contentType) headers['Content-Type'] = opts.contentType;
if (method === 'PUT') headers['Overwrite'] = 'T';
GM_xmlhttpRequest({
method: method,
url: url,
headers: headers,
data: opts.data,
timeout: 20000,
onload: (res) => {
console.log(`[WebDAV ${method}]`, url, '→', res.status);
resolve(res);
},
onerror: () => reject(new Error("网络错误")),
ontimeout: () => reject(new Error("请求超时"))
});
});
}
async function ensureWebDAVFolder() {
const res = await webdavRequest('MKCOL', WEBDAV_FOLDER_URL);
if (res.status === 201 || res.status === 405 || res.status === 301) return true;
if (res.status === 401) throw new Error("认证失败,请检查邮箱和应用密码");
if (res.status === 403) throw new Error("权限不足,请确认应用密码有写入权限");
console.warn("[WebDAV] MKCOL 返回非预期状态:", res.status, res.responseText);
return true;
}
async function webdavDownload() {
const res = await webdavRequest('GET', WEBDAV_FILE_URL);
if (res.status === 200) {
try { return JSON.parse(res.responseText); }
catch(e) { throw new Error("云端文件不是合法 JSON"); }
}
if (res.status === 404) return null;
if (res.status === 401) throw new Error("认证失败,请检查邮箱和应用密码");
throw new Error(`下载失败 HTTP ${res.status}`);
}
async function webdavUploadRaw(payload) {
await ensureWebDAVFolder();
const res = await webdavRequest('PUT', WEBDAV_FILE_URL, {
data: JSON.stringify(payload, null, 2),
contentType: 'application/json'
});
if (res.status >= 200 && res.status < 300) return true;
if (res.status === 401) throw new Error("认证失败,请检查邮箱和应用密码");
if (res.status === 403) throw new Error("权限不足或路径不允许写入");
if (res.status === 404) throw new Error("路径不存在(文件夹创建失败?)");
if (res.status === 409) throw new Error("冲突,可能是父文件夹不存在");
throw new Error(`上传失败 HTTP ${res.status} ${res.responseText ? '· ' + res.responseText.substring(0,80) : ''}`);
}
async function syncUploadIncremental() {
const local = buildLocalSyncPayload();
let remote = null;
try { remote = await webdavDownload(); } catch(e) {
if (!/HTTP 404/.test(e.message)) throw e;
}
let merged;
if (!remote) {
merged = local;
} else {
merged = {
version: 2,
updatedAt: new Date().toISOString(),
profiles: mergeProfiles(remote.profiles || [], local.profiles || []),
currentProfileId: local.currentProfileId || remote.currentProfileId,
extractStrategies: local.extractStrategies || remote.extractStrategies,
autoSummarize: typeof local.autoSummarize === 'boolean' ? local.autoSummarize : remote.autoSummarize,
fetchFulltext: typeof local.fetchFulltext === 'boolean' ? local.fetchFulltext : remote.fetchFulltext,
maxChars: typeof local.maxChars === 'number' ? local.maxChars : remote.maxChars,
flomoApiUrl: local.flomoApiUrl || remote.flomoApiUrl || ""
};
}
await webdavUploadRaw(merged);
return merged;
}
async function syncDownloadIncremental() {
const remote = await webdavDownload();
if (!remote) throw new Error("云端没有同步文件,请先上传一次");
const local = buildLocalSyncPayload();
const merged = {
version: 2,
updatedAt: new Date().toISOString(),
profiles: mergeProfiles(local.profiles || [], remote.profiles || []),
currentProfileId: remote.currentProfileId || local.currentProfileId,
extractStrategies: remote.extractStrategies || local.extractStrategies,
autoSummarize: typeof remote.autoSummarize === 'boolean' ? remote.autoSummarize : local.autoSummarize,
fetchFulltext: typeof remote.fetchFulltext === 'boolean' ? remote.fetchFulltext : local.fetchFulltext,
maxChars: typeof remote.maxChars === 'number' ? remote.maxChars : local.maxChars,
flomoApiUrl: remote.flomoApiUrl || local.flomoApiUrl || ""
};
applyRemotePayloadToLocal(merged);
return merged;
}
async function syncForceUploadOverwrite() {
const local = buildLocalSyncPayload();
await webdavUploadRaw(local);
return local;
}
// ==================== 4. 菜单命令 ====================
GM_registerMenuCommand("⚙️ 设置 AI API", showSettingsModal);
GM_registerMenuCommand("🔁 切换『抓取原文全文』(当前: " + (getFetchFulltextEnabled() ? "开" : "关") + ")", () => {
setFetchFulltextEnabled(!getFetchFulltextEnabled());
alert("已切换。当前:" + (getFetchFulltextEnabled() ? "开启抓取原文" : "仅使用 Folo 预览"));
});
GM_registerMenuCommand("🤖 切换『自动总结』(当前: " + (getAutoSummarizeEnabled() ? "开" : "关") + ")", () => {
setAutoSummarizeEnabled(!getAutoSummarizeEnabled());
alert("已切换。当前:" + (getAutoSummarizeEnabled() ? "开启自动总结" : "关闭自动总结"));
});
// ==================== 4. 样式 ====================
GM_addStyle(`
article[data-testid="entry-render"], #follow-entry-render { user-select: text !important; -webkit-user-select: text !important; }
.folo-native-ai-hidden { display: none !important; }
.custom-copy-btn { position: absolute !important; top: 0px; right: 0px; z-index: 50; padding: 4px 10px !important; background: rgba(59, 130, 246, 0.9); color: white; border: none; border-radius: 0 0 0 8px; cursor: pointer; font-size: 12px; opacity: 0.6; }
.custom-copy-btn:hover { opacity: 1; }
#my-custom-ai-wrapper { margin: 1.5rem 0; width: 100%; position: relative; z-index: 10; animation: fadeIn 0.4s ease; transition: all 0.3s; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
.my-ai-box { padding: 1rem; border-radius: 12px; border: 1px solid rgba(139, 92, 246, 0.3); background: linear-gradient(135deg, rgba(239, 246, 255, 0.8) 0%, rgba(250, 245, 255, 0.8) 100%); backdrop-filter: blur(8px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); color: #1f2937; }
.dark .my-ai-box { background: linear-gradient(135deg, rgba(30, 20, 60, 0.7) 0%, rgba(20, 30, 60, 0.7) 100%); border-color: rgba(139, 92, 246, 0.4); color: #e5e7eb; }
.my-ai-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; flex-wrap: wrap; gap: 6px; }
.my-ai-title { font-weight: 700; font-size: 0.95rem; background: linear-gradient(to right, #7c3aed, #2563eb); -webkit-background-clip: text; color: transparent; }
.my-ai-btn { background: linear-gradient(to right, #7c3aed, #2563eb); color: white; border: none; padding: 5px 14px; border-radius: 99px; cursor: pointer; font-weight: 600; font-size: 0.8rem; }
.my-ai-btn:disabled { background: #999; cursor: not-allowed; }
.my-ai-mode-toggle { font-size: 0.75rem; cursor: pointer; padding: 3px 8px; border-radius: 99px; background: rgba(139,92,246,0.1); color: #7c3aed; border: 1px solid rgba(139,92,246,0.3); user-select: none; }
.my-ai-mode-toggle.active { background: rgba(16,185,129,0.15); color: #10b981; border-color: rgba(16,185,129,0.4); }
.my-ai-auto-badge { font-size: 0.7rem; padding: 2px 6px; border-radius: 99px; background: rgba(16,185,129,0.15); color: #10b981; border: 1px solid rgba(16,185,129,0.4); user-select: none; }
.my-ai-setting-icon { cursor: pointer; color: #7c3aed; font-size: 1.1rem; opacity: 0.7; margin-left: 6px; }
.my-ai-content { font-size: 0.95rem; line-height: 1.7; padding-top: 0.8rem; border-top: 1px dashed rgba(139, 92, 246, 0.3); margin-top: 8px; }
.my-ai-status { font-size: 0.8rem; color: #888; margin-top: 4px; }
.my-ai-chat-area { margin-top: 12px; padding-top: 10px; border-top: 1px dashed rgba(139,92,246,0.3); display: none; }
.my-ai-chat-history { max-height: 400px; overflow-y: auto; margin-bottom: 8px; }
.my-ai-chat-msg { padding: 8px 12px; border-radius: 10px; margin: 6px 0; font-size: 0.9rem; line-height: 1.6; word-wrap: break-word; }
.my-ai-chat-msg.user { background: rgba(37,99,235,0.12); border: 1px solid rgba(37,99,235,0.25); margin-left: 30px; }
.dark .my-ai-chat-msg.user { background: rgba(37,99,235,0.2); }
.my-ai-chat-msg.assistant { background: rgba(139,92,246,0.08); border: 1px solid rgba(139,92,246,0.2); margin-right: 30px; }
.dark .my-ai-chat-msg.assistant { background: rgba(139,92,246,0.15); }
.my-ai-chat-msg .role-label { font-size: 0.7rem; opacity: 0.6; font-weight: 700; margin-bottom: 3px; display: block; }
.my-ai-chat-input-row { display: flex; gap: 6px; align-items: flex-end; }
.my-ai-chat-input { flex: 1; padding: 8px 10px; border: 1px solid rgba(139,92,246,0.3); border-radius: 8px; resize: vertical; min-height: 38px; max-height: 150px; font-family: inherit; font-size: 0.9rem; background: rgba(255,255,255,0.6); color: inherit; box-sizing: border-box; }
.dark .my-ai-chat-input { background: rgba(0,0,0,0.3); color: #e5e7eb; border-color: rgba(139,92,246,0.4); }
.my-ai-chat-input:focus { outline: none; border-color: #7c3aed; }
.my-ai-chat-send { background: linear-gradient(to right, #7c3aed, #2563eb); color: white; border: none; padding: 8px 16px; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 0.85rem; white-space: nowrap; }
.my-ai-chat-send:disabled { background: #999; cursor: not-allowed; }
.my-ai-chat-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
.my-ai-chat-clear, .my-ai-chat-copy, .my-ai-chat-flomo {
background: transparent;
border: 1px solid #ccc;
padding: 4px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 0.75rem;
color: #888;
transition: all 0.15s;
}
.my-ai-chat-clear:hover { background: rgba(0,0,0,0.05); }
.my-ai-chat-copy {
border-color: rgba(139,92,246,0.4);
color: #7c3aed;
}
.my-ai-chat-copy:hover { background: rgba(139,92,246,0.1); }
.my-ai-chat-flomo {
border-color: rgba(16,185,129,0.5);
color: #10b981;
}
.my-ai-chat-flomo:hover { background: rgba(16,185,129,0.1); }
.my-ai-chat-flomo:disabled, .my-ai-chat-copy:disabled {
opacity: 0.5; cursor: not-allowed;
}
.dark .my-ai-chat-copy { color: #a78bfa; }
.dark .my-ai-chat-flomo { color: #34d399; }
#my-config-modal { position: fixed; inset: 0; z-index: 99999; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); display: none; align-items: center; justify-content: center; }
.my-modal-content { background: white; width: 90%; max-width: 540px; border-radius: 12px; padding: 20px; max-height: 90vh; overflow-y: auto; }
.dark .my-modal-content { background: #1e1e2e; color: #eee; border: 1px solid #444; }
.my-modal-header { display: flex; justify-content: space-between; margin-bottom: 15px; border-bottom: 1px solid #eee; padding-bottom: 10px; font-weight: bold; }
.profile-row { display: flex; gap: 8px; margin-bottom: 15px; align-items: center; }
.profile-select { flex: 1; padding: 6px; border-radius: 4px; }
.profile-btn { padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; cursor: pointer; background: #f3f4f6; }
.dark .profile-select, .dark .profile-btn { background: #2a2a3c; border-color: #555; color: white; }
.profile-current-badge { font-size: 11px; padding: 2px 8px; border-radius: 99px; background: rgba(124,58,237,0.12); color: #7c3aed; border: 1px solid rgba(124,58,237,0.3); white-space: nowrap; }
.dark .profile-current-badge { background: rgba(167,139,250,0.18); color: #c4b5fd; }
.my-input-group { margin-bottom: 12px; }
.my-input-label { display: block; font-size: 12px; color: #666; margin-bottom: 4px; font-weight: bold; }
.dark .my-input-label { color: #aaa; }
.my-input { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
.dark .my-input { background: #2a2a3c; border-color: #555; color: #fff; }
.password-wrapper { position: relative; display: flex; align-items: center; }
.password-wrapper input { padding-right: 60px; }
.pw-actions { position: absolute; right: 5px; display: flex; gap: 4px; cursor: pointer; }
.btn-tool { padding: 8px; background: #e9ecef; border: 1px solid #ccc; border-radius: 4px; cursor: pointer; font-size: 12px; white-space: nowrap; }
.dark .btn-tool { background: #3a3a4c; border-color: #555; color: #eee; }
.my-modal-actions { display: flex; justify-content: space-between; margin-top: 20px; padding-top: 15px; border-top: 1px solid #eee; }
.btn-test { background: #10b981; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; }
.btn-save { background: #7c3aed; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; }
.btn-cancel { background: transparent; border: 1px solid #ccc; padding: 8px 16px; border-radius: 4px; cursor: pointer; color: #666; }
datalist { display: none; }
.strategy-row { display: flex; flex-direction: column; gap: 6px; padding: 10px; background: #f9fafb; border-radius: 6px; border: 1px solid #e5e7eb; }
.dark .strategy-row { background: #2a2a3c; border-color: #555; }
.strategy-row label { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; }
.strategy-row .desc { font-size: 11px; color: #888; margin-left: 24px; }
.auto-summary-row { display: flex; flex-direction: column; gap: 6px; padding: 10px; background: #f0fdf4; border-radius: 6px; border: 1px solid #bbf7d0; }
.dark .auto-summary-row { background: #1a2e1f; border-color: #2d5a3a; }
.auto-summary-row label { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; font-weight: 600; }
.auto-summary-row .desc { font-size: 11px; color: #888; margin-left: 24px; }
.flomo-section { padding: 10px; background: #ecfdf5; border-radius: 6px; border: 1px solid #6ee7b7; }
.dark .flomo-section { background: #0f2a1f; border-color: #15803d; }
.flomo-section .desc { font-size: 11px; color: #888; margin-top: 4px; line-height: 1.5; }
.webdav-section { display: flex; flex-direction: column; gap: 10px; padding: 12px; background: #fff7ed; border-radius: 8px; border: 1px solid #fed7aa; }
.dark .webdav-section { background: #2a1f15; border-color: #6b3a1a; }
.webdav-fixed-url { font-family: monospace; font-size: 12px; padding: 6px 10px; background: rgba(0,0,0,0.05); border-radius: 4px; color: #666; word-break: break-all; }
.dark .webdav-fixed-url { background: rgba(255,255,255,0.06); color: #aaa; }
.webdav-btns { display: flex; gap: 8px; flex-wrap: wrap; }
.webdav-btn { flex: 1; min-width: 110px; padding: 8px 10px; border: none; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 600; color: white; }
.webdav-btn.up { background: #2563eb; }
.webdav-btn.up:hover { background: #1d4ed8; }
.webdav-btn.down { background: #10b981; }
.webdav-btn.down:hover { background: #059669; }
.webdav-btn.force { background: #dc2626; }
.webdav-btn.force:hover { background: #b91c1c; }
.webdav-btn:disabled { background: #999 !important; cursor: not-allowed; }
.webdav-status { font-size: 12px; color: #666; min-height: 18px; padding: 4px 0; }
.dark .webdav-status { color: #aaa; }
.webdav-status.success { color: #10b981; }
.webdav-status.error { color: #dc2626; }
.my-ai-content h1, .my-ai-content h2, .my-ai-content h3 { font-weight: 700; margin: 0.8em 0 0.4em; color: #4c1d95; }
.dark .my-ai-content h1, .dark .my-ai-content h2, .dark .my-ai-content h3 { color: #c4b5fd; }
.my-ai-content h1 { font-size: 1.25rem; } .my-ai-content h2 { font-size: 1.15rem; } .my-ai-content h3 { font-size: 1.05rem; }
.my-ai-content p { margin: 0.5em 0; line-height: 1.75; }
.my-ai-content strong { color: #7c3aed; }
.dark .my-ai-content strong { color: #a78bfa; }
.my-ai-content ul, .my-ai-content ol { padding-left: 1.6em; margin: 0.5em 0; }
.my-ai-content li { margin: 0.2em 0; }
.my-ai-content code { background: rgba(139,92,246,0.12); padding: 1px 6px; border-radius: 4px; font-size: 0.88em; color: #be185d; }
.my-ai-content pre { background: rgba(15,23,42,0.05); padding: 0.8em; border-radius: 8px; overflow-x: auto; }
.dark .my-ai-content pre { background: rgba(15,23,42,0.5); }
.my-ai-content pre code { background: none; padding: 0; color: inherit; }
.my-ai-content blockquote { border-left: 3px solid #7c3aed; padding: 0.3em 0.8em; background: rgba(139,92,246,0.08); margin: 0.6em 0; border-radius: 0 6px 6px 0; }
.my-ai-content a { color: #2563eb; text-decoration: underline; }
.dark .my-ai-content a { color: #60a5fa; }
.my-ai-chat-msg p { margin: 0.3em 0; }
.my-ai-chat-msg ul, .my-ai-chat-msg ol { padding-left: 1.4em; margin: 0.3em 0; }
.my-ai-chat-msg code { background: rgba(139,92,246,0.12); padding: 1px 5px; border-radius: 3px; font-size: 0.85em; color: #be185d; }
.my-ai-chat-msg pre { background: rgba(15,23,42,0.05); padding: 0.6em; border-radius: 6px; overflow-x: auto; margin: 0.4em 0; }
.dark .my-ai-chat-msg pre { background: rgba(15,23,42,0.5); }
.my-ai-chat-msg pre code { background: none; padding: 0; }
.my-ai-chat-msg a { color: #2563eb; text-decoration: underline; }
.dark .my-ai-chat-msg a { color: #60a5fa; }
/* Markdown 表格样式 */
.my-ai-content .md-table-wrap,
.my-ai-chat-msg .md-table-wrap {
overflow-x: auto;
margin: 0.8em 0;
border-radius: 8px;
border: 1px solid rgba(139, 92, 246, 0.25);
background: rgba(255, 255, 255, 0.5);
}
.dark .my-ai-content .md-table-wrap,
.dark .my-ai-chat-msg .md-table-wrap {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(139, 92, 246, 0.35);
}
.my-ai-content .md-table,
.my-ai-chat-msg .md-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88em;
line-height: 1.55;
}
.my-ai-content .md-table th,
.my-ai-content .md-table td,
.my-ai-chat-msg .md-table th,
.my-ai-chat-msg .md-table td {
padding: 8px 12px;
border-bottom: 1px solid rgba(139, 92, 246, 0.15);
border-right: 1px solid rgba(139, 92, 246, 0.10);
vertical-align: top;
text-align: left;
word-break: break-word;
}
.my-ai-content .md-table th:last-child,
.my-ai-content .md-table td:last-child,
.my-ai-chat-msg .md-table th:last-child,
.my-ai-chat-msg .md-table td:last-child { border-right: none; }
.my-ai-content .md-table thead th,
.my-ai-chat-msg .md-table thead th {
background: linear-gradient(135deg, rgba(124,58,237,0.12), rgba(37,99,235,0.10));
color: #4c1d95;
font-weight: 700;
white-space: nowrap;
border-bottom: 2px solid rgba(124, 58, 237, 0.35);
}
.dark .my-ai-content .md-table thead th,
.dark .my-ai-chat-msg .md-table thead th {
background: linear-gradient(135deg, rgba(124,58,237,0.25), rgba(37,99,235,0.18));
color: #c4b5fd;
border-bottom-color: rgba(167,139,250,0.5);
}
.my-ai-content .md-table tbody tr:nth-child(even),
.my-ai-chat-msg .md-table tbody tr:nth-child(even) {
background: rgba(139, 92, 246, 0.04);
}
.dark .my-ai-content .md-table tbody tr:nth-child(even),
.dark .my-ai-chat-msg .md-table tbody tr:nth-child(even) {
background: rgba(139, 92, 246, 0.08);
}
.my-ai-content .md-table tbody tr:hover,
.my-ai-chat-msg .md-table tbody tr:hover {
background: rgba(124, 58, 237, 0.08);
}
.dark .my-ai-content .md-table tbody tr:hover,
.dark .my-ai-chat-msg .md-table tbody tr:hover {
background: rgba(124, 58, 237, 0.18);
}
.my-ai-content .md-table tbody tr:last-child td,
.my-ai-chat-msg .md-table tbody tr:last-child td {
border-bottom: none;
}
.my-ai-content .md-table code,
.my-ai-chat-msg .md-table code {
font-size: 0.85em;
padding: 1px 5px;
}
`);
// ==================== 5. 设置弹窗 ====================
function showSettingsModal() {
let modal = document.getElementById('my-config-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'my-config-modal';
modal.innerHTML = `
`;
document.body.appendChild(modal);
bindModalEvents(modal);
}
const select = document.getElementById('profile-select');
renderProfiles(select);
modal.__lastProfileId = getCurrentProfileId();
select.value = modal.__lastProfileId;
loadFormData(getActiveConfig());
loadStrategiesUI();
document.getElementById('cfg-auto-summarize').checked = getAutoSummarizeEnabled();
document.getElementById('cfg-flomo-url').value = getFlomoApiUrl();
document.getElementById('webdav-user').value = getWebDAVUser();
document.getElementById('webdav-pass').value = getWebDAVPass();
const statusEl = document.getElementById('webdav-status');
statusEl.className = 'webdav-status';
statusEl.innerText = '提示:上传/下载默认为增量合并;强制覆盖会用本地配置完全替换云端';
modal.style.display = 'flex';
}
function loadStrategiesUI() {
const strats = getExtractStrategies();
document.getElementById('strat-jina').checked = strats.includes('jina');
document.getElementById('strat-readability').checked = strats.includes('readability');
document.getElementById('strat-heuristic').checked = strats.includes('heuristic');
}
function saveStrategiesFromUI() {
const arr = [];
if (document.getElementById('strat-jina').checked) arr.push('jina');
if (document.getElementById('strat-readability').checked) arr.push('readability');
if (document.getElementById('strat-heuristic').checked) arr.push('heuristic');
if (arr.length === 0) arr.push('heuristic');
setExtractStrategies(arr);
}
function renderProfiles(selectEl) {
const profiles = getProfiles();
const currentId = getCurrentProfileId();
selectEl.innerHTML = "";
profiles.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id;
opt.text = (p.id === currentId ? '★ ' : '') + p.name;
if (p.id === currentId) opt.selected = true;
selectEl.appendChild(opt);
});
}
function loadFormData(config) {
document.getElementById('cfg-name').value = config.name || '';
document.getElementById('cfg-url').value = config.apiUrl || '';
document.getElementById('cfg-key').value = config.apiKey || '';
document.getElementById('cfg-model').value = config.model || '';
document.getElementById('cfg-prompt').value = config.prompt || '';
}
function getFormDataFromUI(id) {
return {
id: id,
name: document.getElementById('cfg-name').value,
apiUrl: document.getElementById('cfg-url').value.trim(),
apiKey: document.getElementById('cfg-key').value.trim(),
model: document.getElementById('cfg-model').value.trim(),
prompt: document.getElementById('cfg-prompt').value.trim()
};
}
function saveFormToProfile(profileId) {
if (!profileId) return;
let profiles = getProfiles();
const idx = profiles.findIndex(p => p.id === profileId);
if (idx === -1) return;
profiles[idx] = getFormDataFromUI(profileId);
GM_setValue("ai_profiles", profiles);
}
function setWebDAVStatus(text, type) {
const el = document.getElementById('webdav-status');
if (!el) return;
el.className = 'webdav-status' + (type ? ' ' + type : '');
el.innerText = text;
}
function persistWebDAVCredsFromForm() {
setWebDAVUser(document.getElementById('webdav-user').value.trim());
setWebDAVPass(document.getElementById('webdav-pass').value.trim());
}
function bindModalEvents(modal) {
const select = document.getElementById('profile-select');
modal.__lastProfileId = select.value || getCurrentProfileId();
select.onchange = () => {
const oldId = modal.__lastProfileId;
const newId = select.value;
if (oldId && oldId !== newId) {
saveFormToProfile(oldId);
}
GM_setValue("ai_current_profile_id", newId);
modal.__lastProfileId = newId;
loadFormData(getActiveConfig());
renderProfiles(select);
select.value = newId;
};
document.getElementById('btn-add-profile').onclick = () => {
const name = prompt("新配置名称:", "DeepSeek");
if (!name) return;
saveFormToProfile(modal.__lastProfileId);
const profiles = getProfiles();
const newId = Date.now().toString();
const newProfile = { ...DEFAULT_PROFILE, id: newId, name: name, apiKey: "" };
profiles.push(newProfile);
saveProfiles(profiles, newId);
modal.__lastProfileId = newId;
renderProfiles(select);
select.value = newId;
loadFormData(getActiveConfig());
};
document.getElementById('btn-del-profile').onclick = () => {
let profiles = getProfiles();
if (profiles.length <= 1) return alert("至少保留一个配置");
const delId = modal.__lastProfileId;
const delProfile = profiles.find(p => p.id === delId);
if (!confirm(`删除配置「${delProfile ? delProfile.name : delId}」?`)) return;
profiles = profiles.filter(p => p.id !== delId);
const newActiveId = profiles[0].id;
saveProfiles(profiles, newActiveId);
modal.__lastProfileId = newActiveId;
renderProfiles(select);
select.value = newActiveId;
loadFormData(getActiveConfig());
};
const keyInput = document.getElementById('cfg-key');
document.getElementById('btn-toggle-pw').onclick = () => keyInput.type = keyInput.type === "password" ? "text" : "password";
document.getElementById('btn-copy-pw').onclick = () => { GM_setClipboard(keyInput.value); alert("Key 已复制"); };
const webdavPassInput = document.getElementById('webdav-pass');
document.getElementById('btn-toggle-webdav-pw').onclick = () => webdavPassInput.type = webdavPassInput.type === "password" ? "text" : "password";
const btnUp = document.getElementById('btn-webdav-up');
const btnDown = document.getElementById('btn-webdav-down');
const btnForce = document.getElementById('btn-webdav-force');
function lockBtns(lock) {
btnUp.disabled = lock;
btnDown.disabled = lock;
btnForce.disabled = lock;
}
btnUp.onclick = async () => {
saveFormToProfile(modal.__lastProfileId);
saveStrategiesFromUI();
setAutoSummarizeEnabled(document.getElementById('cfg-auto-summarize').checked);
setFlomoApiUrl(document.getElementById('cfg-flomo-url').value);
persistWebDAVCredsFromForm();
if (!getWebDAVUser() || !getWebDAVPass()) return setWebDAVStatus("请先填写邮箱和应用密码", "error");
lockBtns(true);
setWebDAVStatus("⬆️ 正在上传(增量合并)...");
try {
const merged = await syncUploadIncremental();
setWebDAVStatus(`✅ 上传成功 · 配置数:${merged.profiles.length} · ${new Date().toLocaleTimeString()}`, "success");
} catch(e) {
setWebDAVStatus("❌ 上传失败:" + e.message, "error");
} finally {
lockBtns(false);
}
};
btnDown.onclick = async () => {
persistWebDAVCredsFromForm();
if (!getWebDAVUser() || !getWebDAVPass()) return setWebDAVStatus("请先填写邮箱和应用密码", "error");
lockBtns(true);
setWebDAVStatus("⬇️ 正在下载(增量合并到本地)...");
try {
const merged = await syncDownloadIncremental();
modal.__lastProfileId = getCurrentProfileId();
renderProfiles(select);
select.value = modal.__lastProfileId;
loadFormData(getActiveConfig());
loadStrategiesUI();
document.getElementById('cfg-auto-summarize').checked = getAutoSummarizeEnabled();
document.getElementById('cfg-flomo-url').value = getFlomoApiUrl();
setWebDAVStatus(`✅ 下载成功 · 配置数:${merged.profiles.length} · ${new Date().toLocaleTimeString()}`, "success");
} catch(e) {
setWebDAVStatus("❌ 下载失败:" + e.message, "error");
} finally {
lockBtns(false);
}
};
btnForce.onclick = async () => {
saveFormToProfile(modal.__lastProfileId);
saveStrategiesFromUI();
setAutoSummarizeEnabled(document.getElementById('cfg-auto-summarize').checked);
setFlomoApiUrl(document.getElementById('cfg-flomo-url').value);
persistWebDAVCredsFromForm();
if (!getWebDAVUser() || !getWebDAVPass()) return setWebDAVStatus("请先填写邮箱和应用密码", "error");
if (!confirm("⚠️ 危险操作\n\n将用本地配置完全覆盖云端文件,云端独有的配置会丢失!\n\n确定继续?")) return;
lockBtns(true);
setWebDAVStatus("💥 正在强制覆盖云端...");
try {
const local = await syncForceUploadOverwrite();
setWebDAVStatus(`✅ 已强制覆盖云端 · 配置数:${local.profiles.length} · ${new Date().toLocaleTimeString()}`, "success");
} catch(e) {
setWebDAVStatus("❌ 覆盖失败:" + e.message, "error");
} finally {
lockBtns(false);
}
};
document.getElementById('btn-fetch-models').onclick = () => {
const rawUrl = document.getElementById('cfg-url').value.trim();
const apiKey = document.getElementById('cfg-key').value.trim();
if (!rawUrl || !apiKey) return alert("请先填写 URL 和 Key");
const btn = document.getElementById('btn-fetch-models');
btn.innerText = "..."; btn.disabled = true;
GM_xmlhttpRequest({
method: "GET", url: getModelsUrl(normalizeApiUrl(rawUrl)), headers: { "Authorization": "Bearer " + apiKey },
onload: (res) => {
btn.innerText = "🔄 获取模型"; btn.disabled = false;
try {
const data = JSON.parse(res.responseText);
if (data.data && Array.isArray(data.data)) {
const list = document.getElementById('model-list');
list.innerHTML = "";
data.data.forEach(m => { const opt = document.createElement('option'); opt.value = m.id; list.appendChild(opt); });
alert(`获取成功: ${data.data.length} 个模型`);
} else alert("获取成功但格式不符");
} catch (e) { alert("返回非 JSON 数据"); }
},
onerror: () => { btn.innerText = "重试"; btn.disabled = false; alert("请求失败"); }
});
};
document.getElementById('btn-test-conn').onclick = () => {
const rawUrl = document.getElementById('cfg-url').value.trim();
const apiKey = document.getElementById('cfg-key').value.trim();
const model = document.getElementById('cfg-model').value.trim();
const btn = document.getElementById('btn-test-conn');
if (!rawUrl || !apiKey) return alert("请完善配置");
const finalUrl = normalizeApiUrl(rawUrl);
btn.innerText = "连接中...";
GM_xmlhttpRequest({
method: "POST", url: finalUrl, headers: { "Content-Type": "application/json", "Authorization": "Bearer " + apiKey },
data: JSON.stringify({ model: model, messages: [{ role: "user", content: "Hi" }], max_tokens: 5 }),
onload: (res) => {
btn.innerText = "⚡ 测试连接";
if (res.status === 200) alert("✅ 连接成功!"); else alert(`❌ 连接失败 (${res.status})\n${res.responseText.substring(0,100)}`);
},
onerror: () => { btn.innerText = "⚡ 测试连接"; alert("❌ 网络错误"); }
});
};
document.getElementById('my-btn-save').onclick = () => {
saveFormToProfile(modal.__lastProfileId);
saveStrategiesFromUI();
setAutoSummarizeEnabled(document.getElementById('cfg-auto-summarize').checked);
setFlomoApiUrl(document.getElementById('cfg-flomo-url').value);
persistWebDAVCredsFromForm();
modal.style.display = 'none';
alert("已保存");
};
document.getElementById('my-btn-cancel').onclick = () => modal.style.display = 'none';
document.getElementById('modal-close-x').onclick = () => modal.style.display = 'none';
}
// ==================== 6. AI 调用 ====================
function callAIChat(messages, onSuccess, onError) {
const config = getActiveConfig();
if (!config.apiKey) {
onError && onError("请先配置 API Key");
return;
}
const finalUrl = normalizeApiUrl(config.apiUrl);
GM_xmlhttpRequest({
method: "POST", url: finalUrl,
headers: { "Content-Type": "application/json", "Authorization": "Bearer " + config.apiKey },
data: JSON.stringify({ model: config.model, messages: messages }),
onload: (res) => {
if (res.responseText.trim().startsWith("<")) {
onError && onError("URL 错误 (返回了 HTML)");
return;
}
try {
const data = JSON.parse(res.responseText);
if (data.error) onError && onError("API Error: " + data.error.message);
else {
const content = data.choices?.[0]?.message?.content || "无内容";
onSuccess && onSuccess(content);
}
} catch(e) { onError && onError("解析失败:" + e.message); }
},
onerror: () => onError && onError("网络错误")
});
}
function callAIWithText(opts) {
const { title, text, url, btn, resultDiv, statusDiv, sourceLabel, wrapper } = opts;
const config = getActiveConfig();
if (!config.apiKey) {
resultDiv.style.display = 'block';
resultDiv.innerHTML = "⚠️ 请先配置 API Key";
showSettingsModal();
return;
}
if (!text || text.length < 10) {
resultDiv.style.display = 'block';
resultDiv.innerHTML = `⚠️ 正文内容过少(${text ? text.length : 0} 字),无法总结。`;
return;
}
const maxChars = getMaxChars();
let workText = text;
let truncatedNote = "";
if (workText.length > maxChars) {
workText = workText.substring(0, maxChars);
truncatedNote = `(已截断到 ${maxChars} 字符)`;
}
btn.disabled = true; btn.innerText = "AI 生成中...";
resultDiv.style.display = 'block';
resultDiv.innerHTML = `🤖 正在调用 AI 模型... (${config.model})`;
if (statusDiv) statusDiv.innerText = `📄 正文来源:${sourceLabel} · 长度:${text.length} 字 ${truncatedNote}`;
const urlBlock = url ? `原文链接: ${url}\n` : "(无原文链接)\n";
const fullContent =
`以下是从 RSS 阅读器中提取的文章信息,请基于这些信息进行总结。\n\n` +
`==== 文章元信息 ====\n` +
`标题: ${title}\n` +
urlBlock +
`\n==== 正文内容 ====\n${workText}\n\n` +
`==== 任务要求 ====\n` +
`请基于上面提供的正文进行总结。注意:你不需要也无法访问网络,所有内容已包含在上方文本中。\n` +
(url ? `如需引用原文出处,请使用此链接:${url}\n` : "");
const systemPrompt =
"You are a helpful assistant summarizing articles. " +
"All article content is provided directly in the user's message - " +
"you do NOT have web access and do NOT need to fetch anything. " +
"Just summarize what's given. If a URL is provided, reference it in your answer when appropriate.";
const userMessage = config.prompt + "\n\n" + fullContent;
callAIChat(
[
{ role: "system", content: systemPrompt },
{ role: "user", content: userMessage }
],
(content) => {
btn.disabled = false; btn.innerText = "重新生成";
let raw = content;
if (url) raw += `\n\n---\n🔗 **原文链接**:[${url}](${url})`;
resultDiv.innerHTML = _md(raw);
if (wrapper) {
wrapper.__articleContext = {
title: title,
text: workText,
url: url,
truncated: !!truncatedNote
};
wrapper.__summaryContent = content; // 保存原始 markdown,便于复制/发送 flomo
wrapper.__chatHistory = [
{ role: "system", content:
"你是一个有用的文章助手。下面是用户正在阅读的文章。请基于这篇文章的内容回答用户的后续提问。所有信息已包含在下方文本中,你无法访问网络。\n\n" +
`==== 文章标题 ====\n${title}\n` +
(url ? `==== 原文链接 ====\n${url}\n` : "") +
`\n==== 文章正文 ====\n${workText}\n\n` +
`==== 之前的 AI 总结 ====\n${content}`
}
];
const chatArea = wrapper.querySelector('.my-ai-chat-area');
if (chatArea) {
chatArea.style.display = 'block';
const histDiv = chatArea.querySelector('.my-ai-chat-history');
if (histDiv) histDiv.innerHTML = '';
}
}
},
(errMsg) => {
btn.disabled = false; btn.innerText = "重试";
resultDiv.innerHTML = `${errMsg}`;
}
);
}
async function runSummary(articleNode, btn, resultDiv, statusDiv, fetchFulltext, wrapper) {
const title = getArticleTitle(articleNode);
const previewText = getCleanArticleText(articleNode);
const originalUrl = getOriginalUrl(articleNode);
if (!fetchFulltext || !originalUrl) {
const reason = !originalUrl ? "未找到原文链接" : "已禁用全文抓取";
callAIWithText({
title, text: previewText, url: originalUrl,
btn, resultDiv, statusDiv, wrapper,
sourceLabel: `Folo 预览(${reason})`
});
return;
}
const strategies = getExtractStrategies();
btn.disabled = true; btn.innerText = "抓取原文中...";
resultDiv.style.display = 'block';
resultDiv.innerHTML = `🌐 正在抓取原文:${originalUrl}`;
if (statusDiv) statusDiv.innerText = `⏳ 准备使用策略:${strategies.join(' → ')}`;
try {
const result = await smartFetchArticle(originalUrl, strategies, (strat) => {
const labels = { jina: '🌟 Jina Reader', readability: '📖 Readability.js', heuristic: '🔧 启发式算法' };
if (statusDiv) statusDiv.innerText = `⏳ 正在尝试:${labels[strat] || strat}...`;
resultDiv.innerHTML = `🌐 正在抓取:${originalUrl}