A tool that makes filtering photo/video contributions easier

Hi Local Guides!

I was inspired by @PrasadVR’s thread: How to Find Your Most-Viewed Video on Google Maps - Google Maps tips & tricks - Local Guides Connect and @WilfriedB to create a userscript that makes it easy to load all your photo/video contributions and filter them by type and/or place.

You can find the script here: Google Maps Enhanced Photos

11 Likes

Thank you, @gncnpk. You created a userscript to filter photos and videos. I will give it a try.

1 Like

Thank you!

1 Like

Thanks a lot @gncnpk, this will improve a lot my approach to analyze the views of all photos and to identify hidden (i.e. not public) media and reviews.

I tried this new script on Chrome, but Auto Load did not start scrolling :pensive_face:

1 Like

Looks promising but somehow didn’t work for me so far. Script shomehow not updating/collecting the information. What do I wrong?


1 Like

According to your name and the screenshot, @ChristianGieler you might also use a German user interface …? Since auto loading did not work for my neither, there might be some dependency on the language, @gncnpk?

In any case, don’t forget, Gavin just wrote this from scratch 12 hours ago, so some bugs are very likely :pensive_face:

1 Like

Ah, I think I know why, I’m checking for a attribute called aria-label*=Photo or aria-label*=Video, it probably changes based on the language.

1 Like

That did it, @gncnpk and @ChristianGieler. Need to change in line 146 to “Foto”:
grafik and also lines 173, 326 and 368.

Since I don’t do videos, I cannot test that filtering part, but filtering by place name does work.

Christian, please note, many times after clicking the Enable Auto Load button, you just need to “push” the scrolling, i.e. move the scroll bar just a little bit and press the end or page down key.

2 Likes

I might change it to just “oto” since it covers Photo (english) and Foto (german), looks like Video is the same word in both languages, but I could be wrong

2 Likes

That is correct.

1 Like

Should be fixed now!

1 Like

Thanks a lot, @gncnpk! Auto Load for photos by date works fine:

However, the “most viewed” seems to be an arbitrary number, I already discovered yesterday. I don’t know, how you detect the number of views, but in German it is called “Aufrufe”. I use a Python script to gather views watching for div class “HtPsUd”.

1 Like

Hi all, thanks for the exchange so far. For me Tampermonkey plugin is just randomly working and the script even don’t get executed… What I am looking generally for is how much views I have on one place with all my pictures. Like in the good old times with the google streetview app on mobile. I played a bit with the initial skript and now generated a skript for the console (F12 in Chrome) where I can see the pictures per place and the views counting per view. If anyone is interested in the skript just ping me. Next step is to try it with Violentmonkey-Userscript to make it reusable, cause Tampermonkey seems not working in my environment

2 Likes

Hi all, @PrasadVR,
@WilfriedB, @gncnpk worked a bit more on that topic and created a script for my needs that works in OrangeMonkey Chrome Plugin


. Here’s a quick overview of what the userscript does and what each control means (it’s built to work with the German Google Maps UI):

What it does

  • Counts photos & videos per place on your contributions page (…/maps/contrib/…/photos).
  • Sums views per place (separating photo-views and video-views internally).
  • Works with the German interface strings (“Foto”, “Video”, “Aufrufe”, etc.). Using a different Maps language may break detection.

Buttons & controls

  • Scan – process only the items currently loaded in the left panel.
  • Load-All (smart) – repeatedly scrolls the contributions panel to the end, waits for lazy-loading, and keeps scanning until nothing new appears.
  • Stop – stops any running auto-load cycle.
  • Reset – clears all collected counts and starts fresh.
  • CSV – downloads a file with columns: Place, Photos, PhotoViews, Videos, VideoViews, ViewsTotal.
  • Find Scroller – highlights the scrollable container (left panel) the script uses.
  • Delay (ms) – wait time between auto-load cycles (increase if your connection is slow).

Filters

  • All / Photos / Videos (segmented switch)
    • All: shows photos, videos, and total views.
    • Photos: hides video counts and shows only photo-views in the Views column.
    • Videos: hides photo counts and shows only video-views in the Views column.
  • Place filter (text box with suggestions)
    • Type any part of a place name to filter rows.
    • Clear resets the filter.
    • **Clicking on an entry → Jump in the photopanel to that entry

Stats & table

  • Stats line: total number of places + totals for photos, videos, and views (respects the active filters).
  • Table columns (aligned grid):
    • Place – truncated with tooltip for long names.
    • Fotos – number of photos for the place.
    • Videos – number of videos for the place.
    • Views – total (or photo-only / video-only, depending on the filter). Hover to see the photo/video view breakdown.
  • Rows are sorted by Views (descending).

Tips / notes

  • For a complete count, use Load-All (smart) so everything is loaded once.
  • The script is read-only; it does not modify your contributions.
  • Designed for the German Maps UI; if your Google Maps language is not German, switch Google Maps to German for best results.

Here is the code:

Blockquote

Summary

// ==UserScript==
// @name GMEP – Fotos & Videos je Place (click-to-jump)
// @namespace local.gmep
// @version 2.4.0
// @description Counts all photos & videos per place (incl. views). Smart auto-load, CSV, aligned grid, media & place filters, and row click to jump to the place in the left panel (Ctrl/Cmd-click auto-loads until found).
// @match Google Maps*
// @match Google Maps*
// @match Google Maps*
// @include /^https://www.google.[a-z.]+/maps/.*/
// @run-at document-idle
// @grant none
// ==/UserScript==

(function () {
‘use strict’;

// -------- Router guard: only on …/contrib/…/photos
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);
const tick = setInterval(onRoute, 800);

// -------- State
const S = {
totals:new Map(), seen:new Set(),
auto:false, overlay:null, delayMs:700, scroller:null, booted:false,
mode:‘all’, // ‘all’ | ‘photos’ | ‘videos’
place:‘’ // place filter (substring)
};

// -------- Utils
const TX = el => (el?.innerText || el?.textContent || ‘’).trim();
const N = s => parseInt((s||‘’).replace(/[^\d]/g,‘’),10)||0;
const T = n => n.toLocaleString();
const ESC= s => (s||‘’).replace(/[&<>“‘]/g, m=>({’&‘:’&‘,’<‘:’<‘,’>‘:’>‘,’”‘:’"‘,"’":‘'’}[m]));
const nap= ms => new Promise(r=>setTimeout(r,ms));

// -------- Place detection
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 ‘Unbekannter Ort’;
}

// -------- Media & views
const listMedia = () =>
Array.from(document.querySelectorAll(‘button.xUc6Hf[data-photo-id]’))
.map(btn => ({ btn, isVideo:/video/i.test(btn.getAttribute(‘aria-label’)||‘’) }));

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(/([\d\s.,]+)\s*(Aufruf|Ansicht|views?)/i);
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-styles24’)) return;
const st=document.createElement(‘style’); st.id=‘gmep-styles24’;
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:660px}
.gmep-title{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}
`;
document.head.appendChild(st);

}

// -------- UI
function ensureOverlay(){
if (S.overlay && document.body.contains(S.overlay)) return;
ensureStyles();
const box=document.createElement(‘div’); box.className=‘gmep-box’;
box.innerHTML = `

GMEP • ALLE Fotos & Views je Place


Scan
Load-All (smart)
Stop
Reset
CSV
Find Scroller

  <div class="gmep-inline">
    <div class="gmep-seg" id="gm-media">
      <button class="gmep-segbtn" data-m="all">All</button>
      <button class="gmep-segbtn" data-m="photos">Photos</button>
      <button class="gmep-segbtn" data-m="videos">Videos</button>
    </div>
    <label>Place:
      <input id="gm-place" list="gm-places" class="gmep-input-wide" placeholder="Filter places (substring)…">
    </label>
    <datalist id="gm-places"></datalist>
    <button id="gm-clear" class="gmep-btn ghost">Clear</button>

    <label>Delay(ms): <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;">Tip: Click a row to jump to that place in the left panel. Ctrl/Cmd-click will auto-load until it is found.</div>

  <div id="gm-stats" class="gmep-badge">ready…</div>
  <div class="gmep-grid gmep-head">
    <span>Place</span><span class="gmep-num">Fotos</span><span class="gmep-num">Videos</span><span class="gmep-num">Views</span>
  </div>
  <div id="gm-list">–</div>
`;
document.body.appendChild(box); S.overlay=box;

// 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('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);

// Media toggle
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');

// Place 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); };

// Row click → jump
box.addEventListener('click', e=>{
  const row = e.target.closest('.gmep-row[data-place]');
  if (!row) return;
  const place = row.dataset.place;
  // Ctrl/Cmd-click: also auto-load while searching
  const tryLoad = e.ctrlKey || e.metaKey;
  jumpToPlace(place, tryLoad);
});

}
const setProg = t => { const el=S.overlay?.querySelector(‘#gm-prog’); if (el) el.textContent = t||‘’; };

// -------- Render
function render(last=0){
ensureOverlay();
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(‘’);

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=`Foto-Views: ${T(pv)} • Video-Views: ${T(vv)}`;
  if (S.mode==='photos'){ showVd=0; showViews=pv; tip=`Foto-Views: ${T(pv)} (videos hidden)`; }
  if (S.mode==='videos'){ showPh=0; showViews=vv; tip=`Video-Views: ${T(vv)} (photos hidden)`; }
  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 ? ` • Filter: “${S.place}”` : '';

S.overlay.querySelector('#gm-stats').textContent =
  `${rows.length} Places • ${T(sumPh)} ${S.mode!=='videos'?'Fotos':'Fotos (0)'} • ${T(sumVd)} ${S.mode!=='photos'?'Videos':'Videos (0)'} • ${T(sumVw)} Views${filtTxt}` +
  (last?` • +${last} new`:``);

S.overlay.querySelector('#gm-list').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="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">${T(ph)}</span>
       <span class="gmep-num">${T(vd)}</span>
       <span class="gmep-num" title="Photo-views: ${T(pvw)} • Video-views: ${T(vvw)}">${T(vw)}</span>
     </div>`).join('')
  : 'No results.';

}

// -------- CSV
function exportCSV(){
const rows = [[‘Place’,‘Photos’,‘PhotoViews’,‘Videos’,‘VideoViews’,‘ViewsTotal’],
…[…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=‘gmep_photos_videos_per_place.csv’; a.click(); URL.revokeObjectURL(a.href);
}

// -------- Scan & Auto-load
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(Load-All: 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(‘done’);
}

// -------- Click-to-jump
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){
  // try to auto-load down a bit until we find it
  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(`Jumped to "${place}"`);
}else{
  setProg(`"${place}" not found in loaded items.`);
}

}

// -------- Boot / Teardown
function boot(){
if (S.booted) return;
S.booted = true;
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();
})();

Blockquote

2 Likes

Thank you for sharing this script! It’s great to see other folks making scripts :slight_smile:

1 Like

Thanks. I added an english version to the script and a button to switch between languages. Also a “minimize” possibilty to watch the pictures when the script is running.
maybe you can test it out with your english browser version if it works for you?

Summary

Blockquote

// ==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 Google Maps*
// @match Google Maps*
// @match Google Maps*
// @include /^https://www.google.[a-z.]+/maps/.*/
// @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();
})();

Blockquote

2 Likes

@gncnpk I don’t know anything about JavaScript and therefore use a different approach to analyze my contributions and did scroll reviews, photos and edits manually until very recently, copied the underlying HTML code to clipboard and analyze it with Python scripts.

Doing so, your scripts proved extremely helpful using the auto load function. Unfortunately there is no need for any of the filtering functions for me, since I do that all afterwards, offline. For the views and edits, I keep the history in spreadsheets.

Anyway, thanks a lot for your scripts - the save a lot of time. for me.

1 Like

You’re welcome, I’m always happy to help out! Hopefully, there will be more interest in scripts :slight_smile:

1 Like