您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Avatars + relative timestamps on notifications. Changes: swap flag → photo, add flag emoji, user avatar, relative time, proper hover previews, and show latest comments in comment notifications.
// ==UserScript== // @name PlatesMania Avatars + Relative Time // @namespace pm-like-avatars // @version 1.4 // @description Avatars + relative timestamps on notifications. Changes: swap flag → photo, add flag emoji, user avatar, relative time, proper hover previews, and show latest comments in comment notifications. // @match https://*.platesmania.com/user* // @match http://*.platesmania.com/user* // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @license MIT // ==/UserScript== (function () { "use strict"; // ---------- Only run on your own profile ---------- function getOwnUserPath() { const loginBarUser = document.querySelector('.loginbar.pull-right > li > a[href^="/user"]'); if (loginBarUser) return new URL(loginBarUser.getAttribute("href"), location.origin).pathname; const langLink = document.querySelector('.languages a[href*="/user"]'); if (langLink) return new URL(langLink.getAttribute("href")).pathname; return null; } const ownPath = getOwnUserPath(); const herePath = location.pathname.replace(/\/+$/, ""); if (!ownPath || herePath !== ownPath.replace(/\/+$/, "")) return; // ---------- Config ---------- const CACHE_KEY = "pm_profile_pic_cache_v1"; const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days const NOMER_CACHE_KEY = "pm_nomer_photo_cache_v1"; const NOMER_MAX_AGE_MS = 60 * 24 * 60 * 60 * 1000; // 60 days const LIKE_ITEM_SELECTOR = ".col-xs-12.margin-bottom-5.bg-info"; const CONTAINER_SELECTOR = "#mCSB_2_container, #content"; const PROCESSED_FLAG = "pmAvatarAdded"; GM_addStyle(` .pm-like-avatar { width: 20px !important; height: 20px !important; border-radius: 4px !important; object-fit: cover !important; vertical-align: text-bottom !important; margin-right: 6px !important; overflow: hidden !important; display: inline-block !important; } .pm-avatar-preview { position: fixed !important; width: 200px !important; height: 200px !important; max-width: 200px !important; max-height: 200px !important; border-radius: 10px !important; box-shadow: 0 8px 28px rgba(0,0,0,0.28) !important; background: #fff !important; z-index: 2147483647 !important; pointer-events: none !important; display: none; object-fit: cover; /* default for avatars */ } .pm-nomer-thumb { width: 40px !important; height: 40px !important; object-fit: cover !important; border-radius: 6px !important; } /* COMMENT PREVIEWS (only in notifications) */ .pm-comment-preview { /* almost-black, tuned for bright green backgrounds */ color: #333 !important; display: block; width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.3; } .pm-comment-preview b { font-weight: 700; } .pm-comment-preview img { height: 20px !important; width: auto !important; vertical-align: middle; display: inline-block !important; float: none !important; margin-left: 4px; margin-right: 4px; } .pm-comment-preview br { display: none; /* keep it to one line */ } `); // ---------- Cache helpers ---------- function readCache(key = CACHE_KEY) { try { const raw = typeof GM_getValue === "function" ? GM_getValue(key, "{}") : localStorage.getItem(key) || "{}"; return JSON.parse(raw); } catch { return {}; } } function writeCache(obj, key = CACHE_KEY) { const raw = JSON.stringify(obj); if (typeof GM_setValue === "function") GM_setValue(key, raw); else localStorage.setItem(key, raw); } function getFromCache(id, key = CACHE_KEY, maxAge = MAX_AGE_MS) { const c = readCache(key); const e = c[id]; if (!e) return null; if (Date.now() - e.ts > maxAge) { delete c[id]; writeCache(c, key); return null; } return e.url; } function putInCache(id, url, key = CACHE_KEY) { const c = readCache(key); c[id] = { url, ts: Date.now() }; writeCache(c, key); } (function prune() { const c1 = readCache(CACHE_KEY), c2 = readCache(NOMER_CACHE_KEY); let changed1 = false, changed2 = false; for (const [k, v] of Object.entries(c1)) if (!v || !v.ts || (Date.now() - v.ts > MAX_AGE_MS)) { delete c1[k]; changed1 = true; } for (const [k, v] of Object.entries(c2)) if (!v || !v.ts || (Date.now() - v.ts > NOMER_MAX_AGE_MS)) { delete c2[k]; changed2 = true; } if (changed1) writeCache(c1, CACHE_KEY); if (changed2) writeCache(c2, NOMER_CACHE_KEY); })(); // ---------- PREVIEW helpers (one shared floating <img>) ---------- const PM_PREVIEW_SIZE = 200; const PM_PREVIEW_PAD = 12; const pmPreviewEl = document.createElement("img"); pmPreviewEl.className = "pm-avatar-preview"; document.addEventListener("DOMContentLoaded", () => { if (!pmPreviewEl.isConnected) document.body.appendChild(pmPreviewEl); }); if (!pmPreviewEl.isConnected) document.body.appendChild(pmPreviewEl); function pmMovePreview(e) { const vw = window.innerWidth; let x = e.clientX - PM_PREVIEW_SIZE / 2; let y = e.clientY - PM_PREVIEW_SIZE - PM_PREVIEW_PAD; if (x < 4) x = 4; if (x + PM_PREVIEW_SIZE > vw - 4) x = vw - PM_PREVIEW_SIZE - 4; if (y < 4) y = e.clientY + PM_PREVIEW_PAD; pmPreviewEl.style.left = `${x}px`; pmPreviewEl.style.top = `${y}px`; } function pmShowPreview(src, e, mode = "cover") { pmPreviewEl.style.objectFit = (mode === "contain") ? "contain" : "cover"; pmPreviewEl.src = src || ""; pmPreviewEl.style.display = "block"; pmMovePreview(e); } function pmHidePreview() { pmPreviewEl.style.display = "none"; pmPreviewEl.removeAttribute("src"); } function attachPreview(el, src, mode = "cover") { el.addEventListener("mouseenter", (e) => pmShowPreview(src, e, mode)); el.addEventListener("mousemove", pmMovePreview); el.addEventListener("mouseleave", pmHidePreview); } function attachSelfPreview(el, mode = "cover") { el.addEventListener("mouseenter", (e) => pmShowPreview(el.src, e, mode)); el.addEventListener("mousemove", pmMovePreview); el.addEventListener("mouseleave", pmHidePreview); } function attachPreviewLazy(el, getSrc, mode = "cover") { el.addEventListener("mouseenter", (e) => { const src = getSrc(); if (src) pmShowPreview(src, e, mode); }); el.addEventListener("mousemove", pmMovePreview); el.addEventListener("mouseleave", pmHidePreview); } // ---------- Small utils ---------- function absUrl(href) { try { return new URL(href, location.origin).toString(); } catch { return href; } } function escapeHtml(s = "") { return s.replace(/[&<>"]/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[ch])); } // ---------- Avatars ---------- async function fetchProfileAvatar(userPath) { const res = await fetch(absUrl(userPath), { credentials: "same-origin" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const html = await res.text(); const doc = new DOMParser().parseFromString(html, "text/html"); const img = doc.querySelector(".profile-img[src]"); return img ? absUrl(img.getAttribute("src")) : null; } function addAvatarToRow(rowEl, avatarUrl) { if (!rowEl || rowEl.dataset[PROCESSED_FLAG] === "1") return; const strong = rowEl.querySelector("strong"); const userLink = strong && strong.querySelector('a[href^="/user"]'); if (!userLink) return; if (strong.querySelector("img.pm-like-avatar")) { rowEl.dataset[PROCESSED_FLAG] = "1"; return; } const img = document.createElement("img"); img.className = "pm-like-avatar"; img.width = 20; img.height = 20; img.alt = ""; img.src = avatarUrl; attachPreview(img, avatarUrl, "cover"); strong.insertBefore(img, userLink); // like-list layout rowEl.dataset[PROCESSED_FLAG] = "1"; } const queue = []; let active = 0, CONCURRENCY = 3; function enqueue(task) { queue.push(task); runQueue(); } function runQueue() { while (active < CONCURRENCY && queue.length) { active++; const fn = queue.shift(); Promise.resolve().then(fn).finally(() => { active--; runQueue(); }); } } function processRow(rowEl) { if (!rowEl || rowEl.dataset[PROCESSED_FLAG] === "1" || rowEl.dataset.pmAvatarPending === "1") return; const userLink = rowEl.querySelector('strong > a[href^="/user"]'); if (!userLink) return; const userHref = userLink.getAttribute("href"); const userId = (userHref.match(/\/user(\d+)\b/) || [])[1]; if (!userId) return; const cached = getFromCache(userId); if (cached) { addAvatarToRow(rowEl, cached); return; } rowEl.dataset.pmAvatarPending = "1"; enqueue(async () => { try { const url = await fetchProfileAvatar(userHref); if (url) { putInCache(userId, url); addAvatarToRow(rowEl, url); } else { rowEl.dataset[PROCESSED_FLAG] = "1"; } } catch { /* allow retry */ } finally { delete rowEl.dataset.pmAvatarPending; } }); } function processAllAvatars(root = document) { root.querySelectorAll(LIKE_ITEM_SELECTOR).forEach(processRow); } // ---------- Relative "x ago" (Moscow → local) ---------- function parseMoscowTime(str) { const m = str.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})$/); if (!m) return null; const [, Y, Mo, D, H, Mi, S] = m.map(Number); const ms = Date.UTC(Y, Mo - 1, D, H - 3, Mi, S); return new Date(ms); } function rel(date) { if (!date) return ""; const s = Math.max(0, (Date.now() - date.getTime()) / 1000); if (s < 60) return `${Math.floor(s)}s ago`; const m = s / 60; if (m < 60) return `${Math.floor(m)}m ago`; const h = m / 60; if (h < 24) return `${Math.floor(h)}h ago`; const d = h / 24; if (d < 30) return `${Math.floor(d)}d ago`; return date.toLocaleDateString(); } function processAllTimes(root = document) { root.querySelectorAll(`${LIKE_ITEM_SELECTOR} small`).forEach(el => { if (el.dataset.pmTimeDone === "1") return; const t = el.textContent.trim(); const dt = parseMoscowTime(t); if (dt) { el.textContent = rel(dt); el.dataset.pmTimeDone = "1"; } }); } // ---------- Private messages panel ---------- function ccToFlag(cc) { if (!cc || cc.length !== 2) return ""; const A = 0x1F1E6, a = cc.toUpperCase(); return String.fromCodePoint(A + (a.charCodeAt(0) - 65), A + (a.charCodeAt(1) - 65)); } // Fetch small photo url (/s/) by scraping the nomer page (regex is fine) async function fetchNomerSmallPhoto(nomerHref) { const res = await fetch(absUrl(nomerHref), { credentials: "same-origin" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const html = await res.text(); const m = html.match(/https?:\/\/img\d+\.platesmania\.com\/[^"'<>]+\/m\/\d+\.jpg/); if (!m) return null; return m[0].replace(/\/m\//, "/s/"); } // NEW: Fetch latest N comments from the nomer page async function fetchNomerComments(nomerHref, count = 1) { const res = await fetch(absUrl(nomerHref), { credentials: "same-origin" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const html = await res.text(); const doc = new DOMParser().parseFromString(html, "text/html"); // Each comment block const bodies = Array.from(doc.querySelectorAll('#ok .media.media-v2 .media-body')); const items = bodies.map(b => { const userEl = b.querySelector('.media-heading strong a, .media-heading strong a span'); // prefer span text if present let user = ""; if (userEl) user = (userEl.textContent || "").trim(); // content div like <div id="z3672309"> ... </div> const contentEl = b.querySelector('div[id^="z"]'); const html = contentEl ? contentEl.innerHTML.trim() : ""; return { user, html }; }).filter(x => x.user && x.html); // take the last N (latest) return items.slice(-count); } // ensure "by [pic] username": insert right after the "by " text node, else before link function insertAvatarBy(img, iEl, userLink) { const nodes = Array.from(iEl.childNodes); const byNode = nodes.find(n => n.nodeType === 3 && /\bby\s*$/i.test(n.textContent)); if (byNode && byNode.nextSibling === userLink) { iEl.insertBefore(img, userLink); } else if (byNode) { if (byNode.nextSibling) iEl.insertBefore(img, byNode.nextSibling); else iEl.appendChild(img); } else { iEl.insertBefore(img, userLink); } } function processPmAlert(alertEl) { if (!alertEl || alertEl.dataset.pmPanelDone === "1" || alertEl.dataset.pmPanelPending === "1") return; const strong = alertEl.querySelector(".overflow-h > strong"); const titleLink = strong && strong.querySelector('a[href*="/nomer"]'); if (!strong || !titleLink) { alertEl.dataset.pmPanelDone = "1"; return; } const path = new URL(titleLink.getAttribute("href"), location.origin).pathname; const pathParts = path.split("/").filter(Boolean); const cc = pathParts[0] || ""; const nomerId = (path.match(/nomer(\d+)/) || [])[1]; // 2) Prefix title with emoji flag const flag = ccToFlag(cc); if (flag && titleLink && !titleLink.dataset.pmFlagged) { titleLink.textContent = `${flag} ${titleLink.textContent.trim()}`; titleLink.dataset.pmFlagged = "1"; } // 1) Swap flag icon → small photo + hover (contain) const flagImg = alertEl.querySelector("img.rounded-x, .alert img"); if (flagImg && nomerId) { const cached = getFromCache(nomerId, NOMER_CACHE_KEY, NOMER_MAX_AGE_MS); if (cached) { flagImg.src = cached; flagImg.classList.add("pm-nomer-thumb"); attachPreviewLazy(flagImg, () => (flagImg.src ? flagImg.src.replace(/\/s\//, "/m/") : ""), "contain"); } else { alertEl.dataset.pmPanelPending = "1"; enqueue(async () => { try { const photoUrl = await fetchNomerSmallPhoto(titleLink.getAttribute("href")); if (photoUrl) { putInCache(nomerId, photoUrl, NOMER_CACHE_KEY); flagImg.src = photoUrl; flagImg.classList.add("pm-nomer-thumb"); attachPreviewLazy(flagImg, () => photoUrl.replace(/\/s\//, "/m/"), "contain"); } } catch { /* ignore */ } finally { delete alertEl.dataset.pmPanelPending; } }); } } // 3) Avatar next to username (after "by ") + hover (cover) ; 4) relative time const iEl = alertEl.querySelector("i"); if (iEl) { // avatar for user placed as "by [pic] username" const userLink = iEl.querySelector('a[href^="/user"]'); if (userLink && !iEl.querySelector("img.pm-like-avatar")) { const userHref = userLink.getAttribute("href"); const userId = (userHref.match(/\/user(\d+)\b/) || [])[1]; const addAvatar = (url) => { const img = document.createElement("img"); img.className = "pm-like-avatar"; img.width = 20; img.height = 20; img.alt = ""; img.src = url; attachPreview(img, url, "cover"); insertAvatarBy(img, iEl, userLink); }; const cached = userId && getFromCache(userId); if (cached) addAvatar(cached); else if (userId) { enqueue(async () => { try { const url = await fetchProfileAvatar(userHref); if (url) { putInCache(userId, url); addAvatar(url); } } catch { /* ignore */ } }); } } // relative time: tooltip HH:mm:ss + date in parentheses if present in text if (!iEl.dataset.pmRelTimeDone) { const time = iEl.getAttribute("data-original-title") || ""; const mDate = iEl.textContent && iEl.textContent.match(/\((\d{4}-\d{2}-\d{2})\)/); const dateStr = mDate ? mDate[1] : ""; const dt = (time && dateStr) ? parseMoscowTime(`${dateStr} ${time}`) : null; if (dt) { iEl.innerHTML = iEl.innerHTML.replace(/\(\d{4}-\d{2}-\d{2}\)/, (`(${rel(dt)})`)); iEl.dataset.pmRelTimeDone = "1"; } } } // 5) Replace "New comments to your photos" with latest N comment previews const pDesc = alertEl.querySelector(".overflow-h > p"); const plusEm = strong && strong.querySelector("small.pull-right em"); let count = 1; if (plusEm) { const m = plusEm.textContent && plusEm.textContent.match(/\+(\d+)/); if (m) count = Math.max(1, parseInt(m[1], 10)); } if (pDesc && titleLink && !pDesc.dataset.pmCommentsLoaded && !pDesc.dataset.pmCommentsPending) { pDesc.dataset.pmCommentsPending = "1"; enqueue(async () => { try { const comments = await fetchNomerComments(titleLink.getAttribute("href"), count); if (comments && comments.length) { // Build HTML lines: <div class="pm-comment-preview"><b>User:</b> commentHTML</div> const html = comments .map(c => `<div class="pm-comment-preview"><b>${escapeHtml(c.user)}:</b> ${c.html}</div>`) .join(""); pDesc.innerHTML = html; } } catch { /* ignore; leave default text */ } finally { delete pDesc.dataset.pmCommentsPending; pDesc.dataset.pmCommentsLoaded = "1"; } }); } alertEl.dataset.pmPanelDone = "1"; } function processPmPanel(root = document) { const alerts = root.querySelectorAll('.panel .alert.alert-blocks, #scrollbar3 .alert.alert-blocks'); alerts.forEach(processPmAlert); } // ---------- Observe ---------- function observe() { const container = document.querySelector(CONTAINER_SELECTOR) || document.body; const mo = new MutationObserver(muts => { muts.forEach(m => m.addedNodes.forEach(n => { if (!(n instanceof HTMLElement)) return; processAllAvatars(n); processAllTimes(n); processPmPanel(n); })); }); mo.observe(container, { childList: true, subtree: true }); const pmPanel = document.querySelector('#scrollbar3') || document.querySelector('.panel .panel-title i.fa-send')?.closest('.panel'); if (pmPanel) { const mo2 = new MutationObserver(muts => { muts.forEach(m => m.addedNodes.forEach(n => { if (n instanceof HTMLElement) processPmPanel(n); })); }); mo2.observe(pmPanel, { childList: true, subtree: true }); } } // ---------- Kickoff ---------- processAllAvatars(document); processAllTimes(document); processPmPanel(document); observe(); setInterval(() => { processAllAvatars(document); processAllTimes(document); processPmPanel(document); }, 1500); })();