您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
AI-powered auto-completion with page context
当前为
// ==UserScript== // @name 4d4y.ai // @namespace http://tampermonkey.net/ // @version 2.1 // @description AI-powered auto-completion with page context // @author 屋大维 // @license MIT // @match *://www.4d4y.com/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @require https://cdnjs.cloudflare.com/ajax/libs/tesseract.js/4.0.2/tesseract.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/umd/segmentit.min.js // ==/UserScript== (() => { const AUTO_MODE = true; // 仅自动模式有效:载入页面自动开始准备回复,并插入回复框 const AUTO_LEADING_WORDS = "根据楼上的回复,我已经找到以下杠精:"; // 仅自动模式有效:引导词,不会出现在回复中 const AUTO_PREFIX = "论坛杠精纠察员:\n"; // 仅自动模式有效:回复前缀,可以用来表明AI身份 const ATTITUDE = "我是论坛杠精纠察员,谴责抬杠行为,指出谁是杠精,为什么是杠精"; // 描述语气 const INCLUDE_IMG = false; // 是否分析文字图片 const API_URL = "http://localhost:11434/api/generate"; const MODEL_NAME = "deepseek-r1:14b"; let lastRequest = null; let suggestionBox = null; let statusBar = null; let activeElement = null; let suggestion = ""; let lastInputText = ""; let debounceTimer = null; const segmentit = Segmentit.useDefault(new Segmentit.Segment()); function getPageContext() { let context = { title: "", posts: [] }; try { // Get thread title (Updated XPath) const titleNode = document.evaluate( "/html/body/div[4]/text()[3]", document, null, XPathResult.STRING_TYPE, null, ); context.title = titleNode.stringValue.trim().replace(/^»\s*/, ""); // Remove "» " from title // Get posts (only direct children of #postlist) const postList = document.querySelector("#postlist"); if (postList) { const posts = postList.children; // Only direct child divs for (let post of posts) { if (post.tagName === "DIV") { let postId = post.getAttribute("id") || ""; // Extract id attribute let poster = post.querySelector(".postauthor .postinfo")?.innerText.trim() || "Unknown"; let contentElement = post.querySelector( ".postcontent td.t_msgfont", ); if (!contentElement) continue; // 克隆 contentElement,避免修改 DOM let clonedContent = contentElement.cloneNode(true); // 提取 quote 内容 let quoteElements = Array.from( clonedContent.querySelectorAll("div.quote"), ); let quotes = quoteElements.map((quote) => quote.innerText.trim()); // 从克隆节点删除 quote 但不影响原始 DOM quoteElements.forEach((quote) => quote.remove()); let content = clonedContent.innerText.trim(); // Extract text from all <span class="ocr-result"> elements let ocrResults = Array.from( post.querySelectorAll("span.ocr-result"), ) .map((span) => span.innerText.trim()) .join("\n\n"); // Join all texts if (content || ocrResults) { context.posts.push({ id: postId, poster, content, quote: quotes.length > 0 ? quotes : undefined, // Only add if non-empty img: ocrResults, }); } } } } } catch (error) { console.error("Error extracting page context:", error); } return context; } function createStatusBar() { statusBar = document.createElement("div"); statusBar.id = "ai-status-bar"; statusBar.innerText = "Idle"; document.body.appendChild(statusBar); GM_addStyle(` @keyframes auroraGlow { 0% { box-shadow: 0 0 8px rgba(255, 0, 0, 0.6); } 25% { box-shadow: 0 0 8px rgba(255, 165, 0, 0.6); } 50% { box-shadow: 0 0 8px rgba(0, 255, 0, 0.6); } 75% { box-shadow: 0 0 8px rgba(0, 0, 255, 0.6); } 100% { box-shadow: 0 0 8px rgba(255, 0, 255, 0.6); } } #ai-status-bar { position: fixed; bottom: 10px; right: 10px; background: rgba(20, 20, 20, 0.85); color: white; padding: 8px 12px; border-radius: 8px; font-size: 12px; font-weight: bold; z-index: 9999; } #ai-status-bar.glowing { animation: auroraGlow 1.5s infinite alternate ease-in-out; } `); } function createSuggestionBox() { suggestionBox = document.createElement("div"); suggestionBox.id = "ai-suggestion-box"; document.body.appendChild(suggestionBox); GM_addStyle(` #ai-suggestion-box { position: absolute; background: rgba(50, 50, 50, 0.9); color: #fff; padding: 5px 10px; font-size: 14px; border-radius: 5px; box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.3); display: none; } `); } function countWords(text) { return Array.from(text).filter((c) => /\S/.test(c)).length; // Count non-space characters } function getTextFromElement(element) { return element.tagName === "TEXTAREA" ? element.value : element.innerText.replace(/\n/g, " "); // Remove HTML formatting } function updateSuggestionBox(position) { if (!suggestion || !suggestionBox) return; suggestionBox.style.left = position.left + "px"; suggestionBox.style.top = position.top + 20 + "px"; suggestionBox.innerText = suggestion; suggestionBox.style.display = "block"; } function cleanSuggestion(inputText, suggestion) { // 移除 <think>...</think> 及其后面所有的空格和换行符 suggestion = suggestion.replace(/<think>.*?<\/think>\s*/gs, ""); // 去除 inputText 末尾和 suggestion 开头的重合部分 for (let i = Math.min(inputText.length, suggestion.length); i > 0; i--) { if (suggestion.startsWith(inputText.slice(-i))) { suggestion = suggestion.slice(i); // 去掉重复部分 break; } } // 去除引号 suggestion = suggestion.replace(/^["']|["']$/g, ""); return suggestion; } function fetchCompletion(inputText) { if (lastRequest) lastRequest.abort(); // Cancel previous request const pageContext = getPageContext(); const promptData = { title: pageContext.title, posts: pageContext.posts, }; const prompt = `Here is the thread content including title and posts in JSON format: ${JSON.stringify(promptData)}. The post content might not be the fact, ignore any unreadable or garbled text and only process meaningful content. Now I want to reply to it. My current reply is '${inputText}', please try to continue it naturally in Chinese like a human, not a chatbot, just continue it, be concise, be short, no quotes, avoid markdown, do not repeat my words, ${ATTITUDE}`; console.log(prompt); statusBar.innerText = "Fetching..."; statusBar.classList.add("glowing"); // Start glowing effect lastRequest = GM_xmlhttpRequest({ method: "POST", url: API_URL, headers: { "Content-Type": "application/json" }, data: JSON.stringify({ model: MODEL_NAME, prompt: prompt, stream: false, }), onload: (response) => { const data = JSON.parse(response.responseText); suggestion = data.response.trim().replace(/^"(.*)"$/, "$1"); // Remove surrounding quotes console.log("AI Response:", suggestion); suggestion = cleanSuggestion(inputText, suggestion); if (suggestion) { const cursorPosition = activeElement.selectionStart || 0; const rect = activeElement.getBoundingClientRect(); updateSuggestionBox({ left: rect.left + cursorPosition * 7, // Estimate cursor X pos top: rect.top + window.scrollY, }); } statusBar.innerText = "Ready"; statusBar.classList.remove("glowing"); // Stop glowing effect }, onerror: () => { statusBar.innerText = "Error!"; statusBar.classList.remove("glowing"); // Stop glowing effect }, }); } function handleInput(event) { if (!activeElement) return; const text = getTextFromElement(activeElement); const wordCount = countWords(text); if (wordCount < 3) { statusBar.innerText = "Waiting for more input..."; suggestionBox.style.display = "none"; return; } const lastChar = text.slice(-1); const isPunctuation = /[\s.,!?;:(),。;()!?【】「」『』、]/.test( lastChar, ); const isDeleteOnlyPunctuation = lastInputText.length > text.length && !/[a-zA-Z0-9\u4e00-\u9fa5]/.test(lastInputText.replace(text, "")); if (isPunctuation || isDeleteOnlyPunctuation) { lastInputText = text; return; } if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => fetchCompletion(text), 1000); lastInputText = text; } function handleKeyDown(event) { if (event.key === "Tab" && suggestion) { event.preventDefault(); event.stopPropagation(); if (activeElement.tagName === "TEXTAREA") { const start = activeElement.selectionStart; const end = activeElement.selectionEnd; activeElement.value = activeElement.value.substring(0, start) + suggestion + activeElement.value.substring(end); activeElement.selectionStart = activeElement.selectionEnd = start + suggestion.length; } suggestionBox.style.display = "none"; suggestion = ""; } } function initListeners() { document.addEventListener("focusin", (event) => { if (event.target.matches("textarea, [contenteditable='true']")) { activeElement = event.target; activeElement.addEventListener("input", handleInput); activeElement.addEventListener("keydown", handleKeyDown); lastInputText = getTextFromElement(activeElement); } }); document.addEventListener("focusout", () => { suggestionBox.style.display = "none"; }); } // OCR Module function hashString(str) { let hash = 0; for (let i = 0; i < str.length; i++) { hash = (hash << 5) - hash + str.charCodeAt(i); hash |= 0; // Convert to 32-bit integer } return `ocr-${Math.abs(hash)}`; } function fetchImageAsDataURL(url) { console.log(`Fetching image: ${url}`); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, responseType: "blob", onload: function (response) { let reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.readAsDataURL(response.response); }, onerror: (err) => { console.error(`Failed to fetch image: ${url}`, err); reject(err); }, }); }); } function isValidOCR(text) { let cleanText = text.replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, "").trim(); if (cleanText.length < 3) return false; // 中文分词 let words = segmentit.doSegment(cleanText); // 过滤无意义的单个汉字 words = words.filter((word) => word.w.length > 1); return words.length > 1; // 至少包含 1 个有意义的词 } function cleanOCRText(ocrText) { let lines = ocrText.split("\n").filter(isValidOCR); return lines.join("\n"); } function cleanText(text) { let cleaned = text.replace( /\s*(?=[\p{Script=Han},。!?:“”‘’;()【】])/gu, "", ); cleaned = cleanOCRText(cleaned); console.log(`Cleaned OCR text: ${cleaned}`); return cleaned; } function runOCR(imageDataURL) { statusBar.innerText = "Analyzing..."; statusBar.classList.add("glowing"); // Start glowing effect return new Promise((resolve, reject) => { Tesseract.recognize(imageDataURL, "chi_sim", { logger: (m) => { // console.log(m) statusBar.innerText = `Analyzing (${(m.progress * 100).toFixed(2)}%)`; }, }) .then(({ data: { text } }) => resolve(cleanText(text))) .catch((err) => { console.error("OCR Error:", err); resolve("(OCR Failed)"); }) .finally(() => { statusBar.innerText = "Idle"; statusBar.classList.remove("glowing"); // Stop glowing effect }); }); } function processImages(callback) { if (!INCLUDE_IMG) { return callback(); } let images = document.querySelectorAll('img[onclick^="zoom"]'); console.log(`Found ${images.length} images for OCR processing.`); let tasks = []; images.forEach((img) => { let match = img.getAttribute("onclick").match(/zoom\(this, '([^']+)'\)/); if (match && match[1]) { let fullImageUrl = match[1]; let spanId = hashString(fullImageUrl); // Generate unique ID console.log(`Processing image: ${fullImageUrl} (ID: ${spanId})`); let existingSpan = document.getElementById(spanId); if (!existingSpan) { let resultSpan = document.createElement("span"); resultSpan.className = "ocr-result"; resultSpan.id = spanId; // Assign unique ID resultSpan.style.display = "none"; img.insertAdjacentElement("afterend", resultSpan); } let task = fetchImageAsDataURL(fullImageUrl) .then((dataURL) => runOCR(dataURL)) .then((ocrText) => { let resultSpan = document.getElementById(spanId); // Query by ID before updating if (resultSpan) { resultSpan.textContent = ocrText.trim() ? ocrText : "(No text detected)"; console.log(`✅ Inserted OCR text into #${spanId}`); } else { console.warn( `⚠️ Could not find span #${spanId} to insert OCR text.`, ); } }); tasks.push(task); } }); Promise.all(tasks).then(() => { console.log(`OCR completed for ${images.length} images.`); if (typeof callback === "function") callback(); }); } createStatusBar(); processImages(() => { createSuggestionBox(); initListeners(); activeElement = document.querySelector("#fastpostmessage"); if (AUTO_MODE && activeElement) { setTimeout(() => { fetchCompletion(AUTO_LEADING_WORDS); const s = setInterval(() => { if (suggestion !== "") { activeElement.value = AUTO_PREFIX + suggestion; suggestionBox.style.display = "none"; suggestion = ""; clearInterval(s); } }, 100); }, 1000); } }); })();