// ==UserScript== // @name Folo 网站增强工具 (v10.13 自动重置版) // @namespace http://tampermonkey.net/ // @version 10.13 // @description Folo 增强:自动检测文章切换并重置 AI 总结 + 精准提取正文 + 多配置管理 // @author Your Name & Gemini // @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-idle // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/576815/Folo%20%E7%BD%91%E7%AB%99%E5%A2%9E%E5%BC%BA%E5%B7%A5%E5%85%B7%20%28v1013%20%E8%87%AA%E5%8A%A8%E9%87%8D%E7%BD%AE%E7%89%88%29.user.js // @updateURL https://update.greasyfork.icu/scripts/576815/Folo%20%E7%BD%91%E7%AB%99%E5%A2%9E%E5%BC%BA%E5%B7%A5%E5%85%B7%20%28v1013%20%E8%87%AA%E5%8A%A8%E9%87%8D%E7%BD%AE%E7%89%88%29.meta.js // ==/UserScript== (function() { 'use strict'; const SCRIPT_VERSION = (typeof GM_info !== 'undefined' && GM_info?.script?.version) ? GM_info.script.version : 'unknown'; console.log(`🚀 Folo 增强脚本 v${SCRIPT_VERSION} (自动重置版) 已启动`); // ==================== 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 getNodeTextSmart(node) { if (!node) return ""; const inner = (node.innerText || '').replace(/\s+/g, ' ').trim(); const textContent = (node.textContent || '').replace(/\s+/g, ' ').trim(); if (textContent.length > inner.length + 40) return textContent; return inner || textContent; } function getCleanArticleText(articleNode) { if (!articleNode) return ""; const clone = articleNode.cloneNode(true); // 移除脚本注入的元素 clone.querySelectorAll('.custom-copy-btn, .my-custom-ai-wrapper').forEach(el => el.remove()); // 移除 Folo 广告按钮 clone.querySelectorAll('button').forEach(el => el.remove()); // 清洗“阅读完整话题” clone.querySelectorAll('a').forEach(a => { if (a.innerText.includes("阅读完整话题")) { const parent = a.parentElement; a.remove(); if (parent && parent.tagName === 'P' && parent.innerText.trim().length === 0) parent.remove(); } }); // 清洗“X 个帖子”元数据 const metaRegex = /^\s*\d+\s*个帖子\s*[\-—]\s*\d+\s*位参与者/i; clone.querySelectorAll('p').forEach(p => { if (metaRegex.test(p.innerText)) p.remove(); }); const paragraphs = Array.from(clone.querySelectorAll('p')) .map(p => (p.innerText || '').replace(/\s+/g, ' ').trim()) .filter(t => t.length >= 12); const paragraphText = paragraphs.join('\n\n').trim(); if (paragraphText.length >= 80) return paragraphText; return getNodeTextSmart(clone); } // ==================== 2. 配置管理系统 ==================== const DEFAULT_PROFILE = { id: "default", name: "默认配置", apiUrl: "https://api.openai.com", apiKey: "", model: "gpt-3.5-turbo", prompt: "请简要总结以下文章内容,提取 3-5 个核心观点,使用中文回答:" }; 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(); const currentId = getCurrentProfileId(); return profiles.find(p => p.id === currentId) || profiles[0]; } function saveProfiles(profiles, activeId) { GM_setValue("ai_profiles", profiles); if (activeId) GM_setValue("ai_current_profile_id", activeId); } GM_registerMenuCommand("⚙️ 设置 AI API", showSettingsModal); // ==================== 3. 样式注入 ==================== 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; } /* AI 总结框 */ .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; } .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.secondary { background: linear-gradient(to right, #0f766e, #2563eb); } .my-ai-btn:disabled { background: #999; cursor: not-allowed; } .my-ai-setting-icon { cursor: pointer; color: #7c3aed; font-size: 1.1rem; opacity: 0.7; margin-left: 10px; } .my-ai-content { font-size: 0.95rem; line-height: 1.7; white-space: pre-wrap; padding-top: 0.8rem; border-top: 1px dashed rgba(139, 92, 246, 0.3); margin-top: 8px; } /* 弹窗样式 */ #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: 500px; 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; } .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; } .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; } `); // ==================== 4. 界面逻辑 (设置弹窗) ==================== 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); } renderProfiles(document.getElementById('profile-select')); loadFormData(getActiveConfig()); modal.style.display = 'flex'; } 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.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 bindModalEvents(modal) { const select = document.getElementById('profile-select'); select.onchange = () => { saveCurrentToMemory(); GM_setValue("ai_current_profile_id", select.value); loadFormData(getActiveConfig()); }; document.getElementById('btn-add-profile').onclick = () => { const name = prompt("新配置名称:", "DeepSeek"); if (name) { const profiles = getProfiles(); const newId = Date.now().toString(); profiles.push({ ...DEFAULT_PROFILE, id: newId, name: name }); saveProfiles(profiles, newId); renderProfiles(select); loadFormData(getActiveConfig()); } }; document.getElementById('btn-del-profile').onclick = () => { let profiles = getProfiles(); if (profiles.length <= 1) return alert("至少保留一个"); if (confirm("删除当前配置?")) { profiles = profiles.filter(p => p.id !== select.value); saveProfiles(profiles, profiles[0].id); renderProfiles(select); 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 已复制"); }; 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 = () => { saveCurrentToMemory(); 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'; function saveCurrentToMemory() { const currentId = select.value; let profiles = getProfiles(); const idx = profiles.findIndex(p => p.id === currentId); if (idx !== -1) profiles[idx] = getFormDataFromUI(currentId); saveProfiles(profiles, currentId); } } // ==================== 5. AI 调用逻辑 ==================== function callAI(title, text, btn, buttons, resultDiv) { 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 = `⚠️ 正文提取内容过少,可能未加载完成。`; return; } const finalUrl = normalizeApiUrl(config.apiUrl); btn.disabled = true; btn.innerText = "生成中..."; resultDiv.style.display = 'block'; resultDiv.innerHTML = `✨ AI 正在思考中, 请稍候... ⏳ (${config.model})`; const fullContent = `标题: ${title}\n\n正文内容:\n${text}`; let resultText = ""; let isFirstChunk = true; let handledLength = 0; let buffer = ""; let renderQueue = ""; let isRendering = false; function renderNext() { if (renderQueue.length === 0) { isRendering = false; return; } isRendering = true; const char = renderQueue.slice(0, 1); renderQueue = renderQueue.slice(1); resultText += char; resultDiv.innerText = resultText; let delay = 30; if (renderQueue.length > 50) delay = 10; if (renderQueue.length > 100) delay = 5; setTimeout(renderNext, delay); } function appendToRenderQueue(text) { renderQueue += text; if (!isRendering) { renderNext(); } } function processBuffer() { let newlineIndex; while ((newlineIndex = buffer.indexOf('\n')) !== -1) { const line = buffer.slice(0, newlineIndex).trim(); buffer = buffer.slice(newlineIndex + 1); if (line.startsWith('data:')) { const dataStr = line.slice(5).trim(); if (dataStr === '[DONE]') { setTimeout(() => completeAI(), 500); continue; } try { const data = JSON.parse(dataStr); if (data.error) { resultDiv.innerHTML = `API Error: ${data.error.message}`; completeAI(); return; } const delta = data.choices?.[0]?.delta?.content || ""; if (delta) { if (isFirstChunk) { resultDiv.innerText = ""; isFirstChunk = false; } appendToRenderQueue(delta); } } catch (e) {} } else if (line && isFirstChunk && line.startsWith('{')) { try { const data = JSON.parse(line); if (data.error) { resultDiv.innerHTML = `API Error: ${data.error.message}`; completeAI(); } } catch(e){} } } } function completeAI() { if (!buttons || !buttons.length) return; buttons.forEach(b => { b.disabled = false; if (b === btn) { b.innerText = "重新生成"; } else { b.innerText = b.dataset.originalText || "总结timeline"; } }); } GM_xmlhttpRequest({ method: "POST", url: finalUrl, responseType: "stream", headers: { "Content-Type": "application/json", "Authorization": "Bearer " + config.apiKey, "Accept": "text/event-stream" }, data: JSON.stringify({ model: config.model, stream: true, messages: [ { role: "system", content: "You are a helpful assistant summarizing articles." }, { role: "user", content: config.prompt + "\n\n" + fullContent } ] }), onloadstart: async (res) => { console.log("[onloadstart] res.response type:", typeof res.response, "isReader?", !!res.response?.getReader); if (res.response && typeof res.response.getReader === 'function') { const reader = res.response.getReader(); const decoder = new TextDecoder(); try { while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); buffer += chunk; processBuffer(); } } catch (e) { console.error("Stream reading error:", e); } finally { completeAI(); } } }, onprogress: (res) => { if (res.response && typeof res.response.getReader === 'function') return; if (res.readyState !== 3) return; const currentText = res.responseText || ""; const newText = currentText.substring(handledLength); if (!newText) return; handledLength = currentText.length; buffer += newText; processBuffer(); }, onreadystatechange: (res) => { if (res.response && typeof res.response.getReader === 'function') return; console.log(`[XHR State] readyState: ${res.readyState}, status: ${res.status}, responseText length: ${res.responseText?.length}`); if (res.readyState === 4 && res.status !== 200) { completeAI(); if (!resultText) { resultDiv.innerHTML = `请求失败 HTTP ${res.status}: ${res.responseText}`; } return; } if (res.readyState === 3 || res.readyState === 4) { const currentText = res.responseText || ""; const newText = currentText.substring(handledLength); if (newText) { handledLength = currentText.length; buffer += newText; processBuffer(); } } if (res.readyState === 4) { if (buffer.trim().startsWith('data:')) { const dataStr = buffer.trim().slice(5).trim(); if (dataStr === '[DONE]') completeAI(); else { try { const data = JSON.parse(dataStr); const delta = data.choices?.[0]?.delta?.content || ""; if (delta) { resultText += delta; resultDiv.innerText = resultText; } } catch(e) {} } } completeAI(); if (isFirstChunk && resultText === "") { try { const data = JSON.parse(res.responseText); if (data.error) { resultDiv.innerHTML = `API Error: ${data.error.message}`; } else if (data.choices?.[0]?.message?.content) { resultDiv.innerText = ""; appendToRenderQueue(data.choices[0].message.content); } } catch(e) {} } } }, onerror: (err) => { console.error("Request error", err); if(buttons && buttons.length){ buttons.forEach(b => {b.disabled = false; b.innerText = "重试";}) } else { btn.disabled = false; btn.innerText = "重试"; } resultDiv.innerText = "网络错误"; } }); } // ==================== 6. 页面注入逻辑 (核心自动重置) ==================== // ★ 关键变更:监听 URL 变化并重置 ★ function checkAndReset(wrapper) { const currentUrl = window.location.href; const savedUrl = wrapper.dataset.url; // 如果 URL 变了 if (savedUrl && savedUrl !== currentUrl) { // console.log("检测到文章切换,重置 AI 框..."); const contentDiv = wrapper.querySelector('.my-ai-content'); const btn = wrapper.querySelector('.my-ai-btn'); contentDiv.style.display = 'none'; contentDiv.innerText = ''; wrapper.querySelectorAll('.my-ai-btn').forEach(actionBtn => { actionBtn.disabled = false; actionBtn.innerText = actionBtn.dataset.originalText || actionBtn.innerText; }); wrapper.dataset.url = currentUrl; } else if (!savedUrl) { wrapper.dataset.url = currentUrl; } const expectedMode = isTimelineArticleUrl(currentUrl) ? 'detail' : 'card'; if (wrapper.dataset.mode && wrapper.dataset.mode !== expectedMode) { try { wrapper.remove(); } catch {} return true; } wrapper.dataset.mode = expectedMode; return false; } function resolveAbsoluteUrl(href) { if (!href) return ""; try { return new URL(href, window.location.href).toString(); } catch { return ""; } } function isTimelineArticleUrl(url) { if (!url) return false; try { const u = new URL(url); return /\/timeline\/articles\/[^/]+\/[^/]+/.test(u.pathname); } catch { return false; } } function toDebugErrorCode(err) { if (!err) return 'UNKNOWN_ERROR'; if (typeof err === 'string') return err; return err.message || err.name || 'UNKNOWN_ERROR'; } function toSafePreview(text, maxLen = 120) { return (text || '').replace(/\s+/g, ' ').trim().slice(0, maxLen); } function buildFailureText(debugInfo) { const payload = { phase: debugInfo.phase || 'unknown', pageUrl: debugInfo.pageUrl || window.location.href, isDetailPage: !!debugInfo.isDetailPage, timelineUrlMatched: !!debugInfo.timelineUrlMatched, title: debugInfo.title || '文章', titleLen: (debugInfo.title || '').length, candidateCount: Number(debugInfo.candidateCount || 0), candidates: Array.isArray(debugInfo.candidates) ? debugInfo.candidates : [], sourceUrl: debugInfo.sourceUrl || '', sourceFetch: debugInfo.sourceFetch || 'not_attempted', sourceLen: Number(debugInfo.sourceLen || 0), sourcePreview: debugInfo.sourcePreview || '', reason: debugInfo.reason || 'VALIDATION_FAILED', timestamp: new Date().toISOString() }; console.warn('[FoloSummary][extract-failed]', payload); return `无法获取全文\n\n调试信息:\n${JSON.stringify(payload, null, 2)}`; } function setButtonsLoading(buttons, clickedBtn, loadingText) { buttons.forEach(btn => { btn.disabled = true; btn.dataset.originalText = btn.dataset.originalText || btn.innerText; if (btn === clickedBtn) { btn.innerText = loadingText; } }); } function resetButtons(buttons) { buttons.forEach(btn => { btn.disabled = false; btn.innerText = btn.dataset.originalText || btn.innerText || '点击生成摘要'; }); } function isLikelyUrlText(text) { const t = (text || '').trim(); if (!t) return false; return /^https?:\/\//i.test(t) || /^app\.folo\.is\//i.test(t); } function normalizeTitleText(title) { const t = (title || '').trim(); if (!t) return '文章'; if (isLikelyUrlText(t)) return '文章'; return t; } function isValidFullText(text, title, detailUrl) { const t = (text || '').replace(/\s+/g, ' ').trim(); if (!t) return false; if (t.length < 80) return false; if (isLikelyUrlText(t)) return false; const normalizedTitle = normalizeTitleText(title); if (normalizedTitle !== '文章' && t === normalizedTitle) return false; if (detailUrl && t === detailUrl) return false; return true; } function getBestArticleByTextLength(root) { const scope = root || document; const articles = Array.from(scope.querySelectorAll('article')); if (articles.length === 0) return null; let best = null; let bestLen = 0; for (const a of articles) { const len = getNodeTextSmart(a).length; if (len > bestLen) { best = a; bestLen = len; } } return best; } function getBestDetailBodyNode(article) { if (!article) return null; const nearbyNodes = []; const wrapper = article.querySelector('.my-custom-ai-wrapper'); if (wrapper) { let sibling = wrapper.nextElementSibling; while (sibling && nearbyNodes.length < 8) { nearbyNodes.push(sibling); sibling = sibling.nextElementSibling; } } const candidates = Array.from(new Set([ ...article.querySelectorAll('article, section, div'), ...nearbyNodes, ...nearbyNodes.flatMap(node => Array.from(node.querySelectorAll ? node.querySelectorAll('article, section, div') : [])) ])).filter(node => { if (node === article) return false; const paragraphCount = node.querySelectorAll('p').length; if (paragraphCount < 2) return false; return getCleanArticleText(node).length >= 80; }); let best = null; let bestScore = 0; for (const node of candidates) { const paragraphCount = node.querySelectorAll('p').length; const textLen = getCleanArticleText(node).length; const score = paragraphCount * 1000 + textLen; if (score > bestScore) { best = node; bestScore = score; } } return best || getBestArticleByTextLength(article) || article; } function getEntryIdFromUrl(url) { if (!url) return ''; try { const u = new URL(url, window.location.href); const parts = u.pathname.split('/').filter(Boolean); return parts[parts.length - 1] || ''; } catch { return ''; } } function extractReadableTextFromHtmlFragment(fragmentHtml) { if (!fragmentHtml) return ''; const doc = new DOMParser().parseFromString(`${fragmentHtml}`, 'text/html'); return getCleanArticleText(doc.body); } function fetchFoloEntryContent(entryId) { return new Promise((resolve, reject) => { if (!entryId) return reject(new Error('NO_ENTRY_ID')); const url = `https://api.folo.is/entries?id=${encodeURIComponent(entryId)}`; fetch(url, { credentials: 'include' }) .then(async (res) => { if (!res.ok) throw new Error(`FOLO_ENTRY_HTTP_${res.status}`); const data = await res.json(); const html = data?.data?.entries?.content || ''; const entryUrl = data?.data?.entries?.url || ''; const text = extractReadableTextFromHtmlFragment(html); resolve({ text, entryUrl, rawHtmlLength: html.length }); }) .catch(err => reject(err)); }); } function extractReadableTextFromHtml(htmlText) { if (!htmlText) return ''; const doc = new DOMParser().parseFromString(htmlText, 'text/html'); const jsonLd = Array.from(doc.querySelectorAll('script[type="application/ld+json"]')); for (const s of jsonLd) { try { const data = JSON.parse(s.textContent || '{}'); const arr = Array.isArray(data) ? data : [data]; for (const item of arr) { const body = item?.articleBody || item?.description; if (body && String(body).trim().length > 120) return String(body).replace(/\s+/g, ' ').trim(); } } catch {} } const candidates = [ 'article', 'main article', '.article-content', '.post-content', '.content', 'main' ]; let best = ''; for (const sel of candidates) { doc.querySelectorAll(sel).forEach(el => { const txt = getNodeTextSmart(el); if (txt.length > best.length) best = txt; }); } if (best.length >= 120) return best; return getNodeTextSmart(doc.body); } function parseContentType(headersText) { const m = (headersText || '').match(/content-type:\s*([^\n\r]+)/i); return m ? m[1].trim() : ''; } function fetchTextByUrl(url) { return new Promise((resolve, reject) => { if (!url) return reject(new Error('NO_URL')); GM_xmlhttpRequest({ method: 'GET', url, onload: (res) => { const html = res?.responseText || ''; if (!html || html.trim().length < 50) return reject(new Error('EMPTY_HTML')); resolve(extractReadableTextFromHtml(html)); }, onerror: () => reject(new Error('FETCH_URL_ERROR')) }); }); } function fetchTextByUrlWithMeta(url) { return new Promise((resolve, reject) => { if (!url) return reject(new Error('NO_URL')); GM_xmlhttpRequest({ method: 'GET', url, onload: (res) => { const html = res?.responseText || ''; const contentType = parseContentType(res?.responseHeaders || ''); const meta = { status: res?.status || 0, contentType, bytes: html.length }; if (!html || html.trim().length < 50) return reject(Object.assign(new Error('EMPTY_HTML'), meta)); resolve({ text: extractReadableTextFromHtml(html), meta }); }, onerror: () => reject(new Error('FETCH_URL_ERROR')) }); }); } function getPrimarySourceUrl(article) { const titleLink = article.querySelector('a[href]'); const url = resolveAbsoluteUrl(titleLink?.getAttribute('href')); return url || ''; } function getDetailUrlFromCard(article) { const anchors = Array.from(article.querySelectorAll('a[href]')); const candidates = anchors .map(a => ({ a, url: resolveAbsoluteUrl(a.getAttribute('href')) })) .filter(x => { if (!x.url) return false; if (x.url.startsWith('javascript:')) return false; try { const u = new URL(x.url); if (u.origin !== window.location.origin) return false; if (u.pathname === '/' && !u.search) return false; return true; } catch { return false; } }); const timelineCandidate = candidates.find(x => isTimelineArticleUrl(x.url)); if (timelineCandidate) return timelineCandidate.url; candidates.sort((x, y) => (y.a.innerText || '').trim().length - (x.a.innerText || '').trim().length); return candidates[0]?.url || ""; } function findTitleInCard(article) { const titleEl = article.querySelector('a[class*="text-[1.7rem]"]') || article.querySelector('[role="heading"]') || article.querySelector('h1, h2, h3') || article.querySelector('a[href]'); const t = (titleEl?.innerText || '').trim(); return normalizeTitleText(t); } function findArticleNodeInDocument(doc) { if (!doc) return null; return doc.getElementById('follow-entry-render') || doc.querySelector('article[data-testid="entry-render"]'); } function fetchFullTextFromDetailPage(detailUrl, { timeoutMs = 20000, pollIntervalMs = 250 } = {}) { return new Promise((resolve, reject) => { if (!detailUrl) return reject(new Error('NO_DETAIL_URL')); const iframe = document.createElement('iframe'); iframe.setAttribute('aria-hidden', 'true'); iframe.style.position = 'fixed'; iframe.style.left = '-99999px'; iframe.style.top = '0'; iframe.style.width = '1px'; iframe.style.height = '1px'; iframe.style.opacity = '0'; iframe.style.pointerEvents = 'none'; iframe.style.border = '0'; iframe.src = detailUrl; let done = false; let pollTimer = null; let timeoutTimer = null; const cleanup = () => { if (pollTimer) clearInterval(pollTimer); if (timeoutTimer) clearTimeout(timeoutTimer); iframe.onload = null; iframe.onerror = null; try { iframe.remove(); } catch {} }; const finish = (err, text) => { if (done) return; done = true; cleanup(); if (err) reject(err); else resolve(text); }; timeoutTimer = setTimeout(() => finish(new Error('DETAIL_TIMEOUT')), timeoutMs); iframe.onerror = () => finish(new Error('IFRAME_LOAD_ERROR')); iframe.onload = () => { pollTimer = setInterval(() => { try { const doc = iframe.contentDocument; const node = findArticleNodeInDocument(doc); if (!node) return; const targetNode = getBestDetailBodyNode(node) || node; const text = getCleanArticleText(targetNode); if (text && text.length >= 40) finish(null, text); } catch (e) { finish(new Error('IFRAME_ACCESS_ERROR')); } }, pollIntervalMs); }; document.body.appendChild(iframe); }); } function injectIntoArticle(article, { isDetailPage }) { if (!article) return; if (!article.dataset.nativeAiHidden) { article.querySelectorAll('div').forEach(div => { if (div.innerText.includes("AI 总结") && !div.closest('.my-custom-ai-wrapper')) { const container = div.closest('.group.relative.overflow-hidden'); if (container) container.classList.add('folo-native-ai-hidden'); } }); article.dataset.nativeAiHidden = 'true'; } if (!article.dataset.unlocked) { ['onselectstart', 'oncopy', 'oncut', 'onpaste'].forEach(e => article.removeAttribute(e)); article.classList.remove('select-none', 'no-select'); if (!article.querySelector('.custom-copy-btn')) { const btn = document.createElement('button'); btn.className = 'custom-copy-btn'; btn.innerText = 'Copy'; btn.onclick = (e) => { e.stopPropagation(); const cleanText = getCleanArticleText(article); GM_setClipboard(cleanText); btn.innerText = "OK"; setTimeout(()=>btn.innerText="Copy", 1000); }; if (getComputedStyle(article).position === 'static') article.style.position = 'relative'; article.appendChild(btn); } article.dataset.unlocked = "true"; } const existingWrapper = article.querySelector('.my-custom-ai-wrapper'); if (existingWrapper) { const removedForModeChange = checkAndReset(existingWrapper); if (!removedForModeChange && article.querySelector('.my-custom-ai-wrapper')) return; } const staleWrapper = article.querySelector('.my-custom-ai-wrapper'); if (staleWrapper) return; const injectionTarget = article.querySelector('.group.relative.block.mt-12') || article; if (!injectionTarget) return; const wrapper = document.createElement('div'); wrapper.className = 'my-custom-ai-wrapper'; wrapper.dataset.url = window.location.href; wrapper.dataset.mode = isDetailPage ? 'detail' : 'card'; if (!isDetailPage) { wrapper.dataset.detailUrl = getDetailUrlFromCard(article); } const activeConfigName = getActiveConfig().name; wrapper.innerHTML = `