PlatesMania Notifications Enhancer

Avatars + relative timestamps on your own /userXXXX page. PM panel: swap flag → photo, add flag emoji, user avatar, relative time, proper hover previews, and show latest comments in comment notifications. Likes list: show plate PNG; tooltip preview gets car logo+model (or text) under the image.

目前為 2025-08-31 提交的版本,檢視 最新版本

// ==UserScript==
// @name         PlatesMania Notifications Enhancer
// @namespace    pm-like-avatars
// @version      1.5
// @description  Avatars + relative timestamps on your own /userXXXX page. PM panel: swap flag → photo, add flag emoji, user avatar, relative time, proper hover previews, and show latest comments in comment notifications. Likes list: show plate PNG; tooltip preview gets car logo+model (or text) under the image.
// @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;

    const NOMER_CACHE_KEY = "pm_nomer_photo_cache_v1";
    const NOMER_MAX_AGE_MS = 60 * 24 * 60 * 60 * 1000;

    const PLATE_CACHE_KEY = "pm_plate_png_cache_v1";
    const PLATE_MAX_AGE_MS = 180 * 24 * 60 * 60 * 1000;

    // store plate meta (png + make + model + logoUrl if exists)
    const PLATE_META_CACHE_KEY = "pm_plate_meta_cache_v1";

    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}
    .pm-nomer-thumb{width:40px!important;height:40px!important;object-fit:cover!important;border-radius:6px!important}
    .pm-plate{height:18px!important;width:auto!important;vertical-align:text-bottom!important;display:inline-block!important}
    /* keep the old size for in-row logos if used elsewhere */
    .pm-car-logo{height:18px!important;width:auto!important;vertical-align:text-bottom!important;margin-left:6px!important}
    .pm-car-model{margin-left:6px!important;vertical-align:text-bottom!important;display:inline-block!important}

    /* NEW: tooltip meta row under preview image */
    .pm-tt-meta{margin-top:6px!important;display:flex!important;align-items:center!important;gap:8px!important;justify-content:center!important}
    .pm-tt-logo{height:25px!important;width:auto!important;vertical-align:middle!important}
    .pm-tt-text{font-weight:600!important;white-space:nowrap!important}

    .pm-comment-preview{color:#333!important;display:flex;align-items:center;width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.3;gap:8px}
    .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}
    .pm-vote{display:inline-flex;align-items:center;gap:4px;margin-left:auto;flex:0 0 auto}
    .pm-vote .pm-vote-btn{cursor:pointer;color:#9ACD32;line-height:1}
    .pm-vote .pm-vote-value{padding:0 3px;color:#CCC;font:bold 10pt Arial;line-height:1}
    .pm-vote .pm-vote-value.pm-positive{color:rgb(51,153,0)!important}
    .pm-vote .pm-vote-value.pm-negative{color:#CC0000!important}
    .pm-vote .pm-vote-btn.pm-busy{opacity:.6;pointer-events:none}
  `);

    // ---------- Cache helpers ----------
    function readCache(key) {
        try {
            const raw = typeof GM_getValue === "function" ? GM_getValue(key, "{}") : localStorage.getItem(key) || "{}";
            return JSON.parse(raw);
        } catch { return {}; }
    }
    function writeCache(obj, 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 (!e.ts || (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);
    }
    // meta cache (object payload)
    function getMetaFromCache(id, key = PLATE_META_CACHE_KEY, maxAge = PLATE_MAX_AGE_MS) {
        const c = readCache(key), e = c[id];
        if (!e) return null;
        if (!e.ts || (Date.now() - e.ts > maxAge)) { delete c[id]; writeCache(c, key); return null; }
        return e.data;
    }
    function putMetaInCache(id, data, key = PLATE_META_CACHE_KEY) {
        const c = readCache(key);
        c[id] = { data, ts: Date.now() };
        writeCache(c, key);
    }
    (function prune() {
        const c1 = readCache(CACHE_KEY), c2 = readCache(NOMER_CACHE_KEY), c3 = readCache(PLATE_CACHE_KEY), c4 = readCache(PLATE_META_CACHE_KEY);
        let ch1=false,ch2=false,ch3=false,ch4=false, now=Date.now();
        for (const [k,v] of Object.entries(c1)) if (!v || !v.ts || (now - v.ts > MAX_AGE_MS)) { delete c1[k]; ch1=true; }
        for (const [k,v] of Object.entries(c2)) if (!v || !v.ts || (now - v.ts > NOMER_MAX_AGE_MS)) { delete c2[k]; ch2=true; }
        for (const [k,v] of Object.entries(c3)) if (!v || !v.ts || (now - v.ts > PLATE_MAX_AGE_MS)) { delete c3[k]; ch3=true; }
        for (const [k,v] of Object.entries(c4)) if (!v || !v.ts || (now - v.ts > PLATE_MAX_AGE_MS)) { delete c4[k]; ch4=true; }
        if (ch1) writeCache(c1, CACHE_KEY);
        if (ch2) writeCache(c2, NOMER_CACHE_KEY);
        if (ch3) writeCache(c3, PLATE_CACHE_KEY);
        if (ch4) writeCache(c4, PLATE_META_CACHE_KEY);
    })();

    // ---------- Shared hover preview for avatars only ----------
    const PM_PREVIEW_SIZE = 200, 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);}

    // ---------- Utils ----------
    function absUrl(href){try{return new URL(href,location.origin).toString();}catch{return href;}}
    function escapeHtml(s=""){return s.replace(/[&<>"]/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[ch]));}
    const slug = s => s.normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'');

    // ---------- 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); rowEl.dataset[PROCESSED_FLAG]="1";
    }

    // ---------- Small task queue ----------
    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 userId=(userLink.getAttribute("href").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(userLink.getAttribute("href")); if(url){putInCache(userId,url); addAvatarToRow(rowEl,url);} else {rowEl.dataset[PROCESSED_FLAG]="1";}}catch{}finally{delete rowEl.dataset.pmAvatarPending;}});
    }
    function processAllAvatars(root=document){ root.querySelectorAll(LIKE_ITEM_SELECTOR).forEach(processRow); }

    // ---------- Relative "x ago" ----------
    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"; }
        });
    }

    // ---------- PM panel helpers ----------
    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)); }

    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 plate PNG + first make/model + try logo existence
    async function fetchNomerMeta(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 doc = new DOMParser().parseFromString(html, "text/html");

        // plate png (regex fastest)
        const mPng = html.match(/https?:\/\/img\d+\.platesmania\.com\/\d{6}\/inf\/\d+[a-z0-9]*\.png/i);
        const pngUrl = mPng ? mPng[0] : null;

        // make/model from the h3 that contains /catalog links
        let make = null, model = null, logoUrl = null;
        const col = doc.querySelector('.col-md-6.col-sm-7');
        const h3Candidates = col ? Array.from(col.querySelectorAll('h3')) : [];
        const mmH3 =
              h3Candidates.find(h => h.matches('.text-center.margin-bottom-10')) ||
              h3Candidates.find(h => h.querySelector('a[href*="/catalog"]')) ||
              null;

        if (mmH3) {
            const links = mmH3.querySelectorAll('a[href*="/catalog"]');
            if (links[0]) make = (links[0].textContent || "").trim();
            if (links[1]) model = (links[1].textContent || "").trim();

            if (make) {
                const s = slug(make).toLowerCase();
                const testUrl = `https://raw.githubusercontent.com/filippofilip95/car-logos-dataset/refs/heads/master/logos/thumb/${s}.png`;
                try {
                    const r = await fetch(testUrl, { method: "GET", cache: "force-cache" });
                    if (r.ok) logoUrl = testUrl;
                } catch {}
            }
        }

        return { pngUrl, make, model, logoUrl };
    }

    // ---------- Helper: inject logo/model into tooltip preview ----------
    function ensureTooltipMeta(linkEl, meta){
        if (!linkEl || !meta) return;

        // Build HTML fragment for meta row
        const safeMake = (meta.make || "").trim();
        const safeModel = (meta.model || "").trim();
        const textFallback = [safeMake, safeModel].filter(Boolean).join(" ");

        const metaFragment = meta.logoUrl
            ? `<div class="pm-tt-meta"><img class="pm-tt-logo" alt="" src="${meta.logoUrl}"><span class="pm-tt-text">${escapeHtml(safeModel || "")}</span></div>`
            : (textFallback ? `<div class="pm-tt-meta"><span class="pm-tt-text">${escapeHtml(textFallback)}</span></div>` : "");

        if (!metaFragment) return;

        // 1) Update the attribute used by PM's tooltip (keeps it consistent for future hovers)
        const currentTitle = linkEl.getAttribute("data-original-title") || linkEl.getAttribute("title") || "";
        if (currentTitle && !/pm-tt-meta/.test(currentTitle)) {
            // Keep the existing <img ...> first, then our meta below
            // If the title already contains an <img>, we just append our block.
            const newTitle = `${currentTitle}${metaFragment}`;
            linkEl.setAttribute("data-original-title", newTitle);
            // some builds also mirror in title=""
            if (linkEl.hasAttribute("title")) linkEl.setAttribute("title", newTitle);
        }

        // 2) If a live tooltip is open, patch its DOM immediately
        const tipId = linkEl.getAttribute("aria-describedby");
        if (tipId) {
            const tip = document.getElementById(tipId);
            const inner = tip && tip.querySelector(".tooltip-inner");
            if (inner && !inner.querySelector(".pm-tt-meta")) {
                // append a DOM node rather than nuking the existing image
                const wrapper = document.createElement("div");
                wrapper.className = "pm-tt-meta";
                if (meta.logoUrl) {
                    const img = document.createElement("img");
                    img.className = "pm-tt-logo";
                    img.alt = "";
                    img.src = meta.logoUrl;
                    const span = document.createElement("span");
                    span.className = "pm-tt-text";
                    span.textContent = safeModel || "";
                    wrapper.appendChild(img);
                    wrapper.appendChild(span);
                } else {
                    const span = document.createElement("span");
                    span.className = "pm-tt-text";
                    span.textContent = textFallback;
                    wrapper.appendChild(span);
                }
                inner.appendChild(wrapper);
            }
        }
    }

    // ---------- Likes list: plate → image; meta goes to tooltip (NOT next to link) ----------
    function swapLinkTextWithPlateAndMeta(linkEl, meta){
        if(!linkEl || !meta || !meta.pngUrl) return;
        if (linkEl.querySelector("img.pm-plate")) return;

        // keep native PM hover: DO NOT add our preview handlers here
        linkEl.textContent = "";
        const plateImg=document.createElement("img");
        plateImg.className="pm-plate"; plateImg.alt=""; plateImg.src=meta.pngUrl;
        linkEl.appendChild(plateImg);

        // NEW: inject logo+model (or text fallback) into tooltip preview
        ensureTooltipMeta(linkEl, meta);

        linkEl.dataset.pmPlateDone="1";
    }

    function processPlateInRow(rowEl){
        const link=rowEl.querySelector('a[href*="/nomer"]');
        if(!link || link.dataset.pmPlateDone==="1" || rowEl.dataset.pmPlatePending==="1") return;
        const href=link.getAttribute("href")||""; const id=(href.match(/nomer(\d+)/)||[])[1]; if(!id) return;

        const cachedMeta = getMetaFromCache(id);
        if (cachedMeta){ swapLinkTextWithPlateAndMeta(link, cachedMeta); return; }

        // Fallback to old png-only cache (for users with existing cache)
        const cachedPng = getFromCache(id, PLATE_CACHE_KEY, PLATE_MAX_AGE_MS);
        if (cachedPng){
            // still try to use any meta stored (if exists), else just insert plate
            swapLinkTextWithPlateAndMeta(link, { pngUrl: cachedPng, make:null, model:null, logoUrl:null });
            return;
        }

        rowEl.dataset.pmPlatePending="1";
        enqueue(async()=>{
            try{
                const meta=await fetchNomerMeta(href);
                if (meta.pngUrl){ putInCache(id, meta.pngUrl, PLATE_CACHE_KEY); }
                putMetaInCache(id, meta);
                swapLinkTextWithPlateAndMeta(link, meta);
            }catch{}finally{ delete rowEl.dataset.pmPlatePending; }
        });
    }
    function processAllPlates(root=document){ root.querySelectorAll(LIKE_ITEM_SELECTOR).forEach(processPlateInRow); }

    // ---------- Comments preview + votes (PM notifications) ----------
    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");
        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'); let user=""; if(userEl) user=(userEl.textContent||"").trim();
            const contentEl=b.querySelector('div[id^="z"]'); const html=contentEl?contentEl.innerHTML.trim():"";
            let cid=null; if(contentEl && contentEl.id && /^z\d+$/.test(contentEl.id)) cid=contentEl.id.slice(1);
            else{ const smallA=b.closest('.media')?.querySelector('h4 small a[href^="#"]'); if(smallA) cid=smallA.getAttribute('href').replace('#',''); }
            let votesText="0"; if(cid){ const valEl=b.querySelector(`#commentit-itogo-${cid}`); if(valEl) votesText=(valEl.textContent||"0").trim(); }
            return { user, html, cid, votesText };
        }).filter(x=>x.user && x.html && x.cid);
        return items.slice(-count);
    }

    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 styleVoteValue(el,text){ el.classList.remove('pm-positive','pm-negative'); const t=(text||"").trim(); if(/^\+/.test(t)) el.classList.add('pm-positive'); else if(/^-/.test(t)) el.classList.add('pm-negative'); }
    async function sendPreviewVote(commentId,valueEl,btnEl){
        if(!commentId) return; btnEl.classList.add('pm-busy');
        try{
            const url=absUrl(`/newcom_1_baseu/func.php?g=1&n=${encodeURIComponent(commentId)}`);
            const res=await fetch(url,{method:'GET',credentials:'same-origin',headers:{'X-Requested-With':'XMLHttpRequest'}});
            const text=(await res.text()).trim(); valueEl.textContent=text || valueEl.textContent || "0"; styleVoteValue(valueEl,valueEl.textContent);
        }catch{}finally{ setTimeout(()=>btnEl.classList.remove('pm-busy'),600); }
    }
    function wireUpPreviewVotes(root=document){
        root.querySelectorAll('.pm-vote .pm-vote-btn[data-comment-id]:not([data-pm-wired])').forEach(btn=>{
            btn.dataset.pmWired="1";
            btn.addEventListener('click',(e)=>{ e.preventDefault(); const cid=btn.getAttribute('data-comment-id'); const valueEl=btn.parentElement?.querySelector('.pm-vote-value'); if(!cid || !valueEl) return; sendPreviewVote(cid,valueEl,btn); });
        });
    }

    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];

        const flag=ccToFlag(cc);
        if(flag && titleLink && !titleLink.dataset.pmFlagged){ titleLink.textContent=`${flag} ${titleLink.textContent.trim()}`; titleLink.dataset.pmFlagged="1"; }

        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{}finally{ delete alertEl.dataset.pmPanelPending; }});
            }
        }

        const iEl=alertEl.querySelector("i");
        if(iEl){
            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{} }); }
            }
            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"; }
            }
        }

        // Replace "New comments..." with previews (+ upvote)
        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){
                    const html=comments.map(c=>{
                        const safeUser=escapeHtml(c.user); const votes=(c.votesText||"0").trim();
                        const positiveClass=/^\+/.test(votes)?" pm-positive":(/^-/.test(votes)?" pm-negative":"");
                        return `<div class="pm-comment-preview"><span class="pm-comment-text"><b>${safeUser}:</b> ${c.html}</span><span class="pm-vote" data-pm-cid="${c.cid}"><i class="fa fa-plus-circle pm-vote-btn" title="Upvote" data-comment-id="${c.cid}"></i><span class="pm-vote-value${positiveClass}" id="pm-commentit-itogo-${c.cid}">${escapeHtml(votes)}</span></span></div>`;
                    }).join("");
                    pDesc.innerHTML=html; wireUpPreviewVotes(pDesc);
                }
            }catch{}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); processAllPlates(n); processPmPanel(n); wireUpPreviewVotes(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); wireUpPreviewVotes(n); }}));});
            mo2.observe(pmPanel,{childList:true,subtree:true});
        }
    }

    // ---------- Kickoff ----------
    processAllAvatars(document);
    processAllTimes(document);
    processAllPlates(document);
    processPmPanel(document);
    wireUpPreviewVotes(document);
    observe();

    setInterval(()=>{ processAllAvatars(document); processAllTimes(document); processAllPlates(document); processPmPanel(document); wireUpPreviewVotes(document); },1500);
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址