您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Counts photos & videos per place (incl. views). Smart auto-load, CSV, aligned grid, media & place filters, row click to jump. Optimized for German UI, works in English too. Now with minimize/restore.
// ==UserScript== // @name GMEP – Photos & Views per Place (i18n + minimize) // @namespace local.gmep // @version 2.7.0-i18n // @description Counts photos & videos per place (incl. views). Smart auto-load, CSV, aligned grid, media & place filters, row click to jump. Optimized for German UI, works in English too. Now with minimize/restore. // @match https://www.google.com/maps/* // @match https://www.google.de/maps/* // @match https://www.google.at/maps/* // @include /^https:\/\/www\.google\.[a-z.]+\/maps\/.*/ // @license MIT // @copyright (c) 2025 CGIELER // @run-at document-idle // @grant none // ==/UserScript== (function () { 'use strict'; /* ---------- Router guard ---------- */ const onRoute = () => /\/maps\/contrib\/[^/]+\/photos/.test(location.href) ? boot() : teardown(); ['pushState','replaceState'].forEach(k=>{ const orig=history[k]; history[k]=function(){const r=orig.apply(this,arguments); setTimeout(onRoute,0); return r;}; }); addEventListener('popstate', onRoute); setInterval(onRoute, 800); /* ---------- i18n ---------- */ const I18N = { de: { title: 'GMEP • ALLE Fotos & Aufrufe je Ort', buttons: { scan:'Scan', load:'Alles laden (smart)', stop:'Stopp', reset:'Reset', csv:'CSV', find:'Scroller finden' }, media: { all:'Alle', photos:'Fotos', videos:'Videos' }, labels: { place:'Ort', placePh:'Ort filtern (Teilbegriff)…', delay:'Verzögerung (ms)', tip:'Tipp: Zeile anklicken → zum Ort im linken Paneel springen. Strg/⌘-Klick lädt automatisch nach unten, bis der Ort gefunden ist.', ready:'bereit…' }, head: { place:'Ort', ph:'Fotos', vd:'Videos', views:'Aufrufe' }, stats: ({n,ph,vd,vw,filter,mode}) => `${n} Orte • ${num(ph)} ${mode!=='videos'?'Fotos':'Fotos (0)'} • ${num(vd)} ${mode!=='photos'?'Videos':'Videos (0)'} • ${num(vw)} Aufrufe${filter}`, rowTip: (pv,vv,mode) => mode==='photos' ? `Foto-Aufrufe: ${num(pv)} (Videos ausgeblendet)` : mode==='videos' ? `Video-Aufrufe: ${num(vv)} (Fotos ausgeblendet)` : `Foto-Aufrufe: ${num(pv)} • Video-Aufrufe: ${num(vv)}`, noRows: 'Keine Treffer.', jumped: p => `Zu „${p}“ gesprungen.`, notFound: p => `„${p}“ ist in den geladenen Einträgen nicht vorhanden.`, prog: { stopped:'gestoppt', done:'fertig', cycle: i => `Load-All: Zyklus ${i}` }, csv: { headers:['Ort','Fotos','FotoAufrufe','Videos','VideoAufrufe','AufrufeGesamt'], filename:'gmep_orte_fotos_aufrufe.csv' }, langLabel:'Sprache', langDe:'Deutsch', langEn:'English', mini: { minimize:'Minimieren', restore:'Maximieren' } }, en: { title: 'GMEP • ALL Photos & Views per Place', buttons: { scan:'Scan', load:'Load All (smart)', stop:'Stop', reset:'Reset', csv:'CSV', find:'Find Scroller' }, media: { all:'All', photos:'Photos', videos:'Videos' }, labels: { place:'Place', placePh:'Filter places (substring)…', delay:'Delay (ms)', tip:'Tip: Click a row to jump to the place in the left panel. Ctrl/Cmd-click auto-loads until found.', ready:'ready…' }, head: { place:'Place', ph:'Photos', vd:'Videos', views:'Views' }, stats: ({n,ph,vd,vw,filter,mode}) => `${n} places • ${num(ph)} ${mode!=='videos'?'photos':'photos (0)'} • ${num(vd)} ${mode!=='photos'?'videos':'videos (0)'} • ${num(vw)} views${filter}`, rowTip: (pv,vv,mode) => mode==='photos' ? `Photo views: ${num(pv)} (videos hidden)` : mode==='videos' ? `Video views: ${num(vv)} (photos hidden)` : `Photo views: ${num(pv)} • Video views: ${num(vv)}`, noRows: 'No results.', jumped: p => `Jumped to “${p}”.`, notFound: p => `“${p}” not found in loaded items.`, prog: { stopped:'stopped', done:'done', cycle: i => `Load-All: cycle ${i}` }, csv: { headers:['Place','Photos','PhotoViews','Videos','VideoViews','ViewsTotal'], filename:'gmep_photos_videos_per_place.csv' }, langLabel:'Language', langDe:'Deutsch', langEn:'English', mini: { minimize:'Minimize', restore:'Restore' } } }; function detectLang(){ const html = (document.documentElement.lang||'').slice(0,2).toLowerCase(); const nav = (navigator.language||'').slice(0,2).toLowerCase(); const a = document.querySelector('button.xUc6Hf[data-photo-id]')?.getAttribute('aria-label') || ''; if (/Aufruf|Ansicht/i.test(a)) return 'de'; if (/view/i.test(a)) return 'en'; if (html==='de'||nav==='de') return 'de'; return 'en'; } /* ---------- State ---------- */ const S = { totals:new Map(), seen:new Set(), auto:false, overlay:null, delayMs:700, scroller:null, booted:false, mode:'all', place:'', lang: detectLang(), min: false // Minimiert? }; /* ---------- Utils ---------- */ const num = n => n.toLocaleString(); const TX = el => (el?.innerText || el?.textContent || '').trim(); const N = s => parseInt((s||'').replace(/[^\d]/g,''),10)||0; const ESC = s => (s||'').replace(/[&<>"']/g, m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); const nap = ms => new Promise(r=>setTimeout(r,ms)); /* ---------- Place ---------- */ function placeFromNode(node){ const img = node.querySelector?.('img[alt], img[title]') || node.closest?.('button.xUc6Hf')?.querySelector('img[alt], img[title]'); const fromImg = (img?.alt || img?.title || '').trim(); if (fromImg) return fromImg; let el=node; for (let i=0;i<12 && el;i++,el=el.parentElement){ let s=el.previousElementSibling; while (s){ const h = s.matches?.('.YB0Y6d[aria-label], .YB0Y6d.BcOb1[aria-label]') ? s : s.querySelector?.('.YB0Y6d[aria-label], .YB0Y6d.BcOb1[aria-label]'); if (h){ const raw = h.getAttribute('aria-label') || ''; const cut = raw.split(/[·,–-]/)[0].replace(/\s\d.*$/,'').trim(); return cut || raw.trim(); } s = s.previousElementSibling; } } return S.lang==='de' ? 'Unbekannter Ort' : 'Unknown place'; } /* ---------- Media & Views ---------- */ const listMedia = () => Array.from(document.querySelectorAll('button.xUc6Hf[data-photo-id]')) .map(btn => ({ btn, isVideo:/video/i.test(btn.getAttribute('aria-label')||'') })); const VIEWS_RE = /([\d\s.,]+)\s*(Aufruf(e)?|Ansicht(en)?|views?)/i; function viewsFrom(btn){ const v1 = N(TX(btn.querySelector('.HtPsUd'))); if (v1>0) return v1; const a = btn.getAttribute('aria-label') || ''; const m = a.match(VIEWS_RE); return m ? N(m[1]) : 0; } /* ---------- Scroller ---------- */ const isScroll = el => { const cs = getComputedStyle(el), oy=cs.overflowY, o=cs.overflow; return (oy==='auto'||oy==='scroll'||o==='auto'||o==='scroll') && el.scrollHeight>el.clientHeight+4; }; const scrollParent = el => { let n=el; while(n&&n!==document.body){ if(isScroll(n)) return n; n=n.parentElement } return null; }; function ensureScroller(hl=false){ if (!S.scroller || !document.body.contains(S.scroller)){ const first = document.querySelector('button.xUc6Hf[data-photo-id]'); S.scroller = (first && scrollParent(first)) || Array.from(document.querySelectorAll('div,section,main,aside')).find(el=>isScroll(el)&&el.querySelector('button.xUc6Hf[data-photo-id]')) || document.querySelector('.m6QErb.XiKgde')?.parentElement || document.scrollingElement; } if (hl && S.scroller){ S.scroller.style.outline='2px solid #3b82f6'; setTimeout(()=>{if(S.scroller) S.scroller.style.outline='';},1200); } return S.scroller; } /* ---------- Styles ---------- */ function ensureStyles(){ if (document.getElementById('gmep-styles-i18n-min')) return; const st=document.createElement('style'); st.id='gmep-styles-i18n-min'; st.textContent = ` .gmep-box{position:fixed;top:12px;right:12px;z-index:2147483647;background:#fff;border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.12);padding:14px;max-height:78vh;overflow:auto;font:12px/1.4 system-ui,Segoe UI,Arial;min-width:700px} .gmep-title{display:flex;align-items:center;gap:8px;font-weight:700;margin-bottom:8px;font-size:13px} .gmep-controls{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px} .gmep-btn{appearance:none;border:1px solid transparent;padding:8px 12px;border-radius:10px;font-weight:600;cursor:pointer;transition:all .15s ease;user-select:none;box-shadow:0 1px 0 rgba(0,0,0,.04)} .gmep-btn:disabled{opacity:.6;cursor:not-allowed} .gmep-btn.primary{background:#2563eb;color:#fff;border-color:#2563eb}.gmep-btn.primary:hover{filter:brightness(1.05)} .gmep-btn.success{background:#10b981;color:#062b23;border-color:#10b981}.gmep-btn.success:hover{filter:brightness(1.05)} .gmep-btn.warn{background:#ef4444;color:#fff;border-color:#ef4444}.gmep-btn.warn:hover{filter:brightness(1.05)} .gmep-btn.ghost{background:#f8fafc;color:#111827;border-color:#e5e7eb}.gmep-btn.ghost:hover{background:#eef2f7} .gmep-inline{display:flex;gap:10px;align-items:center;margin-bottom:10px;flex-wrap:wrap} .gmep-input{width:90px;padding:6px 8px;border:1px solid #e5e7eb;border-radius:8px;font:12px system-ui} .gmep-input-wide{width:240px;padding:6px 8px;border:1px solid #e5e7eb;border-radius:8px;font:12px system-ui} .gmep-badge{font:11px system-ui;color:#6b7280} .gmep-seg{display:inline-flex;border:1px solid #e5e7eb;background:#f8fafc;border-radius:12px;overflow:hidden} .gmep-segbtn{padding:6px 10px;border:0;background:transparent;cursor:pointer;font-weight:600} .gmep-segbtn.on{background:#111827;color:#fff} .gmep-grid{display:grid;grid-template-columns:1fr 90px 90px 120px;column-gap:12px;align-items:baseline} .gmep-head{font-weight:700;border-bottom:1px solid #f1f5f9;padding:6px 0;margin-bottom:4px;position:sticky;top:0;background:#fff} .gmep-row{padding:4px 0;border-bottom:1px dashed #f1f5f9} .gmep-row.clickable{cursor:pointer} .gmep-row.clickable:hover{background:#f9fafb} .gmep-num{text-align:right} .gmep-highlight{outline:3px solid #f59e0b; outline-offset:2px; transition:outline-color .2s} .gmep-lang{margin-left:auto; display:flex; gap:6px; align-items:center} .gmep-select{padding:6px 10px;border:1px solid #e5e7eb;border-radius:8px;background:#fff} .gmep-minbtn{padding:6px 10px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc} .gmep-minbtn:hover{background:#eef2f7} /* Minimiert: nur Titelzeile bleibt sichtbar, restliche Bereiche ausblenden */ .gmep-box.gmep-minimized{padding:6px 8px; min-width:auto; width:auto} .gmep-box.gmep-minimized .gmep-controls, .gmep-box.gmep-minimized .gmep-inline, .gmep-box.gmep-minimized #gm-stats, .gmep-box.gmep-minimized .gmep-head, .gmep-box.gmep-minimized #gm-list, .gmep-box.gmep-minimized .gmep-badge:not(.gmep-title){display:none !important} .gmep-box.gmep-minimized .gmep-lang{display:none} `; document.head.appendChild(st); } /* ---------- UI ---------- */ function ensureOverlay(){ if (S.overlay && document.body.contains(S.overlay)) return; ensureStyles(); const i = I18N[S.lang]; const box=document.createElement('div'); box.className='gmep-box'; box.innerHTML = ` <div class="gmep-title"> <span id="gm-title-text">${i.title}</span> <span class="gmep-lang"> <label>${i.langLabel}:</label> <select id="gm-lang" class="gmep-select"> <option value="de"${S.lang==='de'?' selected':''}>${I18N.de.langDe}</option> <option value="en"${S.lang==='en'?' selected':''}>${I18N.en.langEn}</option> </select> </span> <button id="gm-min" class="gmep-minbtn" title="${i.mini.minimize}">${i.mini.minimize}</button> </div> <div class="gmep-controls"> <button id="gm-scan" class="gmep-btn primary">${i.buttons.scan}</button> <button id="gm-load" class="gmep-btn success">${i.buttons.load}</button> <button id="gm-stop" class="gmep-btn warn">${i.buttons.stop}</button> <button id="gm-reset" class="gmep-btn ghost">${i.buttons.reset}</button> <button id="gm-csv" class="gmep-btn ghost">${i.buttons.csv}</button> <button id="gm-find" class="gmep-btn ghost" title="${i.buttons.find}">${i.buttons.find}</button> </div> <div class="gmep-inline"> <div class="gmep-seg" id="gm-media"> <button class="gmep-segbtn" data-m="all">${i.media.all}</button> <button class="gmep-segbtn" data-m="photos">${i.media.photos}</button> <button class="gmep-segbtn" data-m="videos">${i.media.videos}</button> </div> <label>${i.labels.place}: <input id="gm-place" list="gm-places" class="gmep-input-wide" placeholder="${i.labels.placePh}"> </label> <datalist id="gm-places"></datalist> <button id="gm-clear" class="gmep-btn ghost">${S.lang==='de'?'Löschen':'Clear'}</button> <label>${i.labels.delay}: <input id="gm-delay" type="number" min="200" max="3000" class="gmep-input"></label> <span id="gm-prog" class="gmep-badge"></span> </div> <div class="gmep-badge" style="margin:-4px 0 6px 0;">${i.labels.tip}</div> <div id="gm-stats" class="gmep-badge">${i.labels.ready}</div> <div class="gmep-grid gmep-head"> <span>${i.head.place}</span><span class="gmep-num">${i.head.ph}</span><span class="gmep-num">${i.head.vd}</span><span class="gmep-num">${i.head.views}</span> </div> <div id="gm-list">–</div> `; document.body.appendChild(box); S.overlay=box; // Sprache umschalten box.querySelector('#gm-lang').onchange = e => { S.lang = (e.target.value === 'de') ? 'de' : 'en'; // Overlay neu aufbauen, Minimierungszustand erhalten const keepMin = S.min; S.overlay?.remove(); S.overlay=null; ensureOverlay(); setMin(keepMin); render(0); }; // Minimieren/Maximieren box.querySelector('#gm-min').onclick = () => setMin(!S.min); // Minimierungszustand aus localStorage übernehmen try { S.min = localStorage.getItem('gmep:min') === '1'; } catch(_) {} setMin(S.min); // Controls box.querySelector('#gm-delay').value = S.delayMs; box.querySelector('#gm-delay').onchange = e => S.delayMs = Math.max(200, +e.target.value || 700); box.querySelector('#gm-scan').onclick = () => { const a = scanOnce(); render(a); }; box.querySelector('#gm-load').onclick = () => loadAllSmart(); box.querySelector('#gm-stop').onclick = () => { S.auto = false; setProg(I18N[S.lang].prog.stopped); }; box.querySelector('#gm-reset').onclick = () => { S.totals.clear(); S.seen.clear(); render(0); }; box.querySelector('#gm-csv').onclick = exportCSV; box.querySelector('#gm-find').onclick = () => ensureScroller(true); // Medien-Schalter const seg=box.querySelector('#gm-media'); seg.addEventListener('click', e=>{ const b=e.target.closest('.gmep-segbtn'); if(!b) return; S.mode=b.dataset.m; seg.querySelectorAll('.gmep-segbtn').forEach(x=>x.classList.toggle('on', x===b)); render(0); }); seg.querySelector(`.gmep-segbtn[data-m="${S.mode}"]`).classList.add('on'); // Ort-Filter const pf = box.querySelector('#gm-place'); pf.value = S.place || ''; pf.oninput = e => { S.place = e.target.value.trim(); render(0); }; box.querySelector('#gm-clear').onclick = () => { S.place=''; pf.value=''; render(0); }; // Zeilenklick → springen box.addEventListener('click', e=>{ const row = e.target.closest('.gmep-row[data-place]'); if (!row) return; const place = row.dataset.place; const tryLoad = e.ctrlKey || e.metaKey; // Strg/⌘ jumpToPlace(place, tryLoad); }); } function updateMinButton(){ const btn = S.overlay?.querySelector('#gm-min'); if (!btn) return; const i = I18N[S.lang]; btn.textContent = S.min ? i.mini.restore : i.mini.minimize; btn.title = btn.textContent; } function setMin(flag){ S.min = !!flag; try { localStorage.setItem('gmep:min', S.min ? '1':'0'); } catch(_){} if (S.overlay){ S.overlay.classList.toggle('gmep-minimized', S.min); updateMinButton(); } } const setProg = t => { const el=S.overlay?.querySelector('#gm-prog'); if (el) el.textContent = t||''; }; /* ---------- Render ---------- */ function render(last=0){ ensureOverlay(); const i = I18N[S.lang]; // datalist füllen const dl = S.overlay.querySelector('#gm-places'); if (dl) dl.innerHTML = [...S.totals.keys()].sort().slice(0,2000).map(p=>`<option value="${ESC(p)}">`).join(''); // rows (mit Anzeige-Filter) let rows = [...S.totals.entries()].map(([p,o])=>{ const ph=o.photos||0, pv=o.photoViews||0, vd=o.videos||0, vv=o.videoViews||0; let showPh=ph, showVd=vd, showViews=pv+vv, tip=i.rowTip(pv,vv,S.mode); if (S.mode==='photos'){ showVd=0; showViews=pv; tip=i.rowTip(pv,vv,S.mode); } if (S.mode==='videos'){ showPh=0; showViews=vv; tip=i.rowTip(pv,vv,S.mode); } return [p, showPh, showVd, showViews, pv, vv]; }); const q = S.place?.trim().toLowerCase(); if (q) rows = rows.filter(r => r[0].toLowerCase().includes(q)); rows.sort((a,b)=>b[3]-a[3]); const sumPh=rows.reduce((a,r)=>a+r[1],0), sumVd=rows.reduce((a,r)=>a+r[2],0), sumVw=rows.reduce((a,r)=>a+r[3],0); const filtTxt = q ? (S.lang==='de' ? ` • Filter: „${S.place}“` : ` • Filter: “${S.place}”`) : ''; const statsEl = S.overlay.querySelector('#gm-stats'); if (statsEl){ statsEl.textContent = i.stats({n:rows.length, ph:sumPh, vd:sumVd, vw:sumVw, filter:filtTxt, mode:S.mode}) + (last? (S.lang==='de'?` • +${last} neu`:` • +${last} new`):``); } const listEl = S.overlay.querySelector('#gm-list'); if (listEl){ listEl.innerHTML = rows.length ? rows.slice(0,400).map(([p,ph,vd,vw,pvw,vvw]) => `<div class="gmep-grid gmep-row clickable" data-place="${ESC(p)}" title="${ESC(I18N[S.lang]===I18N.de ? 'Klicken zum Springen · Strg/⌘-Klick lädt bis gefunden' : 'Click to jump · Ctrl/Cmd-click to auto-load until found')}"> <span title="${ESC(p)}">${ESC(p.length>80?p.slice(0,79)+'…':p)}</span> <span class="gmep-num">${num(ph)}</span> <span class="gmep-num">${num(vd)}</span> <span class="gmep-num" title="${ESC(I18N[S.lang].rowTip(pvw,vvw,S.mode))}">${num(vw)}</span> </div>`).join('') : ESC(i.noRows); } } /* ---------- CSV ---------- */ function exportCSV(){ const i = I18N[S.lang]; const rows = [ i.csv.headers, ...[...S.totals.entries()].map(([p,o])=>{ const ph=o.photos||0,pv=o.photoViews||0,vd=o.videos||0,vv=o.videoViews||0; return [p,ph,pv,vd,vv,pv+vv]; }).sort((a,b)=>b[5]-a[5]) ]; const csv = rows.map(r=>r.map(x=>`"${String(x).replace(/"/g,'""')}"`).join(',')).join('\r\n'); const blob= new Blob([csv],{type:'text/csv;charset=utf-8'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=i.csv.filename; a.click(); URL.revokeObjectURL(a.href); } /* ---------- Scan & Auto ---------- */ function scanOnce(){ let added=0; for (const {btn,isVideo} of listMedia()){ const id = btn.getAttribute('data-photo-id'); if (!id || S.seen.has(id)) continue; S.seen.add(id); const place = placeFromNode(btn); const v = viewsFrom(btn); const e = S.totals.get(place) || {photos:0,photoViews:0,videos:0,videoViews:0}; if (isVideo){ e.videos++; e.videoViews += v; } else { e.photos++; e.photoViews += v; } S.totals.set(place, e); added++; } return added; } async function loadAllSmart(maxLoops=300){ S.auto = true; const sc = ensureScroller(false) || document.scrollingElement; let stable = 0; for (let i=0; i<maxLoops && S.auto; i++){ sc.scrollTop = sc.scrollHeight; sc.dispatchEvent(new Event('scroll',{bubbles:true})); setProg(I18N[S.lang].prog.cycle(i+1)); await nap(S.delayMs); const a = scanOnce(); render(a); if (a===0){ if (++stable >= 3) break; } else stable = 0; } S.auto = false; setProg(I18N[S.lang].prog.done); } /* ---------- Jump to place ---------- */ function findFirstNodeForPlace(place){ const btns = document.querySelectorAll('button.xUc6Hf[data-photo-id]'); for (const btn of btns){ if (placeFromNode(btn) === place) return btn; } return null; } async function jumpToPlace(place, loadIfMissing=false){ const sc = ensureScroller(true) || document.scrollingElement; let el = findFirstNodeForPlace(place); if (!el && loadIfMissing){ for (let i=0;i<60 && !el;i++){ sc.scrollTop = sc.scrollHeight; sc.dispatchEvent(new Event('scroll',{bubbles:true})); await nap(300); el = findFirstNodeForPlace(place); } } if (el){ el.scrollIntoView({behavior:'smooth',block:'center',inline:'nearest'}); const card = el.closest('.WY21Hc') || el; card.classList.add('gmep-highlight'); setTimeout(()=>card.classList.remove('gmep-highlight'), 1600); setProg(I18N[S.lang].jumped(place)); }else{ setProg(I18N[S.lang].notFound(place)); } } /* ---------- Boot / Teardown ---------- */ function boot(){ if (S.booted) return; S.booted = true; // Minimierungszustand vorab laden try { S.min = localStorage.getItem('gmep:min') === '1'; } catch(_){} ensureOverlay(); ensureScroller(true); render( scanOnce() ); } function teardown(){ if (!S.booted) return; S.overlay?.remove(); S.overlay=null; S.totals.clear(); S.seen.clear(); S.scroller=null; S.auto=false; S.booted=false; } if (document.readyState === 'loading') addEventListener('DOMContentLoaded', onRoute); else onRoute(); })();