// ==UserScript==
// @name 4d4y.ai
// @namespace http://tampermonkey.net/
// @version 4.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
// ==/UserScript==
(() => {
// 自动模式配置
const AUTO_MODE = false; // 仅自动模式有效:载入页面自动开始准备回复,并插入回复框
const AUTO_LEADING_WORDS = "根据楼上的回复,我已经找到以下杠精:"; // 仅自动模式有效:引导词,不会出现在回复中
const AUTO_PREFIX = "论坛杠精纠察员:\n"; // 仅自动模式有效:回复前缀,可以用来表明AI身份
// 全局变量
const FAST_REPLY_BTN = true; // 回复/引用的时候,避免跳转到富文本编辑器,让AI有上下文
const ATTITUDE = ""; // 描述语气
const INCLUDE_IMG = true; // 是否分析文字图片
const OLLAMA_URL = "http://localhost:11434/api/generate";
const MODEL_NAME = "deepseek-r1:14b";
const DOUBLE_TAB_MODE = true; // 双击TAB才请求自动补全,体验更丝滑
let LAST_TAB_TIME = 0;
// OpenAI 风格配置
const OpenAIConfig = {
enabled: false, // 默认关闭,使用本地ollama模型;改成true,就会调用在线API
API_URL: "https://ark.cn-beijing.volces.com/api/v3/chat/completions", // 火山引擎,阿里云等
apiKey: "", // API密匙,请妥善保管
model: "ep-20250221224443-5q7rb", // 模型名称, 确保已经在控制台开启
options: {}, // 高级配置项,比如温度等
};
let lastRequest = null;
let suggestionBox = null;
let statusBar = null;
let activeElement = null;
let suggestion = "";
let lastInputText = "";
let debounceTimer = null;
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);
clonedContent
.querySelectorAll('div[style="display: none;"]')
.forEach((div) => div.remove());
clonedContent
.querySelectorAll("span.ocr-result")
.forEach((span) => span.remove()); // content不包括span.ocr-result
// 提取 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
function responseHandler(url, response) {
// 根据URL来判断API供应商,并进行相应处理,返回content
if (url === OLLAMA_URL) {
return JSON.parse(response.responseText).response;
} else {
try {
return JSON.parse(response.responseText)["choices"][0]["message"][
"content"
];
} catch (error) {
return "无法处理该API";
}
}
}
let url;
let payload;
let headers;
if (OpenAIConfig.enabled) {
// 在线AI
url = OpenAIConfig.API_URL;
headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${OpenAIConfig.apiKey}`,
};
payload = {
model: OpenAIConfig.model,
messages: [
{
role: "system",
content: "你是人工智能助手,精通网络术语,按照我的要求生成回复",
},
{ role: "user", content: prompt },
],
stream: false,
...OpenAIConfig.options,
};
} else {
// 本地 ollama
url = OLLAMA_URL;
headers = { "Content-Type": "application/json" };
payload = {
model: MODEL_NAME,
prompt: prompt,
stream: false,
};
}
lastRequest = GM_xmlhttpRequest({
method: "POST",
url: url,
headers: headers,
data: JSON.stringify(payload),
onload: (response) => {
const content = responseHandler(url, response);
suggestion = content.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();
let left = rect.left + cursorPosition * 7; // Estimate cursor X position
let top = rect.top + window.scrollY;
// Ensure `left` does not exceed the right boundary of the textarea
const maxLeft = rect.right; // Right edge of textarea
const minLeft = rect.left; // Left edge of textarea
left = Math.min(Math.max(left, minLeft), maxLeft);
// Ensure `top` stays within the viewport
const maxTop = window.innerHeight + window.scrollY - 50; // Avoid bottom overflow
top = Math.max(0, Math.min(top, maxTop));
updateSuggestionBox({ left, top });
}
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 handleDoubleTab(event) {
if (event.key === "Tab") {
event.preventDefault();
event.stopPropagation();
const currentTime = new Date().getTime();
const timeSinceLastTab = currentTime - LAST_TAB_TIME;
if (timeSinceLastTab < 300 && timeSinceLastTab > 0) {
console.log("Double Tab detected!");
let text = getTextFromElement(activeElement);
if (text.length === 0) {
text = "根据楼上的发言,";
}
fetchCompletion(text);
}
LAST_TAB_TIME = currentTime;
}
}
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")) {
activeElement = event.target;
// query 激活 listener
if (DOUBLE_TAB_MODE) {
// 双击TAB引发query
activeElement.addEventListener("keydown", handleDoubleTab);
} else {
// 用户输入引发query
activeElement.addEventListener("input", handleInput);
}
// 插入补全 listener
activeElement.addEventListener("keydown", handleKeyDown);
lastInputText = getTextFromElement(activeElement);
}
});
document.addEventListener("focusout", () => {
suggestionBox.style.display = "none";
suggestion = "";
});
}
// 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();
return cleanText.length >= 3;
}
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();
});
}
// 快速回复/快速引用,防止进入富文本模式(AI会失去上下文)
function getTidFromUrl() {
let match = window.location.href.match(/tid=(\d+)/);
return match ? match[1] : null;
}
function insertTextAtCursor(textarea, text) {
let start = textarea.selectionStart;
let end = textarea.selectionEnd;
let before = textarea.value.substring(0, start);
let after = textarea.value.substring(end);
textarea.value = before + text + after;
textarea.selectionStart = textarea.selectionEnd = start + text.length;
textarea.focus();
}
function simpleReply(user, pid, ptid) {
let textarea = document.querySelector("#fastpostmessage");
if (textarea) {
let replyText = `[b]回复 [url=https://www.4d4y.com/forum/redirect.php?goto=findpost&pid=${pid}&ptid=${ptid}]#${pid}[/url] [i]${user}[/i] [/b]\n\n`;
textarea.value = replyText; // 直接替换,清空原内容
textarea.focus();
}
}
function simpleQuote(user, postTime, pid, ptid, content) {
let textarea = document.querySelector("#fastpostmessage");
if (textarea) {
let trimmedContent =
content.length > 200 ? content.substring(0, 200) + " ..." : content;
let quoteText = `[quote]${trimmedContent}\n[size=2][color=#999999]${user} 发表于 ${postTime}[/color] [url=https://www.4d4y.com/forum/redirect.php?goto=findpost&pid=${pid}&ptid=${ptid}][img]https://www.4d4y.com/forum/images/common/back.gif[/img][/url][/size][/quote]\n\n`;
insertTextAtCursor(textarea, quoteText); // 插入到光标位置,不清空
}
}
function modifyReplyButtons() {
let tid = getTidFromUrl();
if (!tid) return;
let context = getPageContext();
document.querySelectorAll("a.fastreply").forEach((btn) => {
let match = btn.href.match(/reppost=(\d+)/);
if (match) {
let pid = match[1];
let post = context.posts.find((p) => p.id === pid);
if (post) {
let newBtn = document.createElement("a");
newBtn.innerText = btn.innerText; // 保持原文本
newBtn.style.cursor = "pointer";
newBtn.style.color = btn.style.color;
newBtn.style.fontSize = btn.style.fontSize;
newBtn.style.textDecoration = btn.style.textDecoration;
newBtn.addEventListener("click", function (e) {
e.preventDefault();
simpleReply(post.poster, pid, tid);
});
btn.replaceWith(newBtn); // 替换原来的<a>
}
}
});
document.querySelectorAll("a.repquote").forEach((btn) => {
let match = btn.href.match(/repquote=(\d+)/);
if (match) {
let pid = match[1];
let post = context.posts.find((p) => p.id === pid);
if (post) {
let newBtn = document.createElement("a");
newBtn.innerText = btn.innerText; // 保持原文本
newBtn.style.cursor = "pointer";
newBtn.style.color = btn.style.color;
newBtn.style.fontSize = btn.style.fontSize;
newBtn.style.textDecoration = btn.style.textDecoration;
newBtn.addEventListener("click", function (e) {
e.preventDefault();
simpleQuote(post.poster, post.postTime, pid, tid, post.content);
});
btn.replaceWith(newBtn); // 替换原来的<a>
}
}
});
}
if (FAST_REPLY_BTN) {
modifyReplyButtons();
}
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);
}
});
})();