// ==UserScript== // @name בודק כתיב חכם — Raviv Digital // @namespace http://tampermonkey.net/ // @version 1.0.0 // @description בדיקת שגיאות כתיב אוטומטית עם AI (Gemini) ברגע השליחה — Gmail ופורומי NodeBB (מתמחים טופ, קהילת בני ברק, בינה טופ) // @author Raviv Digital // @match https://mail.google.com/* // @match https://mitmachim.top/* // @match https://bnebrak.com/* // @match https://bina.top/* // @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAIjklEQVR4nNWae4yVxRXAf+fMfPfeffEQ0dZUq2KVp2hT1BYfCBjx1dTopRTEpEZbTW0VqUYKVFSUUqXQaA02raai1LJN1dYqEahaYyvdxgfQ1sRYW+lDiwvy2GX33jtz+sd97F1clt3l6SSTe7/JzPl+c+bMmfPNjLBLypJ1jTQGgFEnnncB0aYodrqIHa1QoyAKqIADtDrLLs+7lLme1TGFnU5koxdZ60QeX7R+1bMAK7JZN7mxyFZO0sV/Gz5kwiSUOQ4Z61QQM9QiUvXCPsF3Va+Ldk7Ai5KIgBmKvRyV+feuW7PSQEqgVg1d6ciwIeMXOedmKIAFUywqovtb89VlrghkahZVRDPOiQdCDIvv2vC7mdYBbApINptVQIadcO5y71MziIVALIQitLi9hu8qdwNfaidOxDlBQiyEfCiEOp+aMXfkhOXzQFaUmCWbzbrGxsZw0pBzF6d9+sZYaM+pSqo74fvDbHoiXyDXP0mnWvJtS+ZteH7GimzWCcCw48dNcD61mlgoqNCh8UMIvlgm5oxQ473PhTBx9vrVaxRuU0QWKGaKySEMj4IIJmJmSlxwG7epjBgy7nxVv1II5cl6qMJXybeYUqfRbJKCTHcqpqUZfejDF4chETEhTveqMkYsiiDycYAv1hMxi+KMMergGDFDQT8e8KCgZoaKHOMdZKRUSQ4ivBNBRZAY8d4hMSK2e/kl1oxK54KDBm+FQH5HCypC+9btSLTyirxb+QL4g202TgRCIJ1JccmdN3P0qSN468VXeGHRj4tKVVDbvXx/sG3eqdK2dTuTF85m9MUTARh8/KfZuamZPyx9hPSA/hBClwwC+MowHQR47z2tm5qZ9O1rGX3xREK+ABjqHEeNGorEiFJMXYQVqJRN6CDAJ97TunkLYyZfwsQbryaGgHoHZogIb6z4DalUCjFDupN/UDTvHG1btzHktFO5bMEsLEZEtPiryjOzFvDOC3+kpr4Wsbhb+VLpwIGEVyXsbGPQpz7J1Pvm49MpACxG1Dlevv9hXlv+K+oH9oe4e/gylz+gE1YEC4EklXDFAwvo94nBxBCLdu8dG55ayUs/eJCGQYdBDN1rvqKQXsJ7VVT4qHZ64s1EyLXuZMoDd3PUyJOIIQCCOse/X13Pyu98j5r6OhTr7Fi6k98beCdCe0sLuZZWvGqnOnuC997TtmUrF836JiMmnUssBEQEdcqHG//Dk9fPRs2Kq3AxtOmZcjrguocXMwrt7Yw447OceOpIcjtaURFcD+CTxLOzeTOfn34ZZ14zlRhC6StbyLW28tT1s2nd1Ey6JtPrka2sxNINvBMh5HJ87Xu3cvblFwHwzNJlNN51H/0HDYBC2D2897R9uI2h477AJbfPLHkcwSyiojxzy128t+6v1A8aCIVC781yT5r3quRaWxl22imcfflFWIzEGLnw2ulMuvorbH//AxLvuzYb58i3tHLkkGP58g/vwHkPgFlxsXp+4Y9489fP9R2+VLZHb1MegfLLBSGGwOTv3sg50y6lpXkzSeI7tXOqWD5PbUM905YuoHZgfyzGCvzry5/gT0uX0TD4sL2C1wq8dA1PjNTW1fL2n9ex6qFfoM4RY8mGzbjy3rmc9qXz2bGpudIJV4rlYy7P5CXzGDzkWGIIFfh3XlrL6nmLqBvQr2/erHrOfiQW6moCx0htfR0/n7eIV554Fuc9FiIgWDSm3TOXURPOpLV5C0mS4JzS/uE2vnj7TE446/Qqj+Nofvsf/HbGPJJUgqqg1R6nF5qvMBdNpPtG5cCpvl8Dy265m9dWPo96h8UIAkkmzVcfXMjQs8+gfes22rds45xrpzNm6qVFeBVEhJ1btvLkdbPIb99BKp2qBGu9Nptqs++uQqcyM5wKqVTCw9fP4c2X1hY7ESIWI0kmzRX3zeeI445h6PixnH/rNzrcJRCj8fRN89jy9jtkGuo6QuS9gBdApg4bb3tqVC3cqWKFAqnEc81PF3Hc504ugooiKrQ0b8GlEjIN9ZhZJcZ5bs73eePRX9Jw+GFY9aTtq+Z3Xch6GtuIRZLEE3I5HrrqJjau+xvqHGYRzKgbNJBMQz3QEaA1/WQ5rz/aSP3hA/cJfKc52xv4yrdojKTTaUJ7jseum8UHf3+35J2KbhKzYnzvHG899yK/X3g/9QMHIPvAbDp5oPIk7g18ORMCmdoaWjc187Mrb2DzP/9V7ERxYxt1jvc3vMmzN99BuiZTGr29h/9Iva4q9HTfhhCoqa9lx3v/Y8V1s2j5YDMu8Wjiad+2nadvmAu5PIn3HQHavoQXkKuHd57Evd10UoMkceS27+DIzxzPWd+6iiSToenBR/jvq+upqa/r9FG+rzcQ5OvDx5vsalc9hC97AwG8K35pkS8UI1TnSNfWdPj6/QDvAC/Q5oRMn+CrsoRIOpPG1WQqXmJ/w4vRpiq861UQiL2G33XFNoMYi9uC+xFeIaZVUOxdVawpQa14wNF3+L56sz7Ao2aWFjWBJnXYMqx0MvNxgC8+SzQTp7pMB/zlnFUxhqYa50XNwqEMXzqdCQ3eS85C0+gLx64SgJtHjZtQI6nV+VB1yHeIwZeyOQj9vPc7Qpg4umn1Gl2Rzbp71r+wpj3mlzQkKS9C/lCDrxwtYfnBqZTfns8tGd20eo2Vjlklm83q8MZGiyMnPNYvSU1pzbcHBZyI2xPAAdG8WVCBI1Jptzmfe3xU05ppjdmsZBsbo1BMUj6+v3PkxEUZ52YIRj4GUyleNXBVp/UHAl7AnFkUEe3nnDgR2kNYPKJp9UwrQ4OVO1DdCbv75AmTEmSOioxNiRDNwGL3u8S9he+mjgPSoqRVCKXLHjmz+ac0FS97lOFLv51T9ZWWxaecd4GaTRHjdMGOVqFGqg/C988Kaw52qshGD2udyuMj1hav21g262SX6zb/B9C2VG8/oNDTAAAAAElFTkSuQmCC // @grant GM_setValue // @grant GM_getValue // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @connect generativelanguage.googleapis.com // @run-at document-idle // @noframes // ==/UserScript== /** * בודק כתיב חכם — Raviv Digital (גרסת Tampermonkey) * ===================================================== * גרסה זו וגרסת התוסף (Chrome Extension) חולקות את אותה לוגיקה, ומתעדכנות יחד. * * איך פותחים הגדרות (מפתח API / מודל / השהיה באתר): * לחצו על סמל Tampermonkey בסרגל הכלים ← "⚙️ הגדרות — בודק כתיב חכם" * (או: Tampermonkey ← Dashboard ← לחצו על שם הסקריפט ← יש שם גם את אותו פאנל). * * איך מוסיפים אתר נוסף: * Tampermonkey Dashboard ← לחצו על שם הסקריפט לעריכה ← הוסיפו שורה בראש הקובץ: * // @match https://the-new-site.com/* * שמרו (Ctrl+S). זהו — הזיהוי הגנרי (לפי מילות "שלח/פרסם") יתחיל לעבוד שם מיד. * לאתרי NodeBB נוספים (כמו האתרים הקיימים) לא צריך שום דבר מעבר לזה. */ (function () { "use strict"; /* ============================== הגדרות כלליות ============================== */ const DEFAULT_MODEL = "gemini-3.5-flash-lite"; const BUILT_IN_MODELS = ["gemini-3.5-flash-lite", "gemini-3.6-flash", "gemini-flash-latest"]; const API_BASE = "https://generativelanguage.googleapis.com/v1beta/models"; const MAX_CHARS = 15000; const TIMEOUT_MS = 15000; const CACHE_MAX = 30; const cache = new Map(); // תוצאות לטקסטים זהים באותו טעינת-דף (למשל ביטול ושליחה חוזרת) const SEND_WORDS = [ "שלח", "שליחה", "לשלוח", "פרסם", "פרסום", "לפרסם", "send", "post", "submit", "publish" ]; const siteKey = location.hostname; let lastActiveEditable = null; let bypassEl = null; let uiRoot = null; let activeReq = 0; let loadingTimer = null; // NodeBB (מתמחים טופ / בני ברק / בינה טופ): כפתור השליחה של ה-composer הוא איקון בלבד ללא // טקסט, ולכן מזוהה לפי class/attribute. `.composer-submit` אומת מול התבנית הרשמית של NodeBB. const NODEBB_SUBMIT_SELECTOR = '.composer-submit, [component="composer/submit"], [component="topic/quickreply/button"]'; const NODEBB_COMPOSER_SELECTOR = '.composer-container, .composer, [component="composer"]'; const TEXT_FIELD_SELECTOR = 'textarea, input[type="text"], input[type="email"], input[type="search"], [contenteditable="true"]'; const SYSTEM_INSTRUCTION = `אתה עוזר הגהה מקצועי בעברית. תפקידך היחיד: לאתר שגיאות כתיב/איות אמיתיות בטקסט הנתון — כלומר מילים שכתובות בצורה שגויה. הטקסט עשוי להיות הודעת מייל או פוסט בפורום (כותרת וגוף יחד), ועשוי לכלול סימני Markdown. אל תסמן: שגיאות תחביר, ניקוד, פיסוק, סגנון, משלב לשוני, שמות פרטיים/מותגים, ראשי תיבות וקיצורים מקובלים (למשל בס"ד, בעז"ה, וכו'), מונחים טכניים ומילים באנגלית שכתובות נכון, כינויי משתמשים (@...), כתובות אתר או מייל, קוד וסימני Markdown. אם מילה יכולה להיות תקינה בהקשר מסוים (למשל סלנג נפוץ, קיצור מקובל) — אל תסמן אותה. עבור כל שגיאה החזר את המילה בדיוק כפי שהיא מופיעה בטקסט המקורי, פעם אחת בלבד ברשימה. החזר JSON בלבד, בפורמט: {"hasErrors": true|false, "errors": [{"wrong": "...", "correct": "..."}]} אם אין שגיאות: {"hasErrors": false, "errors": []}`; const RESPONSE_SCHEMA = { type: "OBJECT", properties: { hasErrors: { type: "BOOLEAN" }, errors: { type: "ARRAY", items: { type: "OBJECT", properties: { wrong: { type: "STRING" }, correct: { type: "STRING" } }, required: ["wrong", "correct"] } } }, required: ["hasErrors", "errors"] }; /* ============================== אחסון (GM_*) ============================== */ // מסונכרן (ללא await) — פישוט אפשרי כי סקריפט משתמש אינו זקוק לריבוי הקשרים כמו תוסף כרום. function getSitesMap() { try { return JSON.parse(GM_getValue("rd_sites", "{}")) || {}; } catch (_) { return {}; } } function setSitesMap(sites) { GM_setValue("rd_sites", JSON.stringify(sites)); } function siteEnabledFor(host) { const sites = getSitesMap(); return sites[host] ? sites[host].enabled !== false : true; } function setSiteEnabled(host, enabled) { const sites = getSitesMap(); sites[host] = { enabled }; setSitesMap(sites); } const getApiKey = () => GM_getValue("rd_apiKey", ""); const setApiKey = (v) => GM_setValue("rd_apiKey", v || ""); const getModel = () => GM_getValue("rd_model", DEFAULT_MODEL); const setModel = (v) => GM_setValue("rd_model", v || DEFAULT_MODEL); let siteEnabled = siteEnabledFor(siteKey); /* ============================== Gemini API ============================== */ function gmRequest({ method, url, headers, data, timeout }) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: method || "GET", url, headers: headers || {}, data, timeout: timeout || TIMEOUT_MS, onload: (res) => resolve({ ok: res.status >= 200 && res.status < 300, status: res.status, responseText: res.responseText }), onerror: () => resolve({ kind: "network" }), ontimeout: () => resolve({ kind: "timeout" }) }); }); } function thinkingConfigFor(model) { const m = (model || "").toLowerCase(); if (m.includes("lite")) return null; // Flash-Lite: חשיבה מינימלית כברירת מחדל if (m.includes("2.5")) return { thinkingBudget: 0 }; return { thinkingLevel: "low" }; } function buildBody(text, model, level) { const generationConfig = { responseMimeType: "application/json", maxOutputTokens: 4096 }; if (level === "full") { generationConfig.responseSchema = RESPONSE_SCHEMA; const t = thinkingConfigFor(model); if (t) generationConfig.thinkingConfig = t; } return { systemInstruction: { parts: [{ text: SYSTEM_INSTRUCTION }] }, contents: [{ role: "user", parts: [{ text: `בדוק שגיאות כתיב בטקסט הבא:\n\n"""\n${text}\n"""` }] }], generationConfig }; } async function callGemini(apiKey, model, body) { const url = `${API_BASE}/${encodeURIComponent(model)}:generateContent`; const res = await gmRequest({ method: "POST", url, headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey }, data: JSON.stringify(body), timeout: TIMEOUT_MS }); if (res.kind) return { ok: false, kind: res.kind }; if (!res.ok) { let message = ""; try { message = JSON.parse(res.responseText)?.error?.message || ""; } catch (_) {} return { ok: false, status: res.status, message }; } let data; try { data = JSON.parse(res.responseText); } catch (_) { return { ok: false, status: res.status, message: "invalid JSON response" }; } return { ok: true, data }; } const short = (s) => (s || "").replace(/\s+/g, " ").slice(0, 90); const isKeyError = (msg) => /api key|api_key|key.*(invalid|expired)|invalid.*key/i.test(msg || ""); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function describeFailure(res) { if (res.kind === "timeout") return { reason: "timeout", userMessage: "הבדיקה ארכה יותר מדי" }; if (res.kind === "network") return { reason: "network-error", userMessage: "אין חיבור ל-Gemini — בדקו את האינטרנט" }; const s = res.status; if ((s === 400 && isKeyError(res.message)) || s === 401 || s === 403) { return { reason: "bad-key", userMessage: "מפתח ה-API נדחה" + (res.message ? `: ${short(res.message)}` : "") }; } if (s === 404) return { reason: "not-found", userMessage: "המודל לא נמצא — בחרו מודל אחר בהגדרות" }; if (s === 429) return { reason: "rate-limit", userMessage: "חרגתם ממכסת ה-API — נסו שוב בעוד רגע" }; if (s >= 500) return { reason: "server", userMessage: "שרת Gemini עמוס כרגע" }; return { reason: "api-error", userMessage: `שגיאת Gemini (${s})` + (res.message ? `: ${short(res.message)}` : "") }; } function parseModelOutput(data) { const parts = data?.candidates?.[0]?.content?.parts || []; let raw = parts.filter((p) => !p.thought).map((p) => p.text || "").join("").trim(); if (!raw) return null; raw = raw.replace(/^```(?:json)?/i, "").replace(/```$/, "").trim(); try { return JSON.parse(raw); } catch (_) { const m = raw.match(/\{[\s\S]*\}/); if (!m) return null; try { return JSON.parse(m[0]); } catch (_) { return null; } } } async function checkSpelling(rawText, noCache) { const started = Date.now(); const apiKey = getApiKey(); const model = getModel(); if (!apiKey) return { ok: false, reason: "no-key", userMessage: "לא הוגדר מפתח API לבדיקת כתיב" }; let text = (rawText || "").trim(); const truncated = text.length > MAX_CHARS; if (truncated) text = text.slice(0, MAX_CHARS); if (!/\p{L}{2,}/u.test(text)) return { ok: true, hasErrors: false, errors: [], ms: 0 }; const useModel = model || DEFAULT_MODEL; const cacheKey = `${useModel}|${text}`; if (!noCache && cache.has(cacheKey)) return { ...cache.get(cacheKey), cached: true, ms: 0 }; let res = await callGemini(apiKey, useModel, buildBody(text, useModel, "full")); if (!res.ok && res.status === 400 && !isKeyError(res.message)) { res = await callGemini(apiKey, useModel, buildBody(text, useModel, "basic")); } if (!res.ok && [429, 500, 502, 503, 504].includes(res.status)) { await sleep(700); res = await callGemini(apiKey, useModel, buildBody(text, useModel, "full")); } if (!res.ok) return { ok: false, ...describeFailure(res), message: res.message, ms: Date.now() - started }; const parsed = parseModelOutput(res.data); if (!parsed) { return { ok: false, reason: "parse-error", userMessage: "המודל לא החזיר תשובה תקינה", ms: Date.now() - started }; } const errors = (Array.isArray(parsed.errors) ? parsed.errors : []).filter( (e) => e && e.wrong && e.correct && e.wrong !== e.correct && text.includes(e.wrong) ); const result = { ok: true, hasErrors: errors.length > 0, errors, truncated }; if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value); cache.set(cacheKey, result); return { ...result, ms: Date.now() - started }; } /* ============================== זיהוי DOM וטקסט ============================== */ function isVisible(el) { if (!el || !el.isConnected) return false; const rect = el.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) return false; const style = getComputedStyle(el); return style.visibility !== "hidden" && style.display !== "none"; } function isEditableEl(el) { if (!el) return false; if (el.tagName === "TEXTAREA") return true; if (el.tagName === "INPUT") return ["text", "email", "search"].includes(el.type); return !!el.isContentEditable; } function getTokens(label) { return label .replace(/[()\[\]{}.,:;!?"'׳״`\-–—]/g, " ") .split(/\s+/) .filter(Boolean); } function isSendLikeLabel(label) { if (!label) return false; const tokens = getTokens(label); return tokens.some((t) => { const lower = t.toLowerCase(); return SEND_WORDS.some((w) => lower.includes(w)); }); } function findSendTrigger(target) { if (!(target instanceof Element)) return null; const nb = target.closest(NODEBB_SUBMIT_SELECTOR); if (nb && !nb.disabled && nb.getAttribute("aria-disabled") !== "true" && isVisible(nb)) return nb; const el = target.closest( 'button, [role="button"], input[type="submit"], input[type="button"], a[role="button"]' ); if (!el) return null; if (el.disabled || el.getAttribute("aria-disabled") === "true") return null; if (!isVisible(el)) return null; const label = ( el.innerText || el.value || el.getAttribute("aria-label") || el.getAttribute("title") || "" ).trim(); if (!isSendLikeLabel(label)) return null; return el; } function findAnySendButton(fields) { const anchor = fields && fields[0]; if (anchor && anchor.closest) { const composer = anchor.closest(NODEBB_COMPOSER_SELECTOR); const nbBtn = composer && composer.querySelector(NODEBB_SUBMIT_SELECTOR); if (nbBtn && isVisible(nbBtn)) return nbBtn; } const all = Array.from( document.querySelectorAll( 'button, [role="button"], input[type="submit"], input[type="button"], a[role="button"]' ) ); return ( all.find((el) => { if (el.disabled || el.getAttribute("aria-disabled") === "true") return false; if (!isVisible(el)) return false; const label = ( el.innerText || el.value || el.getAttribute("aria-label") || el.getAttribute("title") || "" ).trim(); return isSendLikeLabel(label); }) || null ); } function extractText(el) { if (!el) return ""; if (el.tagName === "TEXTAREA" || el.tagName === "INPUT") { return (el.value || "").trim(); } if (el.isContentEditable) { const restore = []; if (siteKey === "mail.google.com") { el.querySelectorAll(".gmail_quote, .gmail_extra, .gmail_signature, blockquote").forEach((q) => { restore.push([q, q.style.display]); q.style.display = "none"; }); } const text = el.innerText || el.textContent || ""; restore.forEach(([q, disp]) => { q.style.display = disp; }); return text.replace(/\u00A0/g, " ").trim(); } return ""; } function pickEditableField(trigger) { let scope = trigger; for (let i = 0; i < 8 && scope && scope !== document.body; i++) { scope = scope.parentElement; if (!scope) break; const candidates = Array.from(scope.querySelectorAll(TEXT_FIELD_SELECTOR)).filter(isVisible); if (candidates.length) { if (lastActiveEditable && candidates.includes(lastActiveEditable)) return lastActiveEditable; return candidates.reduce( (best, c) => (extractText(c).length > extractText(best).length ? c : best), candidates[0] ); } } if (lastActiveEditable && document.contains(lastActiveEditable) && isVisible(lastActiveEditable)) { return lastActiveEditable; } const all = Array.from(document.querySelectorAll(TEXT_FIELD_SELECTOR)).filter(isVisible); if (!all.length) return null; return all.reduce((best, c) => (extractText(c).length > extractText(best).length ? c : best), all[0]); } function getNodeBBFields(fromEl) { const composer = fromEl && fromEl.closest ? fromEl.closest(NODEBB_COMPOSER_SELECTOR) : null; if (!composer) return null; const fields = Array.from(composer.querySelectorAll(TEXT_FIELD_SELECTOR)).filter((el) => { if (!isVisible(el)) return false; if (el.closest(".tags-container, .bootstrap-tagsinput, [component*='tag']")) return false; return true; }); return fields.length ? fields : null; } function collectFields(trigger, activeEl) { const nb = getNodeBBFields(trigger || activeEl); if (nb) return nb; const single = trigger ? pickEditableField(trigger) : activeEl; return single ? [single] : []; } function cleanForCheck(text) { return (text || "") .replace(/```[\s\S]*?```/g, " ") .replace(/^\s*>.*$/gm, " ") .replace(/https?:\/\/\S+/g, " ") .trim(); } function extractFieldsText(fields) { return cleanForCheck(fields.map(extractText).filter(Boolean).join("\n\n")); } function replaceWord(str, find, replace) { if (!find || !str.includes(find)) return str; const esc = find.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const re = new RegExp(`(? replace); } function replaceInTextNodes(root, find, replace) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const nodes = []; let n; while ((n = walker.nextNode())) nodes.push(n); nodes.forEach((node) => { if (node.nodeValue && node.nodeValue.includes(find)) { node.nodeValue = replaceWord(node.nodeValue, find, replace); } }); } function applyFixesToField(el, fixes) { if (!el || !fixes.length) return; if (el.tagName === "TEXTAREA" || el.tagName === "INPUT") { let val = el.value; fixes.forEach((f) => { val = replaceWord(val, f.wrong, f.correct); }); if (val === el.value) return; const proto = el.tagName === "TEXTAREA" ? HTMLTextAreaElement : HTMLInputElement; const setter = Object.getOwnPropertyDescriptor(proto.prototype, "value").set; setter.call(el, val); el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); } else if (el.isContentEditable) { fixes.forEach((f) => replaceInTextNodes(el, f.wrong, f.correct)); el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertReplacementText" })); } } function applyFixesToFields(fields, fixes) { fields.forEach((el) => applyFixesToField(el, fixes)); } function escapeHtml(str) { return String(str).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } /* ------------------------------ אייקונים ------------------------------ */ const ICONS = { pencil: ``, check: ``, close: ``, arrowLeft: ``, gear: ``, eye: `` }; /* -------------------------------- UI -------------------------------- */ const CSS_TEXT = ` .rd-overlay, .rd-toast { font-family: 'Segoe UI', Tahoma, Arial, sans-serif; } .rd-overlay { position: fixed; inset: 0; z-index: 2147483647; background: rgba(15,17,26,.55); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; animation: rd-fade-in .18s ease; padding: 16px; } .rd-modal { width: min(480px, 100%); max-height: 82vh; overflow-y: auto; background: #fff; border-radius: 18px; padding: 22px 22px 18px; box-shadow: 0 24px 64px -12px rgba(20,23,31,.4), 0 4px 14px rgba(20,23,31,.1); animation: rd-pop-in .22s cubic-bezier(.2,.9,.3,1); color: #14171F; font-size: 14px; line-height: 1.5; } .rd-modal-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 16px; } .rd-header-main { display: flex; align-items: flex-start; gap: 12px; } .rd-modal-icon { width: 38px; height: 38px; flex-shrink: 0; border-radius: 11px; color: #fff; background: linear-gradient(135deg, #E23744, #FF6B5B); display: flex; align-items: center; justify-content: center; } .rd-modal-title { font-size: 16px; font-weight: 700; margin: 2px 0 3px; color: #14171F; } .rd-modal-subtitle { font-size: 12.5px; color: #6B7280; margin: 0; } .rd-icon-btn { width: 28px; height: 28px; flex-shrink: 0; border: none; border-radius: 8px; background: transparent; color: #6B7280; cursor: pointer; display: flex; align-items: center; justify-content: center; } .rd-icon-btn:hover { background: #F1F2F5; color: #14171F; } .rd-error-list { display: flex; flex-direction: column; gap: 8px; max-height: 300px; overflow-y: auto; margin-bottom: 16px; padding: 2px; } .rd-error-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; border: 1px solid #E6E8EE; border-radius: 12px; background: #F7F8FA; cursor: pointer; transition: border-color .15s, background .15s; } .rd-error-item:has(.rd-error-check:checked) { border-color: #F3B7BC; background: #FFF6F6; } .rd-error-check { margin-top: 3px; width: 16px; height: 16px; accent-color: #E23744; flex-shrink: 0; cursor: pointer; } .rd-error-box { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } .rd-word-wrong { color: #E23744; text-decoration: line-through; font-weight: 600; } .rd-arrow { color: #9AA1AE; display: inline-flex; } .rd-word-correct { color: #16A37A; font-weight: 700; } .rd-explain { flex-basis: 100%; font-size: 11.5px; color: #6B7280; } .rd-modal-actions { display: flex; gap: 8px; flex-wrap: wrap; } .rd-btn { border: none; border-radius: 999px; padding: 10px 16px; font-size: 13.5px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; font-family: inherit; transition: transform .1s ease, box-shadow .15s ease, background .15s ease; } .rd-btn:active { transform: scale(.96); } .rd-btn:disabled { opacity: .6; cursor: default; } .rd-btn-primary { background: linear-gradient(135deg, #E23744, #FF6B5B); color: #fff; box-shadow: 0 6px 16px -4px rgba(226,55,68,.5); } .rd-btn-primary:hover { box-shadow: 0 8px 20px -4px rgba(226,55,68,.6); } .rd-btn-ghost { background: #F1F2F5; color: #14171F; } .rd-btn-ghost:hover { background: #E6E8EE; } .rd-btn-text { background: transparent; color: #6B7280; padding: 10px 8px; } .rd-btn-text:hover { color: #14171F; } .rd-modal-footer { display: flex; align-items: center; gap: 7px; margin-top: 14px; padding-top: 12px; border-top: 1px dashed #E6E8EE; font-size: 11px; color: #9AA1AE; } .rd-brand-mark { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 5px; background: #1A2342; color: #fff; font-size: 9px; font-weight: 800; flex-shrink: 0; } .rd-modal-footer strong { color: #6B7280; } .rd-modal-footer code { background: #F1F2F5; border-radius: 5px; padding: 1px 5px; font-size: 10.5px; } .rd-settings-body { display: flex; flex-direction: column; gap: 14px; margin-bottom: 16px; } .rd-field-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; background: #F7F8FA; border: 1px solid #E6E8EE; border-radius: 12px; } .rd-field-title { font-size: 13px; font-weight: 600; } .rd-field-sub { font-size: 11.5px; color: #6B7280; margin-top: 2px; } .rd-field { display: flex; flex-direction: column; gap: 6px; } .rd-field-label { font-size: 12.5px; font-weight: 600; color: #14171F; } .rd-input-row { display: flex; gap: 8px; } .rd-input { width: 100%; border: 1px solid #E6E8EE; border-radius: 10px; padding: 9px 11px; font-size: 13.5px; font-family: inherit; background: #fff; color: #14171F; } .rd-input:focus { outline: none; border-color: #E23744; box-shadow: 0 0 0 3px rgba(226,55,68,.12); } .rd-input-btn { width: 38px; flex-shrink: 0; border: 1px solid #E6E8EE; background: #fff; border-radius: 10px; display: flex; align-items: center; justify-content: center; cursor: pointer; color: #6B7280; } .rd-input-btn:hover { background: #F7F8FA; color: #14171F; } .rd-help-link { font-size: 11.5px; color: #E23744; text-decoration: none; } .rd-help-link:hover { text-decoration: underline; } .rd-switch { position: relative; display: inline-block; width: 38px; height: 22px; flex-shrink: 0; } .rd-switch input { opacity: 0; width: 0; height: 0; } .rd-slider { position: absolute; cursor: pointer; inset: 0; background: #D7DBE3; border-radius: 999px; transition: .2s; } .rd-slider:before { content: ""; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: .2s; box-shadow: 0 1px 3px rgba(0,0,0,.25); } .rd-switch input:checked + .rd-slider { background: linear-gradient(135deg, #E23744, #FF6B5B); } .rd-switch input:checked + .rd-slider:before { left: 19px; } .rd-settings-msg { font-size: 12.5px; min-height: 16px; } .rd-settings-msg.ok { color: #16A37A; font-weight: 600; } .rd-settings-msg.err { color: #E23744; font-weight: 600; } .rd-toast { position: fixed; top: 18px; left: 50%; z-index: 2147483647; transform: translateX(-50%) translateY(-10px); opacity: 0; pointer-events: none; background: #1A2342; color: #fff; padding: 10px 14px; border-radius: 16px; line-height: 1.4; font-size: 12.5px; display: flex; align-items: center; gap: 8px; box-shadow: 0 12px 28px -8px rgba(20,23,31,.45); transition: opacity .18s ease, transform .18s ease; max-width: min(92vw, 540px); } .rd-toast.rd-show { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; } .rd-toast.rd-warning { background: #B4530F; } .rd-toast.rd-success { background: #128A6B; } .rd-toast-actions { display: flex; gap: 6px; } .rd-toast-btn { background: rgba(255,255,255,.18); border: none; color: #fff; padding: 5px 10px; border-radius: 999px; font-size: 11.5px; cursor: pointer; font-family: inherit; } .rd-toast-btn:hover { background: rgba(255,255,255,.3); } .rd-toast-btn-primary { background: #fff; color: #1A2342; font-weight: 700; } .rd-toast-btn-primary:hover { background: #F1F2F5; } .rd-spinner { width: 13px; height: 13px; border-radius: 50%; border: 2px solid rgba(255,255,255,.35); border-top-color: #fff; animation: rd-spin .7s linear infinite; flex-shrink: 0; } .rd-toast-icon { display: inline-flex; } @keyframes rd-spin { to { transform: rotate(360deg); } } @keyframes rd-fade-in { from { opacity: 0; } to { opacity: 1; } } @keyframes rd-pop-in { from { opacity: 0; transform: translateY(8px) scale(.97); } to { opacity: 1; transform: translateY(0) scale(1); } } @media (max-width: 480px) { .rd-modal { padding: 18px 16px 16px; } } @media (prefers-reduced-motion: reduce) { .rd-overlay, .rd-modal, .rd-toast, .rd-spinner { animation-duration: .001ms !important; transition-duration: .001ms !important; } } `; function ensureUI() { if (uiRoot) return uiRoot; const host = document.createElement("div"); host.id = "raviv-spellcheck-host"; document.documentElement.appendChild(host); const shadow = host.attachShadow({ mode: "open" }); const style = document.createElement("style"); style.textContent = CSS_TEXT; shadow.appendChild(style); const slot = document.createElement("div"); slot.id = "rd-slot"; slot.setAttribute("dir", "rtl"); shadow.appendChild(slot); uiRoot = { host, shadow, slot }; return uiRoot; } function getToastEl() { const { slot } = ensureUI(); let toast = slot.querySelector("#rd-toast"); if (!toast) { toast = document.createElement("div"); toast.id = "rd-toast"; toast.className = "rd-toast"; slot.appendChild(toast); } return toast; } function hideToast() { clearTimeout(loadingTimer); const { slot } = ensureUI(); const toast = slot.querySelector("#rd-toast"); if (toast) toast.className = "rd-toast"; } function showLoadingToast(onSkip) { clearTimeout(loadingTimer); const toast = getToastEl(); toast.className = "rd-toast rd-show"; toast.innerHTML = `בודק כתיב…`; loadingTimer = setTimeout(() => { const t = getToastEl(); if (!t.classList.contains("rd-show") || !t.querySelector(".rd-spinner")) return; t.innerHTML = `הבדיקה לוקחת יותר מהרגיל…` + ``; t.querySelector("[data-skip]").addEventListener("click", () => { hideToast(); onSkip(); }); }, 3000); } function showSuccessToastThenSend(trigger, fields) { const toast = getToastEl(); toast.className = "rd-toast rd-show rd-success"; toast.innerHTML = `${ICONS.check}אין שגיאות כתיב · שולח…`; setTimeout(() => { hideToast(); triggerRealSend(trigger, fields); }, 600); } function showActionToast(message, actions) { const toast = getToastEl(); toast.className = "rd-toast rd-show rd-warning"; const btns = actions .map((a, i) => ``) .join(""); toast.innerHTML = `${escapeHtml(message)}${btns}`; actions.forEach((a, i) => { const btn = toast.querySelector(`[data-i="${i}"]`); if (btn) { btn.addEventListener("click", () => { hideToast(); a.onClick && a.onClick(); }); } }); } function showErrorsModal(errors, fields, trigger) { const { slot } = ensureUI(); let container = slot.querySelector("#rd-modal-container"); if (!container) { container = document.createElement("div"); container.id = "rd-modal-container"; slot.appendChild(container); } const rows = errors .map( (err, i) => ` ` ) .join(""); container.innerHTML = `
`; const overlay = container.querySelector(".rd-overlay"); const onEsc = (e) => { if (e.key === "Escape") closeAll(); }; function closeAll() { container.innerHTML = ""; document.removeEventListener("keydown", onEsc, true); } document.addEventListener("keydown", onEsc, true); overlay.addEventListener("click", (e) => { if (e.target === overlay) closeAll(); }); container.querySelectorAll("[data-action]").forEach((btn) => { btn.addEventListener("click", () => { const action = btn.getAttribute("data-action"); if (action === "cancel") { closeAll(); } else if (action === "send-anyway") { closeAll(); triggerRealSend(trigger, fields); } else if (action === "fix-send") { const fixes = []; container.querySelectorAll(".rd-error-check").forEach((chk) => { if (chk.checked) fixes.push(errors[Number(chk.getAttribute("data-idx"))]); }); closeAll(); applyFixesToFields(fields, fixes); setTimeout(() => triggerRealSend(trigger, fields), 60); } }); }); setTimeout(() => container.querySelector(".rd-btn-primary")?.focus(), 20); } /* ============================== פאנל הגדרות ============================== */ function openSettingsPanel() { const { slot } = ensureUI(); let container = slot.querySelector("#rd-modal-container"); if (!container) { container = document.createElement("div"); container.id = "rd-modal-container"; slot.appendChild(container); } const apiKey = getApiKey(); const model = getModel(); const isCustom = !!model && !BUILT_IN_MODELS.includes(model); container.innerHTML = `
`; const overlay = container.querySelector(".rd-overlay"); const msgEl = container.querySelector("#rd-settings-msg"); const select = container.querySelector("#rd-model-select"); const customField = container.querySelector("#rd-custom-model-field"); const customInput = container.querySelector("#rd-custom-model"); const keyInput = container.querySelector("#rd-key-input"); select.value = isCustom ? "custom" : model || DEFAULT_MODEL; select.addEventListener("change", () => { customField.style.display = select.value === "custom" ? "" : "none"; }); container.querySelector("#rd-toggle-key").addEventListener("click", () => { keyInput.type = keyInput.type === "password" ? "text" : "password"; }); container.querySelector("#rd-site-toggle").addEventListener("change", (e) => { siteEnabled = e.target.checked; setSiteEnabled(siteKey, siteEnabled); }); function closeAll() { container.innerHTML = ""; document.removeEventListener("keydown", onEsc, true); } const onEsc = (e) => { if (e.key === "Escape") closeAll(); }; document.addEventListener("keydown", onEsc, true); overlay.addEventListener("click", (e) => { if (e.target === overlay) closeAll(); }); function currentModelValue() { return select.value === "custom" ? customInput.value.trim() || DEFAULT_MODEL : select.value; } function saveNow() { setApiKey(keyInput.value.trim()); setModel(currentModelValue()); } container.querySelectorAll("[data-action]").forEach((btn) => { btn.addEventListener("click", async () => { const action = btn.getAttribute("data-action"); if (action === "close") { closeAll(); return; } if (action === "save") { saveNow(); msgEl.className = "rd-settings-msg ok"; msgEl.textContent = "נשמר בהצלחה ✓"; return; } if (action === "test") { saveNow(); btn.disabled = true; msgEl.className = "rd-settings-msg"; msgEl.textContent = "בודק חיבור…"; const t0 = Date.now(); const res = await checkSpelling("אני רוצה לשלוח הודעה עם שגיעה בכתיב.", true); const sec = ((Date.now() - t0) / 1000).toFixed(1); btn.disabled = false; if (res && res.ok) { const found = res.hasErrors ? ` · זוהו ${res.errors.length} שגיאות בטקסט הבדיקה` : " · לא זוהתה שגיאה בטקסט הבדיקה (מצופה שגיאה אחת)"; msgEl.className = "rd-settings-msg ok"; msgEl.textContent = `החיבור תקין ✓ · זמן תגובה: ${sec} שניות${found}`; } else { msgEl.className = "rd-settings-msg err"; msgEl.textContent = `נכשל אחרי ${sec} שניות: ${(res && res.userMessage) || "שגיאה לא ידועה"}`; } } }); }); setTimeout(() => keyInput.focus(), 20); } /* --------------------------- לוגיקת שליחה --------------------------- */ function triggerRealSend(trigger, fields) { const btn = trigger && document.contains(trigger) ? trigger : findAnySendButton(fields); if (btn) { bypassEl = btn; btn.click(); setTimeout(() => { if (bypassEl === btn) bypassEl = null; }, 150); } else { showActionToast("לא נמצא כפתור שליחה — יש ללחוץ שוב באופן ידני", []); } } function handleCheckFailure(response, trigger, fields, text) { const reason = response && response.reason; const msg = (response && response.userMessage) || "בדיקת הכתיב נכשלה"; const sendAnyway = { label: "שלח בכל זאת", primary: true, onClick: () => triggerRealSend(trigger, fields) }; if (reason === "no-key" || reason === "bad-key") { showActionToast(msg, [{ label: "פתח הגדרות", onClick: () => openSettingsPanel() }, sendAnyway]); } else { showActionToast(msg, [ { label: "נסה שוב", onClick: () => handleInterceptedSend(trigger, fields, text) }, sendAnyway ]); } } async function handleInterceptedSend(trigger, fields, text) { const myReq = ++activeReq; showLoadingToast(() => { activeReq++; triggerRealSend(trigger, fields); }); let response; try { response = await checkSpelling(text); } catch (e) { response = { ok: false, reason: "network-error", userMessage: "שגיאה לא צפויה בבדיקה", message: String(e) }; } if (myReq !== activeReq) return; hideToast(); if (!response || !response.ok) { handleCheckFailure(response, trigger, fields, text); return; } if (!response.hasErrors || !response.errors || !response.errors.length) { showSuccessToastThenSend(trigger, fields); return; } showErrorsModal(response.errors, fields, trigger); } /* ============================== תפריט Tampermonkey ============================== */ if (typeof GM_registerMenuCommand === "function") { GM_registerMenuCommand("⚙️ הגדרות — בודק כתיב חכם", openSettingsPanel); } /* ------------------------------ מאזינים ------------------------------ */ document.addEventListener( "focusin", (e) => { if (isEditableEl(e.target)) lastActiveEditable = e.target; }, true ); document.addEventListener( "click", (e) => { if (!siteEnabled) return; const trigger = findSendTrigger(e.target); if (!trigger) return; if (bypassEl && trigger === bypassEl) { bypassEl = null; return; } const fields = collectFields(trigger, null); const text = extractFieldsText(fields); if (!text || text.length < 3) return; e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); handleInterceptedSend(trigger, fields, text); }, true ); document.addEventListener( "keydown", (e) => { if (!siteEnabled) return; if (!((e.ctrlKey || e.metaKey) && e.key === "Enter")) return; const active = document.activeElement; if (!isEditableEl(active)) return; const fields = collectFields(null, active); const text = extractFieldsText(fields); if (!text || text.length < 3) return; e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); handleInterceptedSend(null, fields, text); }, true ); })();