// ==UserScript== // @name AI4Paper Connector // @description AI4Paper 浏览器联动脚本,支持 DeepSeek / 豆包 / ChatGPT / Claude / 通义 / Kimi 等 // @namespace https://ai4paper.pro // @version 1.2.13 // @author AI4Paper // @license MIT // @homepageURL https://ai4paper.pro // @supportURL https://github.com/wdcpclover/ai4paper/issues // @match https://chat.deepseek.com/* // @match https://www.doubao.com/chat/* // @match https://chatgpt.com/* // @match https://claude.ai/* // @match https://kimi.moonshot.cn/* // @match https://www.kimi.com/* // @match https://yuanbao.tencent.com/chat/* // @match https://qianwen.aliyun.com/* // @match https://chat.qwenlm.ai/* // @match https://chat.qwen.ai/* // @match https://www.qianwen.com/* // @match https://gemini.google.com/* // @match https://www.perplexity.ai/* // @match https://grok.com/* // @include /.+deepseek.+/ // @include /.+doubao.+/ // @include /.+claude.+/ // @include /.+kimi.+/ // @include /.+qwen.+/ // @include /.+qianwen.+/ // @connect ai4paper.pro // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant unsafeWindow // @run-at document-start // @downloadURL https://update.greasyfork.icu/scripts/573233/AI4Paper%20Connector.user.js // @updateURL https://update.greasyfork.icu/scripts/573233/AI4Paper%20Connector.meta.js // ==/UserScript== (async function () { 'use strict'; const SCRIPT_VERSION = "1.2.13"; const SERVER_BASE = "https://ai4paper.pro/api/browser-task"; const LOGIN_URL = "https://ai4paper.pro/api/user/login"; const TOKEN_STORE = "ai4paper.userToken"; const RUNNING_KEY = "ai4paper.connector.running"; const SESSION_STORE = "ai4paper.connector.sessionId"; const POLL_INTERVAL = 1200; const FINAL_PUSH_RETRY_DELAY = 10000; const FINAL_PUSH_RETRY_ATTEMPTS = 3; const PREFERRED_CONTEXT_WAIT_MS = 15000; const MIN_TASK_TIMEOUT_MS = 15000; const MAX_TASK_TIMEOUT_MS = 5 * 60 * 1000; const DEBUG = true; const AI = detectPlatform(); let isRunning = GM_getValue(RUNNING_KEY, true) !== false; let userToken = GM_getValue(TOKEN_STORE, ""); let activeTask = null; // ── 页面状态条 ───────────────────────────────────────────────────────── let _statusEl = null; function showStatus(msg, color) { if (!_statusEl) { _statusEl = document.createElement("div"); _statusEl.style.cssText = "position:fixed;bottom:12px;right:12px;z-index:2147483647;padding:6px 12px;border-radius:6px;font-size:12px;font-family:monospace;max-width:320px;word-break:break-all;box-shadow:0 2px 8px rgba(0,0,0,.2);"; document.documentElement.appendChild(_statusEl); } _statusEl.style.background = color || "#1e293b"; _statusEl.style.color = "#fff"; _statusEl.textContent = "🔗 AI4Paper: " + msg; clearTimeout(_statusEl._hide); _statusEl._hide = setTimeout(() => { if (_statusEl) _statusEl.style.opacity = "0.3"; }, 5000); _statusEl.style.opacity = "1"; } // ── 后台保活 ────────────────────────────────────────────────────────── function initBackgroundKeepAlive() { try { const blob = new Blob([`setInterval(()=>postMessage(1),400);`], { type: "text/javascript" }); const w = new Worker(URL.createObjectURL(blob)); w.onmessage = () => {}; } catch (_e) { /**/ } try { Object.defineProperty(document, "hidden", { get: () => false, configurable: true }); Object.defineProperty(document, "visibilityState", { get: () => "visible", configurable: true }); document.addEventListener("visibilitychange", e => e.stopImmediatePropagation(), true); } catch (_e) { /**/ } } initBackgroundKeepAlive(); function dbg(...args) { if (DEBUG) console.log("[AI4Paper]", ...args); } function makeId(prefix) { try { if (window.crypto?.randomUUID) return prefix + "_" + window.crypto.randomUUID(); } catch (_e) { /**/ } return prefix + "_" + Math.random().toString(36).slice(2) + Date.now().toString(36); } function detectPlatform() { const host = location.host; if (host === "chat.deepseek.com") return "DeepSeek"; if (host === "www.doubao.com") return "Doubao"; if (host === "chatgpt.com") return "ChatGPT"; if (host.includes("claude")) return "Claude"; if (host === "kimi.moonshot.cn" || host === "www.kimi.com") return "Kimi"; if (host === "yuanbao.tencent.com") return "Yuanbao"; if (host.includes("qwen") || host === "qianwen.aliyun.com" || host === "www.qianwen.com") return "Qwen"; if (host === "gemini.google.com") return "Gemini"; if (host === "www.perplexity.ai") return "Perplexity"; if (host.includes("grok")) return "Grok"; return "Unknown"; } function getWorkerSessionId() { try { const cached = sessionStorage.getItem(SESSION_STORE); if (cached) return cached; const nextId = makeId("ws"); sessionStorage.setItem(SESSION_STORE, nextId); return nextId; } catch (_e) { if (!window.__ai4paperWorkerSessionId) { window.__ai4paperWorkerSessionId = makeId("ws"); } return window.__ai4paperWorkerSessionId; } } function getCurrentThreadId() { try { const path = location.pathname || "/"; const search = location.search || ""; const hash = location.hash || ""; const normalizedPath = path.replace(/\/+$/, "") || "/"; const query = search.replace(/^\?/, ""); if (AI === "ChatGPT") { const match = normalizedPath.match(/^\/c\/([^/]+)$/); return match ? "chatgpt:c:" + match[1] : ""; } if (AI === "Claude") { const match = normalizedPath.match(/^\/chat\/([^/]+)$/); return match ? "claude:chat:" + match[1] : ""; } if (AI === "Doubao" || AI === "Yuanbao") { if (normalizedPath !== "/") return AI.toLowerCase() + ":" + normalizedPath + search; return ""; } if (AI === "DeepSeek" || AI === "Kimi" || AI === "Qwen" || AI === "Gemini" || AI === "Perplexity" || AI === "Grok") { if (normalizedPath !== "/") return AI.toLowerCase() + ":" + normalizedPath + search; if (query.includes("conversation") || query.includes("chat")) return AI.toLowerCase() + ":?" + query; if (hash.length > 1) return AI.toLowerCase() + ":" + hash; return ""; } if (normalizedPath !== "/") return location.host + ":" + normalizedPath + search; return ""; } catch (_e) { return ""; } } function getWorkerContext() { return { sessionId: getWorkerSessionId(), threadId: getCurrentThreadId(), url: location.href, title: document.title || "", }; } function describePreferredContext(task) { const parts = []; if (task?.preferredSessionId) parts.push("会话"); if (task?.preferredThreadId) parts.push("线程"); return parts.join(" / ") || "页面上下文"; } function checkPreferredContext(task) { const ctx = getWorkerContext(); if (task?.preferredSessionId && task.preferredSessionId !== ctx.sessionId) { return { ok: false, reason: "当前标签页不是任务绑定的会话", current: ctx, }; } if (task?.preferredThreadId && task.preferredThreadId !== ctx.threadId) { return { ok: false, reason: "当前页面不是任务绑定的对话线程", current: ctx, }; } return { ok: true, current: ctx }; } async function waitForPreferredContext(task) { const deadline = Date.now() + PREFERRED_CONTEXT_WAIT_MS; while (Date.now() < deadline) { const result = checkPreferredContext(task); if (result.ok) return true; showStatus("等待切回已绑定的" + describePreferredContext(task) + "…", "#b45309"); await sleep(400); } return false; } function getTaskTimeoutMs(task) { const expiresAt = Number(task?.expiresAt || 0); if (!expiresAt) return MAX_TASK_TIMEOUT_MS; const remainingMs = Math.floor(expiresAt * 1000 - Date.now() - 5000); return Math.max(MIN_TASK_TIMEOUT_MS, Math.min(MAX_TASK_TIMEOUT_MS, remainingMs)); } // ── JWT 解析(不验签,仅读 exp) ─────────────────────────────────────── function decodeJWTPayload(token) { const raw = String(token || "").split(".")[1] || ""; if (!raw) throw new Error("missing jwt payload"); const normalized = raw.replace(/-/g, "+").replace(/_/g, "/"); const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4); return JSON.parse(atob(padded)); } function isTokenExpired(token) { try { const payload = decodeJWTPayload(token); return Date.now() / 1000 > payload.exp - 60; // 提前 60s 视为过期 } catch (_e) { return true; } } // ── 登录弹窗 ────────────────────────────────────────────────────────── GM_addStyle(` #a4p-login-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 2147483647; display: flex; align-items: center; justify-content: center; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } #a4p-login-box { background: #fff; border-radius: 12px; padding: 32px 28px; width: 340px; box-shadow: 0 8px 40px rgba(0,0,0,.18); display: flex; flex-direction: column; gap: 14px; } #a4p-login-box h2 { margin: 0; font-size: 18px; font-weight: 700; color: #111; display: flex; align-items: center; gap: 8px; } #a4p-login-box p { margin: 0; font-size: 13px; color: #666; line-height: 1.5; } #a4p-login-box input { width: 100%; box-sizing: border-box; padding: 9px 12px; border: 1px solid #d1d5db; border-radius: 7px; font-size: 14px; outline: none; } #a4p-login-box input:focus { border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99,102,241,.15); } #a4p-login-btn { background: #6366f1; color: #fff; border: none; border-radius: 7px; padding: 10px; font-size: 14px; font-weight: 600; cursor: pointer; transition: background .15s; } #a4p-login-btn:hover { background: #4f46e5; } #a4p-login-btn:disabled { background: #a5b4fc; cursor: not-allowed; } #a4p-login-err { color: #ef4444; font-size: 13px; display: none; } #a4p-login-close { position: absolute; top: 12px; right: 16px; cursor: pointer; font-size: 20px; color: #9ca3af; background: none; border: none; } `); function showLoginDialog() { return new Promise((resolve) => { if (document.getElementById("a4p-login-overlay")) return; const overlay = document.createElement("div"); overlay.id = "a4p-login-overlay"; overlay.innerHTML = `

🔗 AI4Paper 登录

请登录您的 AI4Paper 账号,Connector 将自动帮您把 Zotero 任务发送到 AI 并取回结果。

`; document.documentElement.appendChild(overlay); const emailEl = overlay.querySelector("#a4p-email"); const passEl = overlay.querySelector("#a4p-pass"); const btnEl = overlay.querySelector("#a4p-login-btn"); const errEl = overlay.querySelector("#a4p-login-err"); const closeEl = overlay.querySelector("#a4p-login-close"); function close(token) { overlay.remove(); resolve(token || null); } closeEl.addEventListener("click", () => close(null)); overlay.addEventListener("click", e => { if (e.target === overlay) close(null); }); async function doLogin() { const email = emailEl.value.trim(); const pass = passEl.value; if (!email || !pass) { showErr("请填写邮箱和密码"); return; } btnEl.disabled = true; btnEl.textContent = "登录中…"; errEl.style.display = "none"; GM_xmlhttpRequest({ method: "POST", url: LOGIN_URL, headers: { "Content-Type": "application/json" }, data: JSON.stringify({ email, password: pass }), timeout: 15000, onload(resp) { let data; try { data = JSON.parse(resp.responseText); } catch (_e) { data = {}; } if (resp.status === 200 && data.access_token) { userToken = data.access_token; GM_setValue(TOKEN_STORE, userToken); dbg("login ok, token stored"); close(userToken); } else { showErr(data.detail || "登录失败,请检查邮箱和密码"); btnEl.disabled = false; btnEl.textContent = "登录"; } }, onerror() { showErr("网络错误,请重试"); btnEl.disabled = false; btnEl.textContent = "登录"; }, ontimeout() { showErr("请求超时,请重试"); btnEl.disabled = false; btnEl.textContent = "登录"; }, }); } function showErr(msg) { errEl.textContent = msg; errEl.style.display = "block"; } btnEl.addEventListener("click", doLogin); passEl.addEventListener("keydown", e => { if (e.key === "Enter") doLogin(); }); emailEl.addEventListener("keydown", e => { if (e.key === "Enter") passEl.focus(); }); setTimeout(() => emailEl.focus(), 100); }); } // ── 确保有有效 token,否则弹登录框 ──────────────────────────────────── async function ensureAuth() { if (userToken && !isTokenExpired(userToken)) return true; userToken = ""; GM_setValue(TOKEN_STORE, ""); dbg("no valid token, showing login dialog"); const token = await showLoginDialog(); return !!token; } // ── 服务器通信 ───────────────────────────────────────────────────────── function serverRequest(method, path, body) { return new Promise(resolve => { if (!userToken) { resolve(null); return; } GM_xmlhttpRequest({ method, url: SERVER_BASE + path, headers: { "Content-Type": "application/json", "Authorization": "Bearer " + userToken, }, data: body ? JSON.stringify(body) : undefined, onload(resp) { if (resp.status === 401 || resp.status === 403) { // token 失效,清掉,下次轮询触发登录 userToken = ""; GM_setValue(TOKEN_STORE, ""); resolve(null); return; } try { resolve(typeof resp.response === "object" && resp.response ? resp.response : JSON.parse(resp.responseText || "null")); } catch (_e) { resolve(null); } }, onerror: () => resolve(null), ontimeout: () => resolve(null), timeout: 15000, }); }); } async function pullTask() { const ctx = getWorkerContext(); const query = new URLSearchParams({ platform: AI, sessionId: ctx.sessionId, url: ctx.url, title: ctx.title, }); if (ctx.threadId) query.set("threadId", ctx.threadId); return serverRequest("GET", "/poll?" + query.toString()); } function clearActiveTask() { if (activeTask) { if (activeTask.timeout) clearTimeout(activeTask.timeout); if (activeTask.finishRetryTimer) clearTimeout(activeTask.finishRetryTimer); if (activeTask.reader) { try { activeTask.reader.cancel(); } catch (_e) { /**/ } } } activeTask = null; } async function pushFinalResult(taskId, text, error) { let result = null; for (let attempt = 0; attempt < FINAL_PUSH_RETRY_ATTEMPTS; attempt++) { result = await serverRequest("POST", "/push", { taskId, text, done: true, error, ...getWorkerContext(), }); if (result) return true; if (attempt < FINAL_PUSH_RETRY_ATTEMPTS - 1) { await sleep(1200 * (attempt + 1)); } } return false; } function scheduleFinishRetry(taskId, text, error) { if (!activeTask || activeTask.id !== taskId) return; if (activeTask.finishRetryTimer) clearTimeout(activeTask.finishRetryTimer); activeTask.pendingFinish = { text, error }; activeTask.finishRetryTimer = setTimeout(() => { if (!activeTask || activeTask.id !== taskId) return; const pending = activeTask.pendingFinish || { text: activeTask.lastText || "", error: "" }; finishTask(taskId, pending.text, pending.error).catch(() => {}); }, FINAL_PUSH_RETRY_DELAY); } async function finishTask(taskId, text, error) { if (!taskId || !activeTask || activeTask.id !== taskId) return; if (activeTask.finishInFlight) { activeTask.pendingFinish = { text: String(text || activeTask.lastText || ""), error: error || "" }; return; } dbg("finishTask", taskId, "len:", String(text || "").length, error || ""); activeTask.finishInFlight = true; if (activeTask.finishRetryTimer) { clearTimeout(activeTask.finishRetryTimer); activeTask.finishRetryTimer = null; } const finalText = String(text || activeTask.lastText || ""); activeTask.lastText = finalText; const ok = await pushFinalResult(taskId, finalText, error || ""); if (!activeTask || activeTask.id !== taskId) return; activeTask.finishInFlight = false; if (ok) { activeTask.pendingFinish = null; clearActiveTask(); return; } showStatus("⚠️ 结果回传失败,稍后重试…", "#dc2626"); scheduleFinishRetry(taskId, finalText, error || ""); } async function pushTaskText(taskId, text, done) { if (!activeTask || activeTask.id !== taskId) return; const nextText = String(text || ""); if (done) { const finalText = mergeText(activeTask.lastText || "", nextText); activeTask.lastText = finalText; await finishTask(taskId, finalText, ""); return; } if (!nextText) return; const mergedText = mergeText(activeTask.lastText || "", nextText); if (mergedText === activeTask.lastText) return; activeTask.lastText = mergedText; await serverRequest("POST", "/push", { taskId, text: mergedText, done: false, error: "", ...getWorkerContext(), }); } // ── 流式拦截 ────────────────────────────────────────────────────────── const PATCH_DEFS = [ { AI: "DeepSeek", regex: /completion$/, extract(chunk, all) { let resp = "", think = ""; for (const line of all.split("\n")) { if (!line.startsWith("data: {")) continue; let data; try { data = JSON.parse(line.slice(6)); } catch (_e) { continue; } if (data.choices) { const delta = data.choices[0]?.delta; if (delta?.type === "thinking") think += delta.content || ""; else if (delta?.type === "text") resp += delta.content || ""; } else { if (Array.isArray(data.v) && data.v[0]?.type) { this._type = data.v[0].type; data.v = data.v[0].content; } if (typeof data.v === "string") { if (this._type === "THINK") think += data.v; else if (this._type === "RESPONSE") resp += data.v; } } } this.thinkingText = think; this.text = resp; } }, { AI: "Doubao", regex: /samantha\/chat\/(v\d+\/)?completion|chat\/completions|\/api\/chat/, extract(chunk, all) { let resp = "", think = ""; for (const line of all.split("\n")) { if (!line.startsWith("data:")) continue; const raw = line.slice(5).trim(); if (!raw || raw === "[DONE]") continue; let outer; try { outer = JSON.parse(raw); } catch (_e) { continue; } if (outer.event_data) { try { const inner = typeof outer.event_data === "string" ? JSON.parse(outer.event_data) : outer.event_data; const msgContent = inner?.message?.content; if (msgContent) { try { const content = typeof msgContent === "string" ? JSON.parse(msgContent) : msgContent; if (![1, 5, 6].includes(content.type)) { if (content.text) resp += content.text; if (content.think) think += content.think; } } catch (_e) { if (typeof msgContent === "string") resp += msgContent; } } } catch (_e) { /**/ } continue; } const delta = outer.choices?.[0]?.delta; if (delta) { resp += delta.content || delta.text || ""; continue; } if (outer.text) resp += outer.text; if (outer.content) resp += outer.content; } this.thinkingText = think; this.text = resp; } }, { AI: "ChatGPT", regex: /(conversation|backend-api\/conversation|backend-anon\/conversation|backend-api\/responses|backend-anon\/responses)/, extract(chunk, all) { for (const line of chunk.split("\n")) { if (line.startsWith('data: {"message')) { let data; try { data = JSON.parse(line.slice(6)); } catch (_e) { continue; } if (data.message?.content?.content_type === "text") { this.text = data.message.content.parts?.[0] || ""; } continue; } if (!line.startsWith("data: {")) continue; let data; try { data = JSON.parse(line.slice(6)); } catch (_e) { continue; } const direct = extractAnyText(data); if (direct) this.text = mergeText(this.text, direct); const streamPath = "/message/content/parts/0"; if (Object.keys(data).length === 1 && typeof data.v === "string" && this._path === streamPath) { this.text += data.v; } else if (this._path === streamPath || data.p === streamPath) { this._path = streamPath; if (data.o === "add") this.text = ""; if (typeof data.v === "string") this.text += data.v; } else { this._path = ""; } } if (!this.text) { const fb = extractChatGPTFromAll(all); if (fb) this.text = fb; } } }, { AI: "Claude", regex: /chat_conversations\/.+\/completion/, extract(chunk) { for (const line of chunk.split("\n")) { if (!line.startsWith("data: {")) continue; let data; try { data = JSON.parse(line.slice(6)); } catch (_e) { continue; } if (data.type === "completion") this.text += data.completion || ""; else if (data.type === "content_block_delta") this.text += data.delta?.text || ""; } } }, { AI: "Kimi", regex: /ChatService\/Chat/, extract(_chunk, all) { let think = "", resp = ""; for (const item of all.split(/\x00\x00\x00\x00[^\{]+/).filter(Boolean)) { let data; try { data = JSON.parse(item); } catch (_e) { continue; } if (data.op !== "append") continue; if (data.mask === "block.think.content") think += data.block?.think?.content || ""; else if (data.mask === "block.text.content") resp += data.block?.text?.content || ""; } this.thinkingText = think; this.text = resp; } }, { AI: "Yuanbao", regex: /api\/chat\/.+/, extract(_chunk, all) { let think = "", resp = ""; for (const line of all.split("\n")) { if (!line.startsWith("data: {")) continue; let data; try { data = JSON.parse(line.slice(6)); } catch (_e) { continue; } if (data.type === "text") resp += data.msg || ""; else if (data.type === "think") think += data.content || ""; } this.thinkingText = think; this.text = resp; } }, { AI: "Qwen", regex: /chat\/completions/, extract(_chunk, all) { let think = "", resp = ""; for (const line of all.split("\n")) { if (!line.startsWith("data: {")) continue; let data; try { data = JSON.parse(line.slice(6)); } catch (_e) { continue; } const delta = data.choices?.[0]?.delta; if (!delta) continue; if (delta.phase === "think") think += delta.content || ""; else if (delta.phase === "answer") resp += delta.content || ""; } this.thinkingText = think; this.text = resp; } }, { AI: "Grok", regex: /(conversations\/new|responses)$/, extract(_chunk, all) { let think = "", resp = ""; for (const line of all.split("\n")) { let data; try { data = JSON.parse(line).result; } catch (_e) { continue; } if (!data) continue; if (data.response) data = data.response; if (data.isThinking) think += data.token || ""; else resp += data.token || ""; } this.thinkingText = think; this.text = resp; } }, ]; function createPatchState(def) { return { AI: def.AI, regex: def.regex, text: "", allText: "", _type: "", _path: "", extract: def.extract }; } function extractAnyText(payload) { if (payload == null) return ""; if (typeof payload === "string" || typeof payload === "number" || typeof payload === "boolean") return String(payload); if (Array.isArray(payload)) return payload.map(extractAnyText).filter(Boolean).join(""); if (typeof payload === "object") { const pieces = []; const push = (v) => { if (v == null || v === payload) return; const t = extractAnyText(v); if (t) pieces.push(t); }; push(payload.text); push(payload.output_text); push(payload.content); push(payload.parts); push(payload.value); push(payload.delta); push(payload.message); push(payload.response); if (pieces.length) return pieces.join(""); } return ""; } function mergeText(prev, next) { const a = String(prev || ""), b = String(next || ""); if (!a) return b; if (!b) return a; if (b.startsWith(a)) return b; if (a.startsWith(b)) return a; if (a.includes(b)) return a; return a + b; } function hasMeaningfulText(text) { return String(text || "").trim().length > 0; } function extractChatGPTFromAll(all) { let text = ""; for (const line of String(all || "").split("\n")) { if (!line.startsWith("data:")) continue; const raw = line.slice(5).trim(); if (!raw || raw === "[DONE]") continue; let data; try { data = JSON.parse(raw); } catch (_e) { continue; } const part = extractAnyText(data); if (part) text = mergeText(text, part); } return text; } function getPatchDef(url) { return PATCH_DEFS.find(def => def.AI === AI && def.regex.test(url || "")); } function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } // ── DOM 轮询提取(ChatGPT Service Worker 绕过 fetch 拦截时的降级方案)────── const DOM_EXTRACTORS = { ChatGPT() { const last = getChatGPTAssistantElement(); if (!last) return ""; const article = last.closest('article') || last.closest('[data-testid^="conversation-turn"]') || last.parentElement; if (!article) return (last?.innerText || last?.textContent || "").trim(); const clone = article.cloneNode(true); if (clone && clone.querySelectorAll) { clone.querySelectorAll('button, nav, textarea, input, form, svg, img, picture').forEach((el) => el.remove()); } return (clone?.innerText || clone?.textContent || "").trim(); }, DeepSeek() { const msgs = [...document.querySelectorAll('.ds-markdown, .markdown-body')]; const last = msgs[msgs.length - 1]; return (last?.innerText || last?.textContent || "").trim(); }, Doubao() { const msgs = [...document.querySelectorAll('[class*="flow-markdown-body"]')]; const last = msgs[msgs.length - 1]; return (last?.innerText || last?.textContent || "").trim(); }, Kimi() { const msgs = [...document.querySelectorAll('.segment.segment-assistant .markdown, .segment-assistant .markdown-container')]; const last = msgs[msgs.length - 1]; return (last?.innerText || last?.textContent || "").trim(); }, Qwen() { // www.qianwen.com const qkMsgs = [...document.querySelectorAll('[class*="qk-markdown"]')]; if (qkMsgs.length) { const last = qkMsgs[qkMsgs.length - 1]; return (last?.innerText || last?.textContent || "").trim(); } // chat.qwen.ai const msgs = [...document.querySelectorAll('.response-message-content, .custom-qwen-markdown, .qwen-markdown')]; const last = msgs[msgs.length - 1]; return (last?.innerText || last?.textContent || "").trim(); }, }; function getChatGPTAssistantElement() { const selectors = [ '[data-message-author-role="assistant"] .markdown', '[data-message-author-role="assistant"] .prose', '[data-message-author-role="assistant"]', 'article[data-testid^="conversation-turn"] [data-message-author-role="assistant"]', ]; for (const sel of selectors) { const msgs = [...document.querySelectorAll(sel)]; if (msgs.length) return msgs[msgs.length - 1]; } return null; } function getDoubaoAssistantElement() { const msgs = [...document.querySelectorAll('[class*="flow-markdown-body"]')]; return msgs[msgs.length - 1] || null; } function isVisibleElement(el) { if (!el) return false; const style = window.getComputedStyle(el); if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false; const rect = el.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } function getActionButtonsNearContent(contentEl, minCount = 1, maxDistance = 120) { if (!contentEl || !isVisibleElement(contentEl)) return []; const contentRect = contentEl.getBoundingClientRect(); let scope = contentEl; for (let depth = 0; depth < 6 && scope; depth++, scope = scope.parentElement) { const buttons = [...scope.querySelectorAll("button, [role='button']")].filter(el => { if (!(el instanceof HTMLElement) || !isVisibleElement(el)) return false; if (contentEl.contains(el)) return false; const rect = el.getBoundingClientRect(); const isBelowContent = rect.top >= contentRect.bottom - 24 && rect.top <= contentRect.bottom + maxDistance; const overlapsHorizontally = rect.right >= contentRect.left - 32 && rect.left <= contentRect.right + 32; return isBelowContent && overlapsHorizontally; }); if (buttons.length >= minCount) return buttons; } return []; } // 返回 true = 仍在生成中;false = 已完成 const STREAMING_INDICATORS = { // ChatGPT:最后一条 assistant 消息下方出现操作按钮(复制/点赞/…)即为完成 ChatGPT: () => { const last = getChatGPTAssistantElement(); if (!last) return true; const article = last.closest('article') || last.parentElement; // 操作按钮栏出现 = 完成 const actionBar = article?.querySelector( 'button[data-testid="copy-turn-action-button"], button[aria-label="Copy"], button[aria-label*="thumb"], [class*="action"] button' ); if (actionBar) return false; return getActionButtonsNearContent(last, 2).length === 0; // 没有操作按钮 = 还在生成 }, DeepSeek: () => !!document.querySelector('.stop-btn, [class*="stop"]'), Doubao: () => { const last = getDoubaoAssistantElement(); if (last && getActionButtonsNearContent(last, 4).length >= 4) return false; return document.querySelector('.send-btn-wrapper button')?.getAttribute('data-disabled') === 'true'; }, Kimi: () => document.querySelector('.send-button-container svg')?.getAttribute('name') === 'Stop', Qwen: () => !!document.querySelector('button[aria-label*="停止"]'), }; function startDOMExtractor(taskId) { const extract = DOM_EXTRACTORS[AI]; if (!extract) return null; const isStreaming = STREAMING_INDICATORS[AI] || (() => false); // 快照:记录发送前最后一条 assistant 消息的文字,避免误读旧回复 const snapshotText = extract(); dbg("DOM extractor snapshot len:", snapshotText.length); let lastText = ""; let stableMs = 0; let foundNew = false; // 是否已检测到新回复开始生成 const STABLE_DONE = 3000; const INTERVAL = 400; dbg("DOM extractor started for", AI, taskId); showStatus("等待 AI 回复…", "#0f172a"); const timer = setInterval(async () => { if (!activeTask || activeTask.id !== taskId) { clearInterval(timer); return; } const text = extract(); // 等待新回复出现:文字和快照不同 if (!foundNew) { if (!text || text === snapshotText) return; const streaming = isStreaming(); if (streaming) { // 正在生成中,确认是新回复 foundNew = true; lastText = ""; dbg("new response detected (streaming), text len:", text.length); } else { // 操作按钮已出现,响应可能已快速完成 // 如果文字长度有实质性变化,认为是新的完整回复 if (Math.abs(text.length - snapshotText.length) > 20 || text.length > 100) { clearInterval(timer); dbg("new response detected (already done), len:", text.length); showStatus("✅ 完成,推送给 Zotero…", "#16a34a"); if (activeTask && activeTask.id === taskId) { await finishTask(taskId, text, ""); } return; } // 变化太小,可能是上一条懒加载,继续等待 return; } } if (text !== lastText) { lastText = text; stableMs = 0; showStatus("提取中… " + text.length + " 字", "#0369a1"); if (text !== activeTask.lastText) { activeTask.lastText = text; const r = await serverRequest("POST", "/push", { taskId, text, done: false, error: "", ...getWorkerContext(), }); if (!r) showStatus("⚠️ 推送失败(网络/认证)", "#dc2626"); } return; } // 文本未变:检查是否已完成(操作按钮出现 = 完成) stableMs += INTERVAL; const done = !isStreaming(); if (done || stableMs >= STABLE_DONE) { clearInterval(timer); dbg("DOM extractor done, len:", text.length, "byActionBar:", done); showStatus("✅ 完成,推送给 Zotero…", "#16a34a"); if (activeTask && activeTask.id === taskId) { await finishTask(taskId, text, ""); } } }, INTERVAL); return timer; } function monitorFetchResponse(response, taskId) { const def = getPatchDef(response.url); if (!def || !response.body || !activeTask || activeTask.id !== taskId) return; const state = createPatchState(def); const reader = response.body.getReader(); const decoder = new TextDecoder(); if (activeTask && activeTask.id === taskId) activeTask.reader = reader; const read = () => reader.read().then(async ({ done, value }) => { if (!activeTask || activeTask.id !== taskId) { reader.cancel().catch(() => {}); return; } if (done) { const finalText = mergeText(activeTask.lastText || "", state.text || ""); if (hasMeaningfulText(finalText)) { await pushTaskText(taskId, finalText, true); } else { dbg("fetch stream ended without text, waiting for DOM extractor:", taskId, response.url); } return; } const chunk = decoder.decode(value, { stream: true }); state.allText += chunk; try { state.extract.call(state, chunk, state.allText); await pushTaskText(taskId, state.text, false); } catch (_e) { /**/ } read(); }).catch(async (error) => { const msg = String(error?.name || error || ""); if (msg.includes("AbortError") || msg.includes("abort") || msg.includes("cancel")) return; if (activeTask && activeTask.id === taskId) { const finalText = mergeText(activeTask.lastText || "", state.text || ""); if (hasMeaningfulText(finalText)) { await finishTask(taskId, finalText, ""); } else { dbg("fetch stream error without text, waiting for DOM extractor:", taskId, response.url, msg); } } }); read(); } const nativeFetch = (unsafeWindow.fetch || window.fetch).bind(unsafeWindow || window); unsafeWindow.fetch = async function (...args) { const response = await nativeFetch(...args); if (!activeTask) return response; const clone = response.clone(); window.setTimeout(() => { if (activeTask) monitorFetchResponse(clone, activeTask.id); }, 0); return response; }; // SPA 导航检测 function onNavigate() { if (!activeTask) return; const runningMs = Date.now() - activeTask.startedAt; // 任务刚开始且还没收到任何文本时,忽略导航(ChatGPT 发送后会跳到新对话 URL) if (runningMs < 8000 && !activeTask.lastText) { dbg("navigation ignored (task fresh, no text yet):", activeTask.id); return; } const savedId = activeTask.id; const savedText = activeTask.lastText || ""; dbg("SPA navigation, finishing task:", savedId); finishTask(savedId, savedText, "").catch(() => {}); } try { const _pushState = history.pushState.bind(history); history.pushState = function (...args) { _pushState(...args); setTimeout(onNavigate, 50); }; window.addEventListener("popstate", () => setTimeout(onNavigate, 50)); } catch (_e) { /**/ } const nativeXHROpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (method, url) { const def = getPatchDef(url); if (def) { const taskId = activeTask?.id || null; const state = createPatchState(def); this.addEventListener("readystatechange", async function () { if (!taskId || !activeTask || activeTask.id !== taskId) return; if (this.readyState !== 3 && this.readyState !== 4) return; try { state.allText = this.responseText || ""; state.extract.call(state, state.allText, state.allText); if (this.readyState === 4) { const finalText = mergeText(activeTask.lastText || "", state.text || ""); if (hasMeaningfulText(finalText)) { await pushTaskText(taskId, finalText, true); } else { dbg("xhr stream ended without text, waiting for DOM extractor:", taskId, url); } } else { await pushTaskText(taskId, state.text, false); } } catch (_e) { /**/ } }); } return nativeXHROpen.apply(this, arguments); }; // ── 发送到 AI ───────────────────────────────────────────────────────── function setNativeValue(el, value) { const proto = Object.getPrototypeOf(el); const desc = Object.getOwnPropertyDescriptor(proto, "value") || Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement?.prototype || {}, "value") || Object.getOwnPropertyDescriptor(window.HTMLInputElement?.prototype || {}, "value"); if (desc && typeof desc.set === "function") desc.set.call(el, value); else el.value = value; } function dispatchInput(el, text) { if (!el) return false; if ("value" in el) { setNativeValue(el, text); el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, data: text })); el.dispatchEvent(new Event("change", { bubbles: true })); return true; } if (el.isContentEditable) { el.focus(); // execCommand 方式:触发真实浏览器事件,React/Lexical 均可捕获 let ok = false; try { document.execCommand("selectAll", false, null); ok = document.execCommand("insertText", false, text); } catch (_e) { /**/ } if (!ok) { // 回退:直接设置 textContent(不用 innerHTML 以避免论文中 <> 字符污染) el.textContent = text; } el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, data: text })); return true; } return false; } function reactSet(el, text) { if (!el) return false; setNativeValue(el, text); const keys = Object.keys(el).filter(k => k.startsWith("__reactProps") || k.startsWith("__reactEventHandlers")); for (const key of keys) { const props = el[key]; if (props?.onChange) { props.onChange({ target: el, currentTarget: el, type: "change" }); return true; } if (props?.onInput) { props.onInput({ target: el, currentTarget: el, type: "input" }); return true; } } el.dispatchEvent(new Event("input", { bubbles: true })); return true; } async function setLexical(text) { try { const editorEl = document.querySelector('[data-lexical-editor][role="textbox"]'); const editor = editorEl && editorEl.__lexicalEditor; if (!editor) return false; editor.setEditorState(editor.parseEditorState({ root: { children: [{ children: [{ detail: 0, format: 0, mode: "normal", style: "", text, type: "text", version: 1 }], direction: null, format: "", indent: 0, type: "paragraph", version: 1, textFormat: 0 }], direction: null, format: "", indent: 0, type: "root", version: 1 } })); return true; } catch (_e) { return false; } } function clickFirst(selectors) { for (const sel of selectors) { const el = document.querySelector(sel); if (el) { el.click(); return true; } } return false; } function submitByEnter(el) { if (!el) return false; try { el.focus(); } catch (_e) { /**/ } const eventInit = { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true, cancelable: true, }; try { el.dispatchEvent(new KeyboardEvent("keydown", eventInit)); el.dispatchEvent(new KeyboardEvent("keypress", eventInit)); el.dispatchEvent(new KeyboardEvent("keyup", eventInit)); return true; } catch (_e) { return false; } } async function waitForResponseStart(ai, snapshotText, beforeThreadId, timeoutMs = 5000) { const extract = DOM_EXTRACTORS[ai]; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { await sleep(200); const currentThreadId = getCurrentThreadId(); if (beforeThreadId && currentThreadId && currentThreadId !== beforeThreadId) { dbg("response start detected by thread change:", beforeThreadId, "→", currentThreadId); return true; } if (extract) { const text = String(extract() || ""); if (text && text !== snapshotText && (Math.abs(text.length - snapshotText.length) > 8 || text.length > snapshotText.length)) { dbg("response start detected by DOM delta, len:", text.length); return true; } } } return false; } function buildTaskInput(task) { if (!task) return ""; if ((task.kind || "prompt") !== "chat") { return String(task.prompt || task.message || "").trim(); } const message = String(task.message || "").trim(); const paperContext = String(task.paperContext || "").trim(); if (!paperContext) return message; return [ "以下是当前论文的上下文信息,请在相关时基于这些信息回答;如果上下文不足,请明确说明。", "", "[论文上下文]", paperContext, "", "[用户问题]", message, ].join("\n"); } async function sendToAI(prompt) { try { unsafeWindow.focus(); } catch (_e) { /**/ } await sleep(300); const adapters = { DeepSeek: async () => { const el = document.querySelector("textarea"); if (!el) return false; reactSet(el, prompt); await sleep(200); return clickFirst(["button[type=submit]", "button[aria-label*='发送']", "._7436101"]); }, Doubao: async () => { const el = document.querySelector('textarea.semi-input-textarea') || document.querySelector('textarea'); if (!el) return false; if (!reactSet(el, prompt)) return false; await sleep(prompt.length > 3000 ? 600 : 250); return clickFirst([ '.send-btn-wrapper button', 'button[aria-label*="发送"]', 'button[title*="发送"]', ]); }, ChatGPT: async () => { const el = document.querySelector("#prompt-textarea"); if (!el) return false; const beforeThreadId = getCurrentThreadId(); const beforeText = (DOM_EXTRACTORS.ChatGPT?.() || "").trim(); dispatchInput(el, prompt); // 长提示词(如含全文的 Ask PDF)需要更多时间让 React 更新状态 await sleep(prompt.length > 3000 ? 600 : 250); const clicked = clickFirst(['[data-testid="send-button"]', 'button[aria-label*="Send"]', 'button[aria-label*="send"]']); if (clicked) return true; const entered = submitByEnter(el); if (entered) { const started = await waitForResponseStart("ChatGPT", beforeText, beforeThreadId, 5000); if (started) return true; } return waitForResponseStart("ChatGPT", beforeText, beforeThreadId, 2500); }, Claude: async () => { const el = document.querySelector('[contenteditable="true"][data-testid*="input"]') || document.querySelector('[contenteditable="true"]'); if (!el) return false; dispatchInput(el, prompt); await sleep(200); return clickFirst(["button[aria-label='Send message']", "button[type=submit]"]); }, Kimi: async () => { const ok = await setLexical(prompt); if (!ok) { const el = document.querySelector('[contenteditable="true"]'); if (!el) return false; dispatchInput(el, prompt); } await sleep(200); return clickFirst([".send-button-container", ".send-button", "button[type=submit]"]); }, Yuanbao: async () => { const el = document.querySelector(".chat-input-editor .ql-editor") || document.querySelector('[contenteditable="true"]'); if (!el) return false; dispatchInput(el, prompt); await sleep(200); return clickFirst([".icon-send", "button[type=submit]"]); }, Qwen: async () => { // www.qianwen.com — contenteditable with React fiber (no textarea) const ceEl = document.querySelector('div[contenteditable="true"]'); if (ceEl) { ceEl.focus(); document.execCommand('selectAll', false, null); document.execCommand('insertText', false, prompt); const propsKey = Object.keys(ceEl).find(k => k.startsWith('__reactProps')); const props = propsKey ? ceEl[propsKey] : null; if (props?.onInput) props.onInput({ target: ceEl, currentTarget: ceEl, nativeEvent: new InputEvent('input') }); ceEl.dispatchEvent(new InputEvent('input', { bubbles: true })); await sleep(prompt.length > 3000 ? 600 : 300); return clickFirst(['button[aria-label="发送消息"]', 'button.send-button', 'button[type=submit]']); } // chat.qwen.ai — textarea const el = document.querySelector('textarea.message-input-textarea') || document.querySelector('textarea'); if (!el) return false; reactSet(el, prompt); await sleep(prompt.length > 3000 ? 600 : 200); return clickFirst(['button.send-button', '#send-message-button', 'button[type=submit]']); }, Gemini: async () => { const el = document.querySelector("rich-textarea textarea") || document.querySelector("textarea") || document.querySelector('[contenteditable="true"]'); if (!el) return false; dispatchInput(el, prompt); await sleep(200); return clickFirst(["button[aria-label*='Send']", "button[type=submit]"]); }, Perplexity: async () => { const el = document.querySelector("textarea") || document.querySelector('[contenteditable="true"]'); if (!el) return false; dispatchInput(el, prompt); await sleep(200); return clickFirst(["button[aria-label*='Submit']", "button[aria-label*='Send']", "button[type=submit]"]); }, Grok: async () => { const el = document.querySelector("form [contenteditable]") || document.querySelector("textarea"); if (!el) return false; dispatchInput(el, prompt); await sleep(200); return clickFirst(["button[type=submit]", "button[aria-label*='Send']"]); }, }; const adapter = adapters[AI]; if (adapter) return !!(await adapter().catch(() => false)); const genericEditor = document.querySelector("textarea, [contenteditable='true']"); if (!genericEditor) return false; dispatchInput(genericEditor, prompt); await sleep(200); return clickFirst(["button[type=submit]", "button[aria-label*='Send']", "button[aria-label*='发送']"]); } // ── GM 菜单 ─────────────────────────────────────────────────────────── GM_registerMenuCommand("👤 登录 / 切换账号", async () => { userToken = ""; GM_setValue(TOKEN_STORE, ""); await showLoginDialog(); }); GM_registerMenuCommand("📊 AI4Paper 状态", () => { let tokenInfo = "未登录"; if (userToken) { try { const p = decodeJWTPayload(userToken); tokenInfo = "已登录 (sub:" + p.sub + ")"; } catch (_e) { tokenInfo = "已登录"; } } window.alert([ "平台: " + AI, "脚本: " + SCRIPT_VERSION, "账号: " + tokenInfo, "运行: " + (isRunning ? "是" : "否"), "任务: " + (activeTask?.id || "空闲"), "会话: " + getWorkerSessionId(), "线程: " + (getCurrentThreadId() || "未识别"), ].join("\n")); }); GM_registerMenuCommand(isRunning ? "⏸ 暂停 Connector" : "▶️ 启用 Connector", () => { isRunning = !isRunning; GM_setValue(RUNNING_KEY, isRunning); window.alert(isRunning ? "AI4Paper Connector 已启用" : "AI4Paper Connector 已暂停"); }); // ── 主循环 ──────────────────────────────────────────────────────────── while (true) { await sleep(POLL_INTERVAL); if (!isRunning) continue; try { if (activeTask) continue; // 确保已登录 const authed = await ensureAuth(); if (!authed) { await sleep(5000); continue; } const resp = await pullTask(); if (!resp?.task) continue; const task = resp.task; const taskInput = buildTaskInput(task); const newTask = { id: task.id, prompt: taskInput, kind: task.kind || "prompt", preferredSessionId: task.preferredSessionId || "", preferredThreadId: task.preferredThreadId || "", expiresAt: Number(task.expiresAt || 0), lastText: "", startedAt: Date.now(), reader: null, timeout: null, finishRetryTimer: null, finishInFlight: false, pendingFinish: null }; const taskTimeoutMs = getTaskTimeoutMs(newTask); newTask.timeout = setTimeout(() => { if (activeTask && activeTask.id === newTask.id) { dbg("task timeout:", newTask.id); const text = activeTask.lastText || ""; finishTask(newTask.id, text, "").catch(() => {}); } }, taskTimeoutMs); activeTask = newTask; const contextReady = await waitForPreferredContext(newTask); if (!contextReady) { showStatus("❌ 未回到绑定对话,任务取消", "#dc2626"); await finishTask(task.id, "", "当前页面不是绑定的会话/线程,请切回原对话后重试"); continue; } dbg( "task:", task.id, "kind:", newTask.kind, "platform:", AI, "session:", getWorkerSessionId(), "thread:", getCurrentThreadId() || "-", "preferredSession:", newTask.preferredSessionId || "-", "preferredThread:", newTask.preferredThreadId || "-", "timeoutMs:", taskTimeoutMs, "prompt len:", taskInput.length ); showStatus("收到任务,发送中…", "#7c3aed"); const ok = await sendToAI(taskInput); if (!ok) { showStatus("❌ 发送失败", "#dc2626"); await finishTask(task.id, "", "发送失败,请检查页面状态"); } else { showStatus("✉️ 已发送,等待回复…", "#0369a1"); // 启动 DOM 轮询提取器(与 fetch 拦截并行,哪个先完成都可以) startDOMExtractor(task.id); } } catch (error) { const text = String(error || ""); if (!text.includes("Network")) console.warn("[AI4Paper] loop error", error); await sleep(1000); } } })();