- // ==UserScript==
- // @name 4d4y.ai
- // @namespace http://tampermonkey.net/
- // @version 2.0
- // @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/segmentit@2.0.3/dist/umd/segmentit.min.js
- // ==/UserScript==
-
- (() => {
- const AUTO_MODE = false; // 仅自动模式有效:载入页面自动开始准备回复,并插入回复框
- const AUTO_LEADING_WORDS = "根据楼上的回复,我已经找到以下杠精:"; // 仅自动模式有效:引导词,不会出现在回复中
- const AUTO_PREFIX = "论坛杠精纠察员:\n"; // 仅自动模式有效:回复前缀,可以用来表明AI身份
-
- const ATTITUDE =
- "我是论坛杠精纠察员,谴责抬杠行为,指出谁是杠精,为什么是杠精"; // 描述语气
- 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) {
- 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);
- }
- });
- })();