4d4y.ai

AI-powered auto-completion with page context

目前为 2025-02-18 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name 4d4y.ai
  3. // @namespace http://tampermonkey.net/
  4. // @version 2.0
  5. // @description AI-powered auto-completion with page context
  6. // @author 屋大维
  7. // @license MIT
  8. // @match *://www.4d4y.com/*
  9. // @grant GM_xmlhttpRequest
  10. // @grant GM_addStyle
  11. // @require https://cdnjs.cloudflare.com/ajax/libs/tesseract.js/4.0.2/tesseract.min.js
  12. // @require https://cdn.jsdelivr.net/npm/segmentit@2.0.3/dist/umd/segmentit.min.js
  13. // ==/UserScript==
  14.  
  15. (() => {
  16. const AUTO_MODE = false; // 仅自动模式有效:载入页面自动开始准备回复,并插入回复框
  17. const AUTO_LEADING_WORDS = "根据楼上的回复,我已经找到以下杠精:"; // 仅自动模式有效:引导词,不会出现在回复中
  18. const AUTO_PREFIX = "论坛杠精纠察员:\n"; // 仅自动模式有效:回复前缀,可以用来表明AI身份
  19.  
  20. const ATTITUDE =
  21. "我是论坛杠精纠察员,谴责抬杠行为,指出谁是杠精,为什么是杠精"; // 描述语气
  22. const API_URL = "http://localhost:11434/api/generate";
  23. const MODEL_NAME = "deepseek-r1:14b";
  24.  
  25. let lastRequest = null;
  26. let suggestionBox = null;
  27. let statusBar = null;
  28. let activeElement = null;
  29. let suggestion = "";
  30. let lastInputText = "";
  31. let debounceTimer = null;
  32.  
  33. const segmentit = Segmentit.useDefault(new Segmentit.Segment());
  34.  
  35. function getPageContext() {
  36. let context = { title: "", posts: [] };
  37.  
  38. try {
  39. // Get thread title (Updated XPath)
  40. const titleNode = document.evaluate(
  41. "/html/body/div[4]/text()[3]",
  42. document,
  43. null,
  44. XPathResult.STRING_TYPE,
  45. null,
  46. );
  47. context.title = titleNode.stringValue.trim().replace(/^»\s*/, ""); // Remove "» " from title
  48.  
  49. // Get posts (only direct children of #postlist)
  50. const postList = document.querySelector("#postlist");
  51. if (postList) {
  52. const posts = postList.children; // Only direct child divs
  53. for (let post of posts) {
  54. if (post.tagName === "DIV") {
  55. let postId = post.getAttribute("id") || ""; // Extract id attribute
  56. let poster =
  57. post.querySelector(".postauthor .postinfo")?.innerText.trim() ||
  58. "Unknown";
  59. let contentElement = post.querySelector(
  60. ".postcontent td.t_msgfont",
  61. );
  62.  
  63. if (!contentElement) continue;
  64.  
  65. // 克隆 contentElement,避免修改 DOM
  66. let clonedContent = contentElement.cloneNode(true);
  67.  
  68. // 提取 quote 内容
  69. let quoteElements = Array.from(
  70. clonedContent.querySelectorAll("div.quote"),
  71. );
  72. let quotes = quoteElements.map((quote) => quote.innerText.trim());
  73.  
  74. // 从克隆节点删除 quote 但不影响原始 DOM
  75. quoteElements.forEach((quote) => quote.remove());
  76.  
  77. let content = clonedContent.innerText.trim();
  78.  
  79. // Extract text from all <span class="ocr-result"> elements
  80. let ocrResults = Array.from(
  81. post.querySelectorAll("span.ocr-result"),
  82. )
  83. .map((span) => span.innerText.trim())
  84. .join("\n\n"); // Join all texts
  85.  
  86. if (content || ocrResults) {
  87. context.posts.push({
  88. id: postId,
  89. poster,
  90. content,
  91. quote: quotes.length > 0 ? quotes : undefined, // Only add if non-empty
  92. img: ocrResults,
  93. });
  94. }
  95. }
  96. }
  97. }
  98. } catch (error) {
  99. console.error("Error extracting page context:", error);
  100. }
  101.  
  102. return context;
  103. }
  104.  
  105. function createStatusBar() {
  106. statusBar = document.createElement("div");
  107. statusBar.id = "ai-status-bar";
  108. statusBar.innerText = "Idle";
  109. document.body.appendChild(statusBar);
  110. GM_addStyle(`
  111. @keyframes auroraGlow {
  112. 0% { box-shadow: 0 0 8px rgba(255, 0, 0, 0.6); }
  113. 25% { box-shadow: 0 0 8px rgba(255, 165, 0, 0.6); }
  114. 50% { box-shadow: 0 0 8px rgba(0, 255, 0, 0.6); }
  115. 75% { box-shadow: 0 0 8px rgba(0, 0, 255, 0.6); }
  116. 100% { box-shadow: 0 0 8px rgba(255, 0, 255, 0.6); }
  117. }
  118. #ai-status-bar {
  119. position: fixed;
  120. bottom: 10px;
  121. right: 10px;
  122. background: rgba(20, 20, 20, 0.85);
  123. color: white;
  124. padding: 8px 12px;
  125. border-radius: 8px;
  126. font-size: 12px;
  127. font-weight: bold;
  128. z-index: 9999;
  129. }
  130. #ai-status-bar.glowing {
  131. animation: auroraGlow 1.5s infinite alternate ease-in-out;
  132. }
  133. `);
  134. }
  135.  
  136. function createSuggestionBox() {
  137. suggestionBox = document.createElement("div");
  138. suggestionBox.id = "ai-suggestion-box";
  139. document.body.appendChild(suggestionBox);
  140. GM_addStyle(`
  141. #ai-suggestion-box {
  142. position: absolute;
  143. background: rgba(50, 50, 50, 0.9);
  144. color: #fff;
  145. padding: 5px 10px;
  146. font-size: 14px;
  147. border-radius: 5px;
  148. box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.3);
  149. display: none;
  150. }
  151. `);
  152. }
  153.  
  154. function countWords(text) {
  155. return Array.from(text).filter((c) => /\S/.test(c)).length; // Count non-space characters
  156. }
  157.  
  158. function getTextFromElement(element) {
  159. return element.tagName === "TEXTAREA"
  160. ? element.value
  161. : element.innerText.replace(/\n/g, " "); // Remove HTML formatting
  162. }
  163.  
  164. function updateSuggestionBox(position) {
  165. if (!suggestion || !suggestionBox) return;
  166. suggestionBox.style.left = position.left + "px";
  167. suggestionBox.style.top = position.top + 20 + "px";
  168. suggestionBox.innerText = suggestion;
  169. suggestionBox.style.display = "block";
  170. }
  171. function cleanSuggestion(inputText, suggestion) {
  172. // 移除 <think>...</think> 及其后面所有的空格和换行符
  173. suggestion = suggestion.replace(/<think>.*?<\/think>\s*/gs, "");
  174.  
  175. // 去除 inputText 末尾和 suggestion 开头的重合部分
  176. for (let i = Math.min(inputText.length, suggestion.length); i > 0; i--) {
  177. if (suggestion.startsWith(inputText.slice(-i))) {
  178. suggestion = suggestion.slice(i); // 去掉重复部分
  179. break;
  180. }
  181. }
  182. // 去除引号
  183. suggestion = suggestion.replace(/^["']|["']$/g, "");
  184.  
  185. return suggestion;
  186. }
  187.  
  188. function fetchCompletion(inputText) {
  189. if (lastRequest) lastRequest.abort(); // Cancel previous request
  190. const pageContext = getPageContext();
  191. const promptData = {
  192. title: pageContext.title,
  193. posts: pageContext.posts,
  194. };
  195. 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}`;
  196. console.log(prompt);
  197.  
  198. statusBar.innerText = "Fetching...";
  199. statusBar.classList.add("glowing"); // Start glowing effect
  200.  
  201. lastRequest = GM_xmlhttpRequest({
  202. method: "POST",
  203. url: API_URL,
  204. headers: { "Content-Type": "application/json" },
  205. data: JSON.stringify({
  206. model: MODEL_NAME,
  207. prompt: prompt,
  208. stream: false,
  209. }),
  210. onload: (response) => {
  211. const data = JSON.parse(response.responseText);
  212. suggestion = data.response.trim().replace(/^"(.*)"$/, "$1"); // Remove surrounding quotes
  213. console.log("AI Response:", suggestion);
  214. suggestion = cleanSuggestion(inputText, suggestion);
  215.  
  216. if (suggestion) {
  217. const cursorPosition = activeElement.selectionStart || 0;
  218. const rect = activeElement.getBoundingClientRect();
  219. updateSuggestionBox({
  220. left: rect.left + cursorPosition * 7, // Estimate cursor X pos
  221. top: rect.top + window.scrollY,
  222. });
  223. }
  224. statusBar.innerText = "Ready";
  225. statusBar.classList.remove("glowing"); // Stop glowing effect
  226. },
  227. onerror: () => {
  228. statusBar.innerText = "Error!";
  229. statusBar.classList.remove("glowing"); // Stop glowing effect
  230. },
  231. });
  232. }
  233.  
  234. function handleInput(event) {
  235. if (!activeElement) return;
  236. const text = getTextFromElement(activeElement);
  237. const wordCount = countWords(text);
  238.  
  239. if (wordCount < 3) {
  240. statusBar.innerText = "Waiting for more input...";
  241. suggestionBox.style.display = "none";
  242. return;
  243. }
  244.  
  245. const lastChar = text.slice(-1);
  246. const isPunctuation = /[\s.,!?;:(),。;()!?【】「」『』、]/.test(
  247. lastChar,
  248. );
  249. const isDeleteOnlyPunctuation =
  250. lastInputText.length > text.length &&
  251. !/[a-zA-Z0-9\u4e00-\u9fa5]/.test(lastInputText.replace(text, ""));
  252.  
  253. if (isPunctuation || isDeleteOnlyPunctuation) {
  254. lastInputText = text;
  255. return;
  256. }
  257.  
  258. if (debounceTimer) clearTimeout(debounceTimer);
  259. debounceTimer = setTimeout(() => fetchCompletion(text), 1000);
  260.  
  261. lastInputText = text;
  262. }
  263.  
  264. function handleKeyDown(event) {
  265. if (event.key === "Tab" && suggestion) {
  266. event.preventDefault();
  267. event.stopPropagation();
  268.  
  269. if (activeElement.tagName === "TEXTAREA") {
  270. const start = activeElement.selectionStart;
  271. const end = activeElement.selectionEnd;
  272. activeElement.value =
  273. activeElement.value.substring(0, start) +
  274. suggestion +
  275. activeElement.value.substring(end);
  276. activeElement.selectionStart = activeElement.selectionEnd =
  277. start + suggestion.length;
  278. }
  279.  
  280. suggestionBox.style.display = "none";
  281. suggestion = "";
  282. }
  283. }
  284.  
  285. function initListeners() {
  286. document.addEventListener("focusin", (event) => {
  287. if (event.target.matches("textarea, [contenteditable='true']")) {
  288. activeElement = event.target;
  289. activeElement.addEventListener("input", handleInput);
  290. activeElement.addEventListener("keydown", handleKeyDown);
  291. lastInputText = getTextFromElement(activeElement);
  292. }
  293. });
  294.  
  295. document.addEventListener("focusout", () => {
  296. suggestionBox.style.display = "none";
  297. });
  298. }
  299. // OCR Module
  300. function hashString(str) {
  301. let hash = 0;
  302. for (let i = 0; i < str.length; i++) {
  303. hash = (hash << 5) - hash + str.charCodeAt(i);
  304. hash |= 0; // Convert to 32-bit integer
  305. }
  306. return `ocr-${Math.abs(hash)}`;
  307. }
  308.  
  309. function fetchImageAsDataURL(url) {
  310. console.log(`Fetching image: ${url}`);
  311. return new Promise((resolve, reject) => {
  312. GM_xmlhttpRequest({
  313. method: "GET",
  314. url: url,
  315. responseType: "blob",
  316. onload: function (response) {
  317. let reader = new FileReader();
  318. reader.onloadend = () => resolve(reader.result);
  319. reader.readAsDataURL(response.response);
  320. },
  321. onerror: (err) => {
  322. console.error(`Failed to fetch image: ${url}`, err);
  323. reject(err);
  324. },
  325. });
  326. });
  327. }
  328.  
  329. function isValidOCR(text) {
  330. let cleanText = text.replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, "").trim();
  331. if (cleanText.length < 3) return false;
  332.  
  333. // 中文分词
  334. let words = segmentit.doSegment(cleanText);
  335. // 过滤无意义的单个汉字
  336. words = words.filter((word) => word.w.length > 1);
  337. return words.length > 1; // 至少包含 1 个有意义的词
  338. }
  339.  
  340. function cleanOCRText(ocrText) {
  341. let lines = ocrText.split("\n").filter(isValidOCR);
  342. return lines.join("\n");
  343. }
  344.  
  345. function cleanText(text) {
  346. let cleaned = text.replace(
  347. /\s*(?=[\p{Script=Han},。!?:“”‘’;()【】])/gu,
  348. "",
  349. );
  350. cleaned = cleanOCRText(cleaned);
  351. console.log(`Cleaned OCR text: ${cleaned}`);
  352. return cleaned;
  353. }
  354.  
  355. function runOCR(imageDataURL) {
  356. statusBar.innerText = "Analyzing...";
  357. statusBar.classList.add("glowing"); // Start glowing effect
  358. return new Promise((resolve, reject) => {
  359. Tesseract.recognize(imageDataURL, "chi_sim", {
  360. logger: (m) => {
  361. // console.log(m)
  362. statusBar.innerText = `Analyzing (${(m.progress * 100).toFixed(2)}%)`;
  363. },
  364. })
  365. .then(({ data: { text } }) => resolve(cleanText(text)))
  366. .catch((err) => {
  367. console.error("OCR Error:", err);
  368. resolve("(OCR Failed)");
  369. })
  370. .finally(() => {
  371. statusBar.innerText = "Idle";
  372. statusBar.classList.remove("glowing"); // Stop glowing effect
  373. });
  374. });
  375. }
  376.  
  377. function processImages(callback) {
  378. let images = document.querySelectorAll('img[onclick^="zoom"]');
  379. console.log(`Found ${images.length} images for OCR processing.`);
  380. let tasks = [];
  381.  
  382. images.forEach((img) => {
  383. let match = img.getAttribute("onclick").match(/zoom\(this, '([^']+)'\)/);
  384. if (match && match[1]) {
  385. let fullImageUrl = match[1];
  386. let spanId = hashString(fullImageUrl); // Generate unique ID
  387. console.log(`Processing image: ${fullImageUrl} (ID: ${spanId})`);
  388.  
  389. let existingSpan = document.getElementById(spanId);
  390. if (!existingSpan) {
  391. let resultSpan = document.createElement("span");
  392. resultSpan.className = "ocr-result";
  393. resultSpan.id = spanId; // Assign unique ID
  394. resultSpan.style.display = "none";
  395. img.insertAdjacentElement("afterend", resultSpan);
  396. }
  397.  
  398. let task = fetchImageAsDataURL(fullImageUrl)
  399. .then((dataURL) => runOCR(dataURL))
  400. .then((ocrText) => {
  401. let resultSpan = document.getElementById(spanId); // Query by ID before updating
  402. if (resultSpan) {
  403. resultSpan.textContent = ocrText.trim()
  404. ? ocrText
  405. : "(No text detected)";
  406. console.log(`✅ Inserted OCR text into #${spanId}`);
  407. } else {
  408. console.warn(
  409. `⚠️ Could not find span #${spanId} to insert OCR text.`,
  410. );
  411. }
  412. });
  413.  
  414. tasks.push(task);
  415. }
  416. });
  417.  
  418. Promise.all(tasks).then(() => {
  419. console.log(`OCR completed for ${images.length} images.`);
  420. if (typeof callback === "function") callback();
  421. });
  422. }
  423.  
  424. createStatusBar();
  425. processImages(() => {
  426. createSuggestionBox();
  427. initListeners();
  428. activeElement = document.querySelector("#fastpostmessage");
  429. if (AUTO_MODE && activeElement) {
  430. setTimeout(() => {
  431. fetchCompletion(AUTO_LEADING_WORDS);
  432. const s = setInterval(() => {
  433. if (suggestion !== "") {
  434. activeElement.value = AUTO_PREFIX + suggestion;
  435. suggestionBox.style.display = "none";
  436. suggestion = "";
  437. clearInterval(s);
  438. }
  439. }, 100);
  440. }, 1000);
  441. }
  442. });
  443. })();