// ==UserScript== // @name מתמחים טופ – תיוג מומחים לפי קטגוריה // @namespace mitmachim-expert-tagger // @version 0.4.5 // @description שולף אוטומטית (פעם ביום) את המצביעים בסקר המומחים, ובפתיחת נושא חדש מציע לתייג את המומחים של הקטגוריה שבה נפתח הנושא // @match https://mitmachim.top/* // @run-at document-idle // @grant none // ==/UserScript== (() => { 'use strict'; /* ------------------------------------------------------------------ * * הגדרות * ------------------------------------------------------------------ */ const CONFIG = { POLL_TID: 102382, // מזהה הנושא שבו נמצא הסקר CACHE_KEY: 'met_cache_v2', CACHE_TTL_MS: 24 * 60 * 60 * 1000, // רענון פעם ביום MAX_TAGS: Infinity, // ללא הגבלה BUTTON_BOTTOM: '45vh', MENTION_PREFIX: 'מבקש עזרה מ: ', SOCKET_EVENT: 'plugins.poll.getOptionDetails', // ראה הערה בהסבר: לא אומת מול הפורום CONCURRENCY: 3, DEBUG: true, SHOW_TOOLBAR: false, // סרגל קבוע עם צפייה ורענון (מראה שהסקריפט רץ) TOOLBAR_TOP: '35vh', // מיקום הסרגל מלמעלה }; // התאמה ידנית: כותרת אפשרות בסקר (כפי שהיא כתובה) -> שם קטגוריה בפורום. // רלוונטי רק לאפשרויות שההתאמה האוטומטית לא תפסה (רואים אותן ב-__metDiagnose()). const OPTION_ALIASES = { // 'שם האפשרות בסקר': 'שם הקטגוריה בפורום', }; // משתמשים שביקשו לא להיות מתויגים (userslug) const OPT_OUT = [ // 'some-user-slug', ]; // תוספות ידניות: שם קטגוריה -> רשימת userslug const MANUAL_EXPERTS = { // 'אנדרואיד': ['some-user-slug'], }; /* ------------------------------------------------------------------ * * עזרים כלליים * ------------------------------------------------------------------ */ const log = (...a) => CONFIG.DEBUG && console.log('[expert-tagger]', ...a); const decodeHtml = (s) => { const t = document.createElement('textarea'); t.innerHTML = String(s == null ? '' : s); return t.value; }; // נרמול שמות להשוואה: בלי אימוג'י, סימני פיסוק, ניקוד ורווחים const norm = (s) => decodeHtml(s).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ''); const slugify = (s) => String(s).trim().replace(/\s+/g, '-'); const prettyName = (slug) => slug.replace(/-/g, ' '); function shuffle(arr) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } async function getJson(path) { const r = await fetch(path, { credentials: 'same-origin', headers: { Accept: 'application/json' } }); if (!r.ok) throw new Error(`${path} → ${r.status}`); return r.json(); } function emit(event, data, timeoutMs = 10000) { return new Promise((resolve, reject) => { const s = window.socket; if (!s) return reject(new Error('socket לא זמין (צריך להיות מחובר לפורום)')); const timer = setTimeout(() => reject(new Error('timeout')), timeoutMs); s.emit(event, data, (err, res) => { clearTimeout(timer); err ? reject(err) : resolve(res); }); }); } async function mapLimit(items, limit, fn) { const res = new Array(items.length); let i = 0; await Promise.all( Array.from({ length: Math.min(limit, items.length) }, async () => { while (i < items.length) { const idx = i++; res[idx] = await fn(items[idx], idx); } }) ); return res; } /* ------------------------------------------------------------------ * * שליפת קטגוריות וסקר * ------------------------------------------------------------------ */ async function loadCategories() { const data = await getJson('/api/categories'); const out = []; (function walk(list, parentCid) { (list || []).forEach((c) => { out.push({ cid: Number(c.cid), name: decodeHtml(c.name || ''), parentCid }); walk(c.children, Number(c.cid)); }); })(data.categories, 0); return out; } const ID_KEYS = ['id', 'optionId', 'optionid', 'oid']; const TITLE_KEYS = ['title', 'text', 'name', 'option', 'label']; const pick = (o, keys) => { for (const k of keys) if (o && o[k] != null && o[k] !== '') return o[k]; return undefined; }; // מחפש בתוך תשובת ה-API של הנושא אובייקט שנראה כמו סקר (מערך options עם id וכותרת) function findPoll(root) { const seen = new Set(); let found = null; (function walk(node) { if (found || !node || typeof node !== 'object' || seen.has(node)) return; seen.add(node); if (Array.isArray(node)) { node.forEach(walk); return; } if ( Array.isArray(node.options) && node.options.length > 1 && node.options.every((o) => o && typeof o === 'object' && pick(o, ID_KEYS) !== undefined && pick(o, TITLE_KEYS) !== undefined) ) { found = { pollId: pick(node, ['pollId', 'pollid', 'id']) ?? pick(node.info || {}, ['pollId', 'pollid', 'id']), options: node.options.map((o) => ({ id: pick(o, ID_KEYS), title: String(pick(o, TITLE_KEYS)) })), raw: node, }; return; } Object.values(node).forEach(walk); })(root); return found; } // תשובת getOptionDetails יכולה להגיע כמערך או כאובייקט עם users – מטפלים בשני המקרים function parseVoters(res) { const arr = Array.isArray(res) ? res : (res && (res.users || res.voters || res.details || res.votes)) || []; return arr .map((u) => { if (typeof u === 'string') return slugify(u); if (u && typeof u === 'object') return u.userslug || (u.username ? slugify(u.username) : null); return null; }) .filter(Boolean); } // התאמת כותרת אפשרות בסקר לקטגוריה: זהה > האפשרות מכילה את שם הקטגוריה > להפך function matchCategories(optionTitle, cats) { const o = norm(OPTION_ALIASES[optionTitle] || optionTitle); if (!o) return []; let best = [], bestKey = 0; for (const c of cats) { const n = norm(c.name); if (!n) continue; let s = 0; if (n === o) s = 3; else if (n.length >= 3 && o.includes(n)) s = 2; else if (o.length >= 3 && n.includes(o)) s = 1; if (!s) continue; const key = s * 1000 + (s < 3 ? n.length : 0); if (key > bestKey) { best = [c]; bestKey = key; } else if (key === bestKey) best.push(c); } return best; } /* ------------------------------------------------------------------ * * בניית הנתונים + מטמון יומי * ------------------------------------------------------------------ */ const state = { done: false, scheduled: false, lastError: null }; // ---- שליפת מבנה הסקר ומצביעים (מבוסס על מה שנמצא בדוח האבחון) ---- // אפשרויות הסקר כפי שהן מוצגות בדף (גיבוי; עובד רק כשדף הסקר פתוח) function domOptions(pollId) { const root = document.querySelector(`.poll-view[data-poll-id="${pollId}"]`); if (!root) return null; const seen = new Set(); const out = []; root.querySelectorAll('.poll-view-option[data-poll-option-id]').forEach((n) => { const id = n.dataset.pollOptionId; if (seen.has(id)) return; seen.add(id); out.push({ id, title: ((n.querySelector('label') || n).textContent || '').trim() }); }); return out; } // post.pollIds מגיע כמחרוזת '["617"]' (לא מערך) – מחלצים את המספרים בכל צורה function parsePollIds(v) { if (Array.isArray(v)) return v.map((x) => String(x).match(/\d+/)).filter(Boolean).map((m) => m[0]); return String(v == null ? '' : v).match(/\d+/g) || []; } async function fetchPollStructure(topic) { const post0 = (topic.posts || [])[0] || {}; let pollId = parsePollIds(post0.pollIds)[0]; if (!pollId) { const d = document.querySelector('.poll-view[data-poll-id]'); if (d) pollId = d.dataset.pollId; } if (!pollId) throw new Error('בפוסט הראשון של הנושא אין סקר'); let options = null; try { const res = await emit('plugins.poll.get', { pollIds: [Number(pollId)] }); const pd = Array.isArray(res) ? res.find(Boolean) : res; const list = pd && (pd.options || (pd.poll && pd.poll.options)); if (Array.isArray(list) && list.length > 1) { options = list.map((o) => ({ id: String(o.id), title: String(o.title != null ? o.title : (o.name || '')) })); } else { const f = findPoll(res); if (f) options = f.options; } } catch (e) { log('plugins.poll.get נכשל', e); } if (!options) options = domOptions(pollId); // גיבוי: רק כשדף הסקר פתוח if (!options || options.length < 2) { throw new Error('לא הצלחתי לקרוא את אפשרויות הסקר (פתח "מומחים" ← "דוח אבחון")'); } return { pollId, options }; } // כמה צורות אפשריות של הבקשה; משתמשים בראשונה שעובדת ושומרים אותה const DETAIL_VARIANTS = [ (p, o) => ({ pollId: p, optionId: o }), (p, o) => ({ pollId: Number(p), optionId: Number(o) }), (p, o) => ({ pollId: p, option: o }), ]; let detailVariant = 0; const uidSlugCache = new Map(); async function slugForUid(uid) { if (uidSlugCache.has(uid)) return uidSlugCache.get(uid); let slug = null; try { slug = (await getJson(`/api/user/uid/${uid}`)).userslug || null; } catch (e) { /* מדלגים */ } uidSlugCache.set(uid, slug); return slug; } // מחלץ משתמשים מכל מבנה תשובה: אובייקטים עם userslug/username, או מערכי uid שמתורגמים ל-slug async function extractUsers(res) { const slugs = new Set(); const uids = new Set(); const seen = new Set(); if (Array.isArray(res) && res.length && res.every((x) => /^\d+$/.test(String(x)))) { res.forEach((x) => uids.add(String(x))); } (function walk(n) { if (!n || typeof n !== 'object' || seen.has(n)) return; seen.add(n); if (!Array.isArray(n)) { if ('uid' in n && Number(n.uid) === 0) return; // מצביע אנונימי if (n.userslug) { slugs.add(n.userslug); return; } if (typeof n.username === 'string' && n.username && !/^\[\[/.test(n.username)) { slugs.add(slugify(n.username).toLowerCase()); return; } } for (const [k, v] of Object.entries(n)) { if (Array.isArray(v) && v.length && /uid|voter|user/i.test(k) && v.every((x) => /^\d+$/.test(String(x)))) { v.forEach((x) => uids.add(String(x))); } else if (v && typeof v === 'object') { walk(v); } } })(res); for (const uid of Array.from(uids).slice(0, 150)) { const sl = await slugForUid(uid); if (sl) slugs.add(sl); } return Array.from(slugs); } async function fetchVoters(pollId, optionId) { const order = [detailVariant, ...DETAIL_VARIANTS.map((_, i) => i).filter((i) => i !== detailVariant)]; let lastErr; for (const i of order) { try { const res = await emit(CONFIG.SOCKET_EVENT, DETAIL_VARIANTS[i](pollId, optionId)); detailVariant = i; return await extractUsers(res); } catch (e) { lastErr = e; if (!/invalid-data/.test(String((e && e.message) || e))) break; // רק שגיאת פורמט מצדיקה צורה אחרת } } throw lastErr; } async function refresh() { const [cats, topic] = await Promise.all([loadCategories(), getJson(`/api/topic/${CONFIG.POLL_TID}`)]); const poll = await fetchPollStructure(topic); const voters = await mapLimit(poll.options, CONFIG.CONCURRENCY, async (o) => { try { return await fetchVoters(poll.pollId, o.id); } catch (e) { log('נכשלה שליפת מצביעים עבור', o.title, e); return null; } }); if (voters.every((v) => v === null)) { throw new Error('לא הצלחתי לקרוא מצביעים (פתח "מומחים" ← "דוח אבחון")'); } if (voters.every((v) => !v || !v.length)) { throw new Error('רשימות המצביעים ריקות – ייתכן שהסקר אנונימי, או שעדיין אף אחד לא הצביע'); } const byCid = {}; const unmatched = []; const failed = []; const add = (cid, name, slugs) => { if (!byCid[cid]) byCid[cid] = { name, experts: [] }; slugs.forEach((s) => { if (!byCid[cid].experts.includes(s)) byCid[cid].experts.push(s); }); }; poll.options.forEach((o, i) => { if (voters[i] === null) { failed.push(o.title); return; } const matches = matchCategories(o.title, cats); if (!matches.length) { unmatched.push(o.title); return; } matches.forEach((c) => add(c.cid, c.name, voters[i])); }); Object.entries(MANUAL_EXPERTS).forEach(([catName, slugs]) => { cats.filter((c) => norm(c.name) === norm(catName)).forEach((c) => add(c.cid, c.name, slugs)); }); const optOut = new Set(OPT_OUT); Object.values(byCid).forEach((v) => { v.experts = v.experts.filter((s) => !optOut.has(s)); }); const data = { t: Date.now(), cats, byCid, unmatched, failed }; try { localStorage.setItem(CONFIG.CACHE_KEY, JSON.stringify(data)); } catch (e) { log('שמירה למטמון נכשלה', e); } log('עודכן', { קטגוריות: Object.keys(byCid).length, unmatched, failed }); return data; } const readCache = () => { try { return JSON.parse(localStorage.getItem(CONFIG.CACHE_KEY)); } catch (e) { return null; } }; let inflight = null; function getData({ force = false } = {}) { const cached = readCache(); if (!force && cached && Date.now() - cached.t < CONFIG.CACHE_TTL_MS) return Promise.resolve(cached); if (inflight) return inflight; inflight = refresh() .then((d) => { state.lastError = null; return d; }) .catch((e) => { state.lastError = e; console.warn('[expert-tagger] רענון נכשל:', e); if (cached) return cached; // ממשיכים עם הנתונים הישנים throw e; }) .finally(() => { inflight = null; }); return inflight; } const loggedIn = () => !!(window.app && window.app.user && window.app.user.uid > 0); function startBackgroundRefresh() { if (!loggedIn()) return; setTimeout(() => getData().catch(() => {}), 4000); setInterval(() => { if (loggedIn()) getData().catch(() => {}); }, 60 * 60 * 1000); // בודק כל שעה אם עבר יום } /* ------------------------------------------------------------------ * * חלון כתיבה (composer) וזיהוי הקטגוריה * ------------------------------------------------------------------ */ function getComposer() { const root = document.querySelector('[component="composer"]') || document.querySelector('.composer'); if (!root) return null; const title = root.querySelector('input.title, [component="composer/title"] input'); const body = root.querySelector('textarea.write'); if (!title || !body || !title.getClientRects().length) return null; // בתגובה שדה הכותרת מוסתר return { root, title, body }; } // מחזיר cid של הקטגוריה שנבחרה בחלון הכתיבה (או null) function detectComposerCid(composer, cats) { const sel = composer.root.querySelector('[component="category-selector"]'); if (sel) { const chosen = sel.querySelector('[component="category-selector-selected"]') || null; if (chosen) { const holder = chosen.matches('[data-cid]') ? chosen : chosen.querySelector('[data-cid]'); if (holder && holder.dataset.cid) return Number(holder.dataset.cid); } const text = norm((chosen || sel).textContent); if (text) { const exact = cats.filter((c) => norm(c.name) === text); if (exact.length === 1) return exact[0].cid; const partial = cats .filter((c) => norm(c.name) && text.includes(norm(c.name))) .sort((a, b) => norm(b.name).length - norm(a.name).length); if (partial.length) return partial[0].cid; } } // נפתח מתוך דף קטגוריה const pageCid = window.ajaxify && window.ajaxify.data && Number(window.ajaxify.data.cid); return pageCid || null; } // אם בקטגוריה אין מומחים – עולים לקטגוריית האב function effectiveCid(cid, data) { const parentOf = (c) => { const f = data.cats.find((x) => x.cid === c); return f ? f.parentCid : 0; }; let c = cid; while (c) { if (data.byCid[c] && data.byCid[c].experts.length) return c; c = parentOf(c); } return null; } function insertMentions(body, slugs) { const base = body.value.replace(/\s+$/, ''); const sep = base ? '\n\n' : ''; // שורה ריקה מעל body.value = base + sep + '||' + CONFIG.MENTION_PREFIX + slugs.map((s) => '@' + s).join(' ') + '||\n\n'; // ספויילר + שורה ריקה מתחת body.dispatchEvent(new Event('input', { bubbles: true })); body.focus(); body.setSelectionRange(body.value.length, body.value.length); } /* ------------------------------------------------------------------ * * ממשק * ------------------------------------------------------------------ */ const STYLE = ` #met-btn{position:fixed;left:12px;bottom:${CONFIG.BUTTON_BOTTOM};z-index:100000; background:#0d6efd;color:#fff;border:0;border-radius:24px;padding:10px 16px; font-weight:600;font-size:14px;line-height:1.2;font-family:inherit;cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.3);direction:rtl} #met-btn:hover{filter:brightness(1.1)} #met-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100001; display:flex;align-items:center;justify-content:center;direction:rtl} #met-modal{background:var(--bs-body-bg,#fff);color:var(--bs-body-color,#212529); border:1px solid var(--bs-border-color,#ccc);border-radius:12px;padding:18px; width:min(420px,92vw);max-height:80vh;overflow:auto;box-shadow:0 8px 30px rgba(0,0,0,.4)} #met-modal h5{margin:0 0 10px;font-size:17px} #met-modal select{width:100%;margin-bottom:10px;padding:6px} #met-modal label.met-row{display:flex;gap:8px;align-items:center;padding:4px 0;cursor:pointer} #met-modal .met-note{font-size:12px;opacity:.75;margin:8px 0} #met-modal .met-actions{display:flex;gap:8px;justify-content:flex-start;margin-top:12px} #met-modal button{padding:7px 14px;border-radius:8px;border:1px solid var(--bs-border-color,#ccc); cursor:pointer;background:transparent;color:inherit} #met-modal button.met-primary{background:#0d6efd;color:#fff;border-color:#0d6efd} #met-modal button.met-link{border:0;padding:0;text-decoration:underline;font-size:12px;opacity:.8} #met-modal button:disabled{opacity:.5;cursor:default} #met-toolbar{position:fixed;left:12px;top:${CONFIG.TOOLBAR_TOP};z-index:100000;display:flex; flex-direction:column;gap:6px;direction:rtl} #met-toolbar .met-tb{background:rgba(33,37,41,.88);color:#fff;border:0;border-radius:18px; padding:6px 12px;font-size:13px;line-height:1.2;font-family:inherit;cursor:pointer; box-shadow:0 1px 6px rgba(0,0,0,.35)} #met-toolbar .met-tb:hover{background:rgba(33,37,41,1)} #met-toolbar .met-tb:disabled{opacity:.6;cursor:default} #met-toast{position:fixed;left:12px;top:calc(${CONFIG.TOOLBAR_TOP} + 90px);z-index:100002;display:none; background:#212529;color:#fff;padding:8px 12px;border-radius:8px;font-size:13px; max-width:min(320px,90vw);direction:rtl;box-shadow:0 2px 10px rgba(0,0,0,.4)} #met-modal details{border-bottom:1px solid var(--bs-border-color,#ddd);padding:4px 0} #met-modal summary{cursor:pointer;font-weight:600} #met-modal .met-experts{display:flex;flex-wrap:wrap;gap:4px 12px;padding:6px 0 4px} #met-modal input[type=search]{width:100%;margin:8px 0;padding:6px} #met-modal .met-status{font-size:13px;line-height:1.6;margin-bottom:6px} `; function injectStyle() { if (document.getElementById('met-style')) return; const s = document.createElement('style'); s.id = 'met-style'; s.textContent = STYLE; document.head.appendChild(s); } function el(tag, props = {}, ...children) { const e = document.createElement(tag); Object.assign(e, props); children.forEach((c) => e.append(c)); return e; } function renderModal(modal, overlay, composer, data) { modal.textContent = ''; const close = () => overlay.remove(); const catName = (cid) => (data.byCid[cid] && data.byCid[cid].name) || (data.cats.find((c) => c.cid === cid) || {}).name || ''; const available = Object.keys(data.byCid).map(Number).filter((cid) => data.byCid[cid].experts.length); modal.append(el('h5', { textContent: 'לתייג את המומחים בתחום?' })); if (!available.length) { modal.append( el('div', { className: 'met-note', textContent: 'עדיין אין נתוני מומחים. בדוק שהסקר אינו אנונימי והרץ __metDiagnose() בקונסול.' }), el('div', { className: 'met-actions' }, el('button', { textContent: 'סגור', onclick: close })) ); return; } available.sort((a, b) => catName(a).localeCompare(catName(b), 'he')); const detected = detectComposerCid(composer, data.cats); let current = effectiveCid(detected, data) ?? available[0]; let info; if (!detected) info = 'לא זוהתה קטגוריה בהודעה – בחר תחום מהרשימה.'; else if (current === detected) info = `קטגוריית הנושא: ${catName(detected)}`; else if (effectiveCid(detected, data)) info = `בקטגוריה "${catName(detected)}" אין מומחים רשומים – מוצגים מומחי "${catName(current)}".`; else info = `בקטגוריה "${catName(detected)}" אין מומחים רשומים – בחר תחום קרוב מהרשימה.`; const select = el('select'); available.forEach((cid) => select.appendChild(el('option', { value: String(cid), textContent: catName(cid), selected: cid === current }))); const list = el('div'); const counter = el('div', { className: 'met-note' }); const addBtn = el('button', { className: 'met-primary', textContent: 'הוסף להודעה' }); let boxes = []; function updateCounter() { const n = boxes.filter((b) => b.checked).length; counter.textContent = `נבחרו ${n} מתוך ${boxes.length} מומחים.`; addBtn.disabled = n === 0; boxes.forEach((b) => { if (!b.checked) b.disabled = n >= CONFIG.MAX_TAGS; }); } function renderList() { list.textContent = ''; boxes = []; const mySlug = window.app && window.app.user && window.app.user.userslug; const pool = ((data.byCid[current] && data.byCid[current].experts) || []).filter((s) => s !== mySlug); const preselected = new Set(shuffle(pool).slice(0, CONFIG.MAX_TAGS)); pool.forEach((slug) => { const cb = el('input', { type: 'checkbox', checked: preselected.has(slug) }); cb.dataset.slug = slug; cb.addEventListener('change', updateCounter); boxes.push(cb); list.appendChild(el('label', { className: 'met-row' }, cb, prettyName(slug))); }); if (!pool.length) list.appendChild(el('div', { className: 'met-note', textContent: 'אין עוד מומחים בתחום הזה.' })); updateCounter(); } select.addEventListener('change', () => { current = Number(select.value); renderList(); }); addBtn.addEventListener('click', () => { const slugs = boxes.filter((b) => b.checked).map((b) => b.dataset.slug); if (slugs.length) { insertMentions(composer.body, slugs); state.done = true; removeButton(); } close(); }); const refreshBtn = el('button', { className: 'met-link', textContent: 'רענן עכשיו' }); refreshBtn.addEventListener('click', async () => { refreshBtn.disabled = true; refreshBtn.textContent = 'מרענן...'; try { renderModal(modal, overlay, composer, await getData({ force: true })); } catch (e) { refreshBtn.textContent = 'הרענון נכשל'; } }); const updated = new Date(data.t).toLocaleString('he-IL', { dateStyle: 'short', timeStyle: 'short' }); const footer = el('div', { className: 'met-note' }, `עודכן: ${updated} · `, refreshBtn); if (state.lastError) footer.append(el('div', { textContent: 'הרענון האחרון נכשל, מוצגים נתונים ישנים.' })); modal.append( el('div', { className: 'met-note', textContent: info }), select, list, counter, el('div', { className: 'met-actions' }, addBtn, el('button', { textContent: 'ביטול', onclick: close })), footer ); renderList(); } async function openModal(composer) { injectStyle(); const overlay = el('div', { id: 'met-overlay' }); const modal = el('div', { id: 'met-modal' }); overlay.appendChild(modal); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); modal.append( el('h5', { textContent: 'לתייג את המומחים בתחום?' }), el('div', { className: 'met-note', textContent: 'טוען נתונים...' }) ); document.body.appendChild(overlay); try { const data = await getData(); renderModal(modal, overlay, composer, data); } catch (e) { modal.textContent = ''; modal.append( el('h5', { textContent: 'לתייג את המומחים בתחום?' }), el('div', { className: 'met-note', textContent: `לא הצלחתי לטעון את נתוני הסקר: ${e && e.message ? e.message : e}` }), el('div', { className: 'met-actions' }, el('button', { textContent: 'סגור', onclick: () => overlay.remove() })) ); } } /* ------------------------------------------------------------------ * * סרגל כלים קבוע: צפייה ברשימה וריענון * ------------------------------------------------------------------ */ const VERSION = '0.4.5'; function stats(data) { const groups = Object.values((data && data.byCid) || {}).filter((v) => v.experts.length); const unique = new Set(); groups.forEach((g) => g.experts.forEach((s) => unique.add(s))); return { categories: groups.length, experts: unique.size }; } function toast(text) { let t = document.getElementById('met-toast'); if (!t) { t = el('div', { id: 'met-toast' }); document.body.appendChild(t); } t.textContent = text; t.style.display = 'block'; clearTimeout(toast._timer); toast._timer = setTimeout(() => { t.style.display = 'none'; }, 5000); } // רענון יזום + הודעה למשתמש. onBusy(true/false) מעדכן את מצב הכפתור שהפעיל אותו async function doRefresh(onBusy) { if (onBusy) onBusy(true); try { const data = await getData({ force: true }); if (state.lastError) { toast(`הרענון נכשל: ${(state.lastError && state.lastError.message) || state.lastError} (מוצגים נתונים ישנים)`); } else { const s = stats(data); toast(`עודכן ✓ ${s.categories} קטגוריות · ${s.experts} מומחים`); } return data; } catch (e) { toast(`הרענון נכשל: ${(e && e.message) || e}`); return null; } finally { if (onBusy) onBusy(false); } } /* ---- אבחון: האזנה לקריאות socket של תוספים + דוח מלא ---- */ const sniffed = []; const clip = (v, n = 300) => { let t; try { t = typeof v === 'string' ? v : JSON.stringify(v); } catch (e) { t = String(v); } return t && t.length > n ? t.slice(0, n) + '…' : t; }; function installSniffer() { const sock = window.socket; if (!sock || sock.__metPatched || typeof sock.emit !== 'function') return !!(sock && sock.__metPatched); const orig = sock.emit; sock.emit = function (event, ...args) { try { if (typeof event === 'string' && event.startsWith('plugins.') && sniffed.length < 40) { const rec = { event, data: clip(args[0]), at: new Date().toISOString() }; sniffed.push(rec); const cb = args[args.length - 1]; if (typeof cb === 'function') { args[args.length - 1] = function (err, res) { rec.err = clip(err); rec.res = clip(res, 1500); return cb.apply(this, arguments); }; } } } catch (e) { /* לא מפריעים לפורום */ } return orig.apply(this, [event, ...args]); }; sock.__metPatched = true; return true; } function startSniffer() { let tries = 0; const t = setInterval(() => { tries++; if (installSniffer() || tries > 40) clearInterval(t); }, 500); } function collectPollPaths(root, label) { const out = []; const seen = new Set(); (function walk(n, path) { if (out.length > 25 || !n || typeof n !== 'object' || seen.has(n)) return; seen.add(n); for (const [k, v] of Object.entries(n)) { const p = path + '.' + k; if (/poll/i.test(k)) out.push({ path: p, preview: clip(v, 200) }); if (v && typeof v === 'object') walk(v, p); } })(root, label); return out; } async function buildReport() { const r = { version: VERSION, url: location.href, loggedIn: loggedIn(), time: new Date().toISOString(), onPollTopicPage: location.pathname.includes('/topic/' + CONFIG.POLL_TID), }; let pollIdGuess = 0, optionIdGuess = 0; try { const topic = await getJson(`/api/topic/${CONFIG.POLL_TID}`); r.topicKeys = Object.keys(topic); const p0 = (topic.posts || [])[0]; r.firstPostKeys = p0 ? Object.keys(p0) : null; r.pollLikePathsInTopicApi = collectPollPaths(topic, 'topic'); if (p0 && typeof p0.content === 'string') { const i = p0.content.search(/poll/i); r.firstPostContentLength = p0.content.length; r.firstPostContentAroundPoll = i >= 0 ? p0.content.slice(Math.max(0, i - 200), i + 700) : null; } const f = findPoll(topic); r.findPollResult = f ? { pollId: f.pollId, optionCount: f.options.length, sample: f.raw.options[0] } : null; if (f) { pollIdGuess = f.pollId; optionIdGuess = f.options[0].id; } const pid = p0 && parsePollIds(p0.pollIds)[0]; r.pollIdFromPost = pid || null; if (pid) { pollIdGuess = pid; try { const raw = await emit('plugins.poll.get', { pollIds: [Number(pid)] }, 8000); r.pollGetResponse = clip(raw, 3000); const f2 = findPoll(raw); r.pollGetParsed = f2 ? { optionCount: f2.options.length, firstOptions: f2.options.slice(0, 3) } : null; if (f2) optionIdGuess = f2.options[0].id; } catch (e) { r.pollGetError = clip((e && e.message) || e, 300); } if (!optionIdGuess) { const d = domOptions(pid); if (d && d[0]) { optionIdGuess = d[0].id; r.optionsFromDom = d.length; } } } } catch (e) { r.topicApiError = String((e && e.message) || e); } const nodes = Array.from(document.querySelectorAll('[class*="poll" i],[id*="poll" i],[data-poll-id],[data-pollid]')).slice(0, 15); r.dom = { count: nodes.length, nodes: nodes.map((n) => ({ tag: n.tagName, id: n.id, cls: String(n.className).slice(0, 120), data: Object.assign({}, n.dataset), text: clip(n.textContent.trim().replace(/\s+/g, ' '), 120), })), firstHtml: nodes[0] ? clip(nodes[0].outerHTML, 1500) : null, }; const ad = window.ajaxify && window.ajaxify.data; r.ajaxifyKeys = ad ? Object.keys(ad) : null; r.pollLikePathsInAjaxify = ad ? collectPollPaths(ad, 'ajaxify.data') : null; r.detailsVariants = {}; for (let i = 0; i < DETAIL_VARIANTS.length; i++) { try { r.detailsVariants['v' + i] = { ok: clip(await emit(CONFIG.SOCKET_EVENT, DETAIL_VARIANTS[i](pollIdGuess, optionIdGuess), 8000), 1500) }; } catch (e) { r.detailsVariants['v' + i] = { error: clip((e && e.message) || e, 300) }; } } r.sniffedSocketCalls = sniffed.slice(); return r; } function openViewer() { injectStyle(); const old = document.getElementById('met-overlay'); if (old) old.remove(); const overlay = el('div', { id: 'met-overlay' }); const modal = el('div', { id: 'met-modal' }); overlay.appendChild(modal); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); document.body.appendChild(overlay); let filter = ''; function render() { modal.textContent = ''; const data = readCache(); const user = window.app && window.app.user; modal.append(el('h5', { textContent: `✓ הסקריפט פעיל (גרסה ${VERSION})` })); const status = el('div', { className: 'met-status' }); const addLine = (txt) => { status.append(el('div', { textContent: txt })); }; addLine(loggedIn() ? `מחובר כ: ${user.username || user.userslug}` : 'לא מחובר לפורום – אי אפשר לרענן.'); if (data) { const s = stats(data); addLine(`עודכן לאחרונה: ${new Date(data.t).toLocaleString('he-IL', { dateStyle: 'short', timeStyle: 'short' })}`); addLine(`${s.categories} קטגוריות עם מומחים · ${s.experts} מומחים ייחודיים`); } else { addLine('אין עדיין נתונים שמורים. לחץ "רענן עכשיו".'); } if (inflight) addLine('מתבצע רענון ברקע...'); if (state.lastError) addLine(`הרענון האחרון נכשל: ${(state.lastError && state.lastError.message) || state.lastError}`); modal.append(status); const refreshBtn = el('button', { className: 'met-primary', textContent: 'רענן עכשיו' }); refreshBtn.addEventListener('click', async () => { await doRefresh((busy) => { refreshBtn.disabled = busy; refreshBtn.textContent = busy ? 'מרענן...' : 'רענן עכשיו'; }); if (document.body.contains(overlay)) render(); }); const reportBtn = el('button', { textContent: '📋 דוח אבחון' }); const reportBox = el('textarea', { readOnly: true, rows: 8 }); reportBox.style.cssText = 'display:none;width:100%;direction:ltr;font:11px monospace;margin-top:8px'; reportBtn.addEventListener('click', async () => { reportBtn.disabled = true; reportBtn.textContent = 'מכין דוח...'; const text = JSON.stringify(await buildReport(), null, 2); reportBox.style.display = 'block'; reportBox.value = text; reportBox.select(); let copied = false; try { await navigator.clipboard.writeText(text); copied = true; } catch (e) { /* אפשר להעתיק ידנית מהתיבה */ } reportBtn.disabled = false; reportBtn.textContent = copied ? '📋 הועתק ✓' : '📋 דוח אבחון'; }); modal.append( el('div', { className: 'met-actions' }, refreshBtn, reportBtn, el('button', { textContent: 'סגור', onclick: () => overlay.remove() })), reportBox ); if (!data) return; const search = el('input', { type: 'search', placeholder: 'חיפוש מומחה לפי שם...', value: filter }); const listBox = el('div'); function renderList() { listBox.textContent = ''; const q = norm(filter); Object.keys(data.byCid).map(Number) .filter((cid) => data.byCid[cid].experts.length) .sort((a, b) => data.byCid[a].name.localeCompare(data.byCid[b].name, 'he')) .forEach((cid) => { const g = data.byCid[cid]; const experts = q ? g.experts.filter((sl) => norm(prettyName(sl)).includes(q)) : g.experts; if (!experts.length) return; const d = el('details'); if (q) d.open = true; d.append(el('summary', { textContent: `${g.name} (${g.experts.length})` })); const wrap = el('div', { className: 'met-experts' }); experts.forEach((sl) => wrap.append(el('a', { href: `/user/${sl}`, target: '_blank', rel: 'noopener', textContent: prettyName(sl) }))); d.append(wrap); listBox.append(d); }); if (!listBox.childNodes.length) { listBox.append(el('div', { className: 'met-note', textContent: q ? 'לא נמצאו תוצאות.' : 'אין קטגוריות עם מומחים.' })); } } search.addEventListener('input', () => { filter = search.value; renderList(); }); modal.append(search, listBox); renderList(); const unmatched = data.unmatched || []; const failed = data.failed || []; if (unmatched.length || failed.length) { const d = el('details'); d.append(el('summary', { textContent: `בעיות בהתאמה (${unmatched.length + failed.length})` })); if (unmatched.length) d.append(el('div', { className: 'met-note', textContent: `אפשרויות בסקר שלא הותאמו לקטגוריה: ${unmatched.join(' · ')}` })); if (failed.length) d.append(el('div', { className: 'met-note', textContent: `אפשרויות שלא הצלחתי לשלוף את המצביעים שלהן: ${failed.join(' · ')}` })); modal.append(d); } } render(); } function ensureToolbar() { if (!CONFIG.SHOW_TOOLBAR || document.getElementById('met-toolbar') || !document.body) return; injectStyle(); const bar = el('div', { id: 'met-toolbar' }); const viewBtn = el('button', { type: 'button', className: 'met-tb', textContent: '👥 מומחים', title: 'צפייה ברשימת המומחים ובסטטוס הסקריפט' }); const refBtn = el('button', { type: 'button', className: 'met-tb', textContent: '🔄 רענון', title: 'רענון רשימת המומחים מהסקר' }); viewBtn.addEventListener('click', openViewer); refBtn.addEventListener('click', () => doRefresh((busy) => { refBtn.disabled = busy; refBtn.textContent = busy ? '⏳ מרענן...' : '🔄 רענון'; })); bar.append(viewBtn, refBtn); document.body.appendChild(bar); } /* ------------------------------------------------------------------ * * הכפתור הצף * ------------------------------------------------------------------ */ function removeButton() { const b = document.getElementById('met-btn'); if (b) b.remove(); } function sync() { state.scheduled = false; ensureToolbar(); const composer = getComposer(); if (!composer) { state.done = false; // החלון נסגר – מאפסים למופע הבא removeButton(); return; } if (state.done || document.getElementById('met-btn')) return; injectStyle(); const btn = el('button', { id: 'met-btn', type: 'button', textContent: 'לתייג את המומחים בתחום?' }); btn.addEventListener('click', () => openModal(getComposer() || composer)); document.body.appendChild(btn); } const schedule = () => { if (state.scheduled) return; state.scheduled = true; requestAnimationFrame(sync); }; /* ------------------------------------------------------------------ * * כלי אבחון (מהקונסול) * ------------------------------------------------------------------ */ window.__metRefresh = () => getData({ force: true }); window.__metDiagnose = async () => { const out = {}; try { const topic = await getJson(`/api/topic/${CONFIG.POLL_TID}`); const poll = findPoll(topic); out.pollFound = !!poll; if (poll) { out.pollId = poll.pollId; out.optionCount = poll.options.length; out.pollObjectKeys = Object.keys(poll.raw); out.rawOptionSample = poll.raw.options[0]; try { out.sampleDetails = await emit(CONFIG.SOCKET_EVENT, { pollId: poll.pollId, optionId: poll.options[0].id }); } catch (e) { out.sampleDetailsError = String((e && e.message) || e); } } else { out.topicKeys = Object.keys(topic); out.firstPostKeys = topic.posts && topic.posts[0] ? Object.keys(topic.posts[0]) : null; } } catch (e) { out.error = String((e && e.message) || e); } const c = readCache(); out.cache = c && { updated: new Date(c.t).toISOString(), categoriesWithExperts: Object.keys(c.byCid).length, unmatchedOptions: c.unmatched, failedOptions: c.failed, }; console.log('[expert-tagger] diagnose', out); console.log(JSON.stringify(out, null, 2)); return out; }; /* ------------------------------------------------------------------ * * הפעלה * ------------------------------------------------------------------ */ new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true }); schedule(); startSniffer(); startBackgroundRefresh(); log(`v${VERSION} פעיל`); })();