// ==UserScript== // @name MapsGraderSheet // @namespace grader.lock.sheet // @version 1.9.10 // @description Grade Google Maps places, keep a local saved json places list and sync with Google Sheets. Improved UI flow for updating an already graded place: click Saved/Gespeichert, remove previous grade, retry Save/Speichern until list reappears, then pick new grade. Added German address parsing for Postleitzahl and Stadt. Robust, conservative place-name handling to avoid transient "0" labels. // @match https://www.google.com/maps/* // @grant GM_xmlhttpRequest // @connect script.google.com // @connect script.googleusercontent.com // @connect maps.googleapis.com // ==/UserScript== (() => { 'use strict'; // Toggle to true only while debugging locally const DEBUG = true; // --- NEW: SHEET COLUMN MAPPING --- const SHEET_COLUMNS = { recordNumber: 1, email: 2, firma: 3, adresse: 4, postleitzahl: 5, stadt: 6, telefon: 7, website: 8, mapsLink: 9, kategorie: 10, latLon: 11, googleMapsLabel: 12, id: 13, managingDirector: 14 }; let currentDetailsFetchId = 0; let lastShownPlaceName = ''; // --- NEW: ADDRESS PARSING FUNCTIONS --- // robust parser for postal code and city from a typical German address function parsePostalCodeAndCity(raw) { if (!raw || typeof raw !== 'string') return { postalCode: '', city: '' }; const s = raw.replace(/\u00A0/g, ' ').trim(); const postalMatch = s.match(/(\d{5})/); let postal = postalMatch ? postalMatch[1] : ''; let city = ''; if (postal) { const rx = new RegExp('\\b' + postal + '\\s+([\\p{L}\\s\\.]+?)(?=,|$)', 'u'); const cm = s.match(rx); if (cm && cm[1]) { city = cm[1].trim(); } } if (!city && postal) { const fallback = s.split(postal)[1] || ''; const m = fallback.match(/^\s*([^\u002C]+)/); if (m && m[1]) city = m[1].trim(); } if (!postal) { const parts = s.split(','); if (parts.length >= 2) { const candidate = parts[1].trim(); const m2 = candidate.match(/(\d{5})\s+(.+)/); if (m2) { postal = m2[1]; city = m2[2].replace(/,.*$/,'').trim(); } else { const m3 = candidate.match(/(\d{5})/); if (m3) postal = m3[1]; const after = candidate.replace(/^\d{5}\s*/,'').replace(/,.*$/,'').trim(); if (after) city = after; } } } city = city.replace(/\bDeutschland\b/i, '').replace(/\bGermany\b/i,'').trim(); return { postalCode: postal || '', city: city || '' }; } // helper to set Postleitzahl and Stadt and create Lat hyphen Lon key safely function enrichRecordWithPostalCityAndLatLon(rec, addressString, lat, lng) { const parsed = parsePostalCodeAndCity(addressString || ''); rec['Postleitzahl'] = parsed.postalCode || ''; rec['Stadt'] = parsed.city || ''; const latLonKey = 'Lat' + String.fromCharCode(45) + 'Lon'; rec[latLonKey] = (isFinite(lat) && isFinite(lng)) ? `${lat},${lng}` : ''; return rec; } // --- END NEW FUNCTIONS --- // tolerant grade read function getGradeFromRecord(rec) { if (!rec) return ''; const g = rec['Kategorie'] || rec['Category (A / B / C / D)'] || ''; return String(g || '').toUpperCase().trim(); } // New small helper: only accept real grades function sanitizeGrade(raw) { const s = String(raw || '').toUpperCase().trim(); if (s === 'A' || s === 'B' || s === 'C' || s === 'D') return s; return ''; } const SHEET_ID = '1d8WyNvMr8n7irWSJPRopnrN_1hBpH-bEhH7lvuNYrqE'; const FETCH_URL = 'https://script.google.com/macros/s/AKfycbxTjZvDCju60CDux4_OrpyA9UFJ__ARUbSqpdJAkxHxVeMpoJKyBvUwdFp6L71hq_2t/exec'; const ADD_URL = 'https://script.google.com/macros/s/AKfycbyI7dBt3IBG1zYhIPHUhttzchXFHnSO-5eOKaY1vcB7W9F0v4qaeT4qvanm7XWMOiRJ/exec'; const UPDATE_URL = 'https://script.google.com/macros/s/AKfycbzcCUneoE-AMUm0fWmI7gzQHY-eDYyhfjxrVxveRkks3D__63j_-TKFWGx_Lwg7Y4co/exec'; const GOOGLE_KEY = 'AIzaSyCvHUfmrjFCV8CfIU_uPH-0n68ooEX9xd8'; const COLORS = { A:'#2ecc71', B:'#f1c40f', C:'#ff8c42', D:'#e74c3c' }; const LOCAL_KEY = 'saved_json_places'; let ALLPLACESJSON = []; const INDEX = { map:new Map(), pos:new Map(), loaded:false, loading:null }; let persistenceObserver = null; let insertLock = false; let lastPlaceKey = computePlaceKey(); let nameDomObserver = null; let lastCanonicalHref = getCanonicalHref(); let lastDomName = extractPlaceNameFromDOM(); // caches to avoid repeated API calls const DETAILS_CACHE = new Map(); const DETAILS_PROMISES = new Map(); function ensureFont() { if (!document.getElementById('montLink')) { const l = document.createElement('link'); l.id = 'montLink'; l.rel = 'stylesheet'; l.href = 'https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700&display=swap'; document.head.appendChild(l); } } // fetch helpers with optional logging function fetchJSON(url) { return new Promise(resolve => { try { if (DEBUG) console.log('fetchJSON request', url); GM_xmlhttpRequest({ method: 'GET', url: url, onload: r => { try { if (DEBUG) console.log('fetchJSON response status', r.status); const parsed = JSON.parse(r.responseText); resolve(parsed); } catch (err) { if (DEBUG) console.error('fetchJSON parse error', err, 'raw', r.responseText); resolve(null); } }, onerror: err => { if (DEBUG) console.error('fetchJSON network error', err, url); resolve(null); } }); } catch (e) { if (DEBUG) console.error('fetchJSON unexpected error', e, url); resolve(null); } }); } function postJSON(url, body) { return new Promise(resolve => { try { if (DEBUG) console.log('postJSON request', url, body); GM_xmlhttpRequest({ method: 'POST', url: url, data: JSON.stringify(body), headers: { 'Content-Type': 'application/json' }, onload: r => { try { if (DEBUG) console.log('postJSON response', url, r.status); let parsed = null; try { parsed = JSON.parse(r.responseText); } catch(e){} resolve({ ok: r.status >= 200 && r.status < 300, status: r.status, text: r.responseText, json: parsed }); } catch (e) { if (DEBUG) console.error('postJSON onload handling error', e); resolve({ ok: r.status >= 200 && r.status < 300, status: r.status, text: r.responseText }); } }, onerror: e => { if (DEBUG) console.error('postJSON network error', e, url); resolve({ ok: false, status: 0, text: '' }); } }); } catch(e) { if (DEBUG) console.error('postJSON unexpected error', e, url); resolve({ ok: false, status: 0, text: '' }); } }); } function getCanonicalHref() { try { const link = document.querySelector('link[rel="canonical"]'); if (link && link.href) return String(link.href); const meta = document.querySelector('meta[property="og:url"]'); if (meta && meta.content) return String(meta.content); } catch(e){} return ''; } function getCurrentCid() { const canonical = getCanonicalHref(); const href = canonical || location.href; try { const url = new URL(href); const cidParam = url.searchParams.get('cid'); if (cidParam && /^\d+$/.test(cidParam)) return cidParam; const m = href.match(/0x[0-9a-fA-F]+:0x([0-9a-fA-F]+)/); if (m && m[1]) return BigInt('0x'+m[1]).toString(); } catch {} return null; } function computePlaceKey() { try { const canonical = getCanonicalHref() || location.href; const cid = (function(){ try { const u = new URL(canonical); const cidParam = u.searchParams.get('cid'); if (cidParam && /^\d+$/.test(cidParam)) return cidParam; } catch(e){} const m = canonical.match(/0x[0-9a-fA-F]+:0x([0-9a-fA-F]+)/); if (m && m[1]) return BigInt('0x'+m[1]).toString(); return null; })(); if (cid) return 'cid:' + cid; const dataMatch = canonical.match(/\/data=(!?[^\/?#]+)/); if (dataMatch && dataMatch[1]) return 'data:' + dataMatch[1]; const placeMatch = canonical.match(/\/place\/([^\/?#]+)/); if (placeMatch && placeMatch[1]) return 'place:' + decodeURIComponent(placeMatch[1]); const u = new URL(canonical); return 'url:' + u.origin + u.pathname; } catch (e) { return 'url:' + location.origin; } } // local storage helpers function loadLocalSaved() { try { const raw = localStorage.getItem(LOCAL_KEY); if (!raw) return null; const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed; } catch(e){} return null; } function saveLocalPlaces(arr) { try { localStorage.setItem(LOCAL_KEY, JSON.stringify(arr)); return true; } catch(e){ return false; } } // fetch sheet and respect local saved list function fetchSheet() { if (INDEX.loaded) return Promise.resolve(); if (INDEX.loading) return INDEX.loading; const local = loadLocalSaved(); if (local && Array.isArray(local) && local.length) { ALLPLACESJSON = local.slice(); INDEX.map.clear(); INDEX.pos.clear(); ALLPLACESJSON.forEach((r,i) => { const id = String(r.ID||''); INDEX.map.set(id, r); INDEX.pos.set(id, i); }); } INDEX.loading = fetchJSON(`${FETCH_URL}?sheetID=${encodeURIComponent(SHEET_ID)}`) .then(j => { const rows = Array.isArray(j?.data) ? j.data : []; if (!rows.length && ALLPLACESJSON.length) { INDEX.loaded = true; return; } if (!ALLPLACESJSON.length) { ALLPLACESJSON = rows.slice(); INDEX.map.clear(); INDEX.pos.clear(); ALLPLACESJSON.forEach((r,i) => { const id = String(r.ID||''); INDEX.map.set(id, r); INDEX.pos.set(id, i); }); saveLocalPlaces(ALLPLACESJSON); INDEX.loaded = true; return; } const localIds = ALLPLACESJSON.map(x => String(x.ID||'')); const sheetIds = rows.map(x => String(x.ID||'')); let differs = false; if (localIds.length !== sheetIds.length) differs = true; else { for (let i=0;i { const id = String(r.ID||''); INDEX.map.set(id, r); INDEX.pos.set(id, i); }); saveLocalPlaces(ALLPLACESJSON); } else { INDEX.map.clear(); INDEX.pos.clear(); ALLPLACESJSON.forEach((r,i) => { const id = String(r.ID||''); INDEX.map.set(id, r); INDEX.pos.set(id, i); }); } INDEX.loaded = true; }) .catch(()=>{ INDEX.loaded = true; ALLPLACESJSON = ALLPLACESJSON || []; }); return INDEX.loading; } // improved and defensive place-details loader function getPlaceDetails(cid, forceFetch = false) { if (!cid) return Promise.resolve(null); if (forceFetch) { DETAILS_CACHE.delete(cid); DETAILS_PROMISES.delete(cid); } if (DETAILS_CACHE.has(cid) && !forceFetch) { if (DEBUG) console.log('getPlaceDetails cache hit for', cid); return Promise.resolve(DETAILS_CACHE.get(cid)); } if (DETAILS_PROMISES.has(cid) && !forceFetch) { if (DEBUG) console.log('getPlaceDetails waiting for in flight promise for', cid); return DETAILS_PROMISES.get(cid); } const base = 'https://maps.googleapis.com/maps/api/place/details/json'; const params = [ 'cid=' + encodeURIComponent(cid), 'fields=' + encodeURIComponent('name,formatted_address,formatted_phone_number,website,geometry,types'), 'key=' + encodeURIComponent(GOOGLE_KEY) ].join('&'); const url = base + '?' + params; const p = fetchJSON(url).then(j => { const result = (j && j.status === 'OK') ? j.result : null; if (result) { DETAILS_CACHE.set(cid, result); } else { DETAILS_CACHE.delete(cid); } DETAILS_PROMISES.delete(cid); return result; }).catch(e => { DETAILS_PROMISES.delete(cid); return null; }); DETAILS_PROMISES.set(cid, p); return p; } function createProgressBar() { const wrap = document.createElement('div'); wrap.style.cssText='display:flex;align-items:center;justify-content:center;width:100%;height:110px;'; const bg = document.createElement('div'); bg.style.cssText='width:60%;height:8px;background:#e0e0e0;border-radius:8px;overflow:hidden;'; const fill = document.createElement('div'); fill.style.cssText='height:100%;width:0%;border-radius:8px;background:linear-gradient(90deg,#6be585 0%,#18a0fb 100%);'; bg.appendChild(fill); wrap.appendChild(bg); wrap._timer = null; wrap._start = () => { if (wrap._timer) return; let p=0, fwd=true; wrap._timer = setInterval(()=>{ p = fwd ? p+2 : p-2; if(p>=100) fwd=false; if(p<=0) fwd=true; fill.style.width = p+'%'; }, 18); }; wrap._stop = () => { clearInterval(wrap._timer); wrap._timer=null; fill.style.width='0%'; }; return wrap; } function styleButton(btn, color, active) { btn.style.cssText = ` flex:1; padding:8px 0; font-weight:700; border-radius:3px; font-family:Montserrat,Arial; cursor:pointer; border:1px solid ${color}; background:${ active ? color : '#fff' }; color:${ active ? '#fff' : color }; `; } function buildCatalog() { const wrap = document.createElement('div'); wrap.id = 'graderBox'; wrap.dataset.mapsgrader = '1'; wrap.style.cssText = ` background:#fff; border:1px solid #e7e7e7; border-radius:8px; margin:10px 0 14px 0; padding:12px; font:14px Montserrat,Arial; box-shadow:0 1px 6px rgba(0,0,0,0.06); width:100%; z-index:1000; `; const head = document.createElement('div'); head.id = 'gHead'; head.style.cssText = 'font-weight:700; margin-bottom:8px;'; head.textContent = '(loading…)'; wrap.appendChild(head); const content = document.createElement('div'); content.id = 'gContent'; const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:8px;'; const btnA = document.createElement('button'); btnA.type = 'button'; btnA.textContent = 'A'; btnA.dataset.g = 'A'; btnA.setAttribute('aria-label', 'grade A'); const btnB = document.createElement('button'); btnB.type = 'button'; btnB.textContent = 'B'; btnB.dataset.g = 'B'; btnB.setAttribute('aria-label', 'grade B'); const btnC = document.createElement('button'); btnC.type = 'button'; btnC.textContent = 'C'; btnC.dataset.g = 'C'; btnC.setAttribute('aria-label', 'grade C'); const btnD = document.createElement('button'); btnD.type = 'button'; btnD.textContent = 'D'; btnD.dataset.g = 'D'; btnD.setAttribute('aria-label', 'grade D'); row.appendChild(btnA); row.appendChild(btnB); row.appendChild(btnC); row.appendChild(btnD); content.appendChild(row); wrap.appendChild(content); const loader = createProgressBar(); loader.id = 'gLoader'; loader.style.display = 'none'; wrap.appendChild(loader); const banner = document.createElement('div'); banner.id = 'gBanner'; banner.style.cssText = ` display:none; background:${COLORS.A}; color:#fff; font-weight:700; padding:10px; border-radius:6px; margin-bottom:8px; text-align:center; opacity:0; transition:opacity .4s; `; banner.textContent = '✓ Graded'; wrap.insertBefore(banner, content); const buttons = { A: btnA, B: btnB, C: btnC, D: btnD }; return { wrap, head, content, loader, banner, buttons }; } function showLoader(cat, on) { cat.content.style.display = on ? 'none' : ''; cat.loader.style.display = on ? '' : 'none'; on ? cat.loader._start() : cat.loader._stop(); } function flashSuccess(cat) { cat.banner.style.display = ''; cat.banner.style.opacity = '1'; cat.wrap.style.borderColor = COLORS.A; setTimeout(()=> cat.banner.style.opacity = '0', 1600); setTimeout(()=>{ cat.banner.style.display = 'none'; cat.wrap.style.borderColor = '#e7e7e7'; },2000); } function applyButtonStyles(buttons, current) { Object.entries(buttons).forEach(([letter,btn]) => styleButton(btn, COLORS[letter], letter === current) ); } function ensureButtonsReady(cat, currentGrade) { return new Promise(resolve => { let attempts = 0; function tick() { attempts++; const btns = cat && cat.buttons; const allExist = btns && btns.A && btns.B && btns.C && btns.D; if (allExist) { applyButtonStyles(btns, currentGrade); resolve(true); return; } if (attempts >= 8) { resolve(false); return; } setTimeout(tick, 80 + attempts * 30); } tick(); }); } function waitFor(selOrFn, timeout=6000) { return new Promise(res => { const t0 = Date.now(); (function loop() { try { const el = typeof selOrFn==='function' ? selOrFn() : document.querySelector(selOrFn); if (el) return res(el); if (Date.now() > timeout + t0) return res(null); } catch(e){} requestAnimationFrame(loop); })(); }); } function findButtonByTextOrAria(targetText) { const all = Array.from(document.querySelectorAll('button, [role="button"]')).filter(Boolean); for (const el of all) { try { const aName = el.getAttribute('aria' + String.fromCharCode(45) + 'label') || ''; if (aName.trim() === targetText) return el; } catch(e){} try { const txt = (el.textContent||'').trim(); if (txt === targetText) return el; } catch(e){} } return null; } function findSaveButtonOnce() { let found = null; found = findButtonByTextOrAria('Save') || findButtonByTextOrAria('Speichern'); if (found) return found; const all = Array.from(document.querySelectorAll('button, [role="button"]')); for (const b of all) { const txt = (b.textContent||'').trim(); if (txt === 'Save' || txt === 'Speichern') return b; } return null; } function waitForSaveButton(timeout=5000) { const t0 = Date.now(); return new Promise(res => { (function loop() { const b = findSaveButtonOnce(); if (b) return res(b); if (Date.now() > t0 + timeout) return res(null); setTimeout(loop, 120); })(); }); } function getActionMenuById() { return document.getElementById('action' + String.fromCharCode(45) + 'menu'); } async function openListAndSelect(labelToClick, tryTimeout=6000) { const saveBtn = await waitForSaveButton(3000); if (!saveBtn) return false; try { saveBtn.click(); } catch(e){ try { saveBtn.dispatchEvent(new MouseEvent('click', { bubbles:true })); } catch(e){} } const menu = await waitFor(getActionMenuById, tryTimeout); if (!menu) return false; const items = Array.from(menu.querySelectorAll('[role="menuitemradio"], [role="menuitem"], .MMWRwe, .fxNQSd')).filter(Boolean); for (const it of items) { const labelEl = it.querySelector('.mLuXec'); const label = (labelEl && labelEl.textContent) ? labelEl.textContent.trim() : (it.textContent||'').trim(); if (label && label.toUpperCase() === (labelToClick||'').toUpperCase()) { try { it.click(); } catch(e){ try { it.dispatchEvent(new MouseEvent('click', { bubbles:true })); } catch(e){} } return true; } } return false; } // Normalize and validate place name candidates function normalizeNameRaw(n) { if (!n || typeof n !== 'string') return ''; // replace NBSP and zero width spaces etc const cleaned = String(n).replace(/[\u00A0\u200B\uFEFF]/g, ' ').replace(/\s+/g, ' ').trim(); return cleaned; } function validatePlaceName(n) { const cleaned = normalizeNameRaw(n); if (!cleaned) return ''; if (cleaned === '0') return ''; if (/^\d+$/.test(cleaned)) return ''; if (cleaned.length < 3) return ''; return cleaned; } // central safe setter for catalog header function setCatalogHeader(c, candidate) { try { const v = validatePlaceName(candidate); if (v) { c.head.textContent = v; lastShownPlaceName = v; return true; } // candidate invalid: do not overwrite an existing valid header const existing = (c.head && c.head.textContent) ? normalizeNameRaw(c.head.textContent) : ''; if (validatePlaceName(existing)) { // keep existing valid header return false; } // no existing valid header: prefer lastShownPlaceName if valid if (validatePlaceName(lastShownPlaceName)) { c.head.textContent = lastShownPlaceName; return false; } // final fallback: stable placeholder c.head.textContent = '(place)'; return false; } catch (e) { try { c.head.textContent = '(place)'; } catch(e){} return false; } } // Helper to extract street + number from formatted address (first comma-separated segment) function extractStreetNumberFromFormatted(formatted) { if (!formatted || typeof formatted !== 'string') return ''; const seg = formatted.split(',')[0].trim(); // Very small sanitization: remove NBSP / zero-widths, collapse spaces const cleaned = seg.replace(/[\u00A0\u200B\uFEFF]/g,' ').replace(/\s+/g,' ').trim(); // If the cleaned segment is too short or looks like a postal code only, return empty if (!cleaned || cleaned === '0' || /^\d+$/.test(cleaned) || cleaned.length < 3) return ''; return cleaned; } async function autoUpdateGradeInMapsUI(prevGrade, newGrade) { function findButtonByTextOrAriaLocal(targetText) { const all = Array.from(document.querySelectorAll('button, [role="button"]')); for (const el of all) { try { const a = el.getAttribute('aria' + String.fromCharCode(45) + 'label') || ''; if (a.trim() === targetText) return el; } catch(e){} try { if ((el.textContent || '').trim() === targetText) return el; } catch(e){} } return null; } function getActionMenuLocal() { return document.getElementById('action' + String.fromCharCode(45) + 'menu'); } async function waitForMenuLocal(timeout = 4000) { const t0 = Date.now(); return new Promise(res => { (function loop() { const m = getActionMenuLocal(); if (m) return res(m); if (Date.now() > t0 + timeout) return res(null); setTimeout(loop, 80); })(); }); } async function selectLabelFromOpenMenu(label, menuTimeout = 3000) { const menu = await waitForMenuLocal(menuTimeout); if (!menu) return false; const items = Array.from(menu.querySelectorAll('[role="menuitemradio"], [role="menuitem"], .MMWRwe, .fxNQSd')); for (const it of items) { const labelEl = it.querySelector('.mLuXec'); const itemLabel = (labelEl && labelEl.textContent) ? labelEl.textContent.trim() : (it.textContent || '').trim(); if (itemLabel && itemLabel.toUpperCase() === (label||'').toUpperCase()) { try { it.click(); } catch(e){ try { it.dispatchEvent(new MouseEvent('click', { bubbles:true })); } catch(e){} } return true; } } return false; } let opener = findButtonByTextOrAriaLocal('Gespeichert') || findButtonByTextOrAriaLocal('Saved'); if (!opener) { opener = findButtonByTextOrAriaLocal('Save') || findButtonByTextOrAriaLocal('Speichern'); } if (!opener) return false; try { opener.click(); } catch(e){ try { opener.dispatchEvent(new MouseEvent('click', { bubbles:true })); } catch(e){} } await waitForMenuLocal(2500); if (prevGrade) { await selectLabelFromOpenMenu(prevGrade, 2500); } let menuAppeared = false; const MAX_ATTEMPTS = 12; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { const saveBtn = findButtonByTextOrAriaLocal('Save') || findButtonByTextOrAriaLocal('Speichern'); if (saveBtn) { try { saveBtn.click(); } catch(e){ try { saveBtn.dispatchEvent(new MouseEvent('click', { bubbles:true })); } catch(e){} } } else { try { opener.click(); } catch(e){ try { opener.dispatchEvent(new MouseEvent('click', { bubbles:true })); } catch(e){} } } const menu = await waitForMenuLocal(1800 + attempt * 120); if (menu) { menuAppeared = true; break; } await new Promise(r => setTimeout(r, 140 + attempt * 100)); } if (!menuAppeared) { const finalTry = await selectLabelFromOpenMenu(newGrade, 2000); return finalTry; } const picked = await selectLabelFromOpenMenu(newGrade, 3000); return picked; } function removeAllCatalogsImmediate() { const existing = Array.from(document.querySelectorAll('#graderBox')); existing.forEach(el=>{ try { el.remove(); } catch(e){} }); } function findInsertionTarget() { const mainPanel = document.querySelector('.m6QErb.WNBkOb.XiKgde[role="main"]') || null; let tabBar = null; if (mainPanel) tabBar = mainPanel.querySelector('[role="tablist"]'); if (!tabBar) tabBar = document.querySelector('[role="tablist"]'); const nameBlock = document.querySelector('.lMbq3e') || document.querySelector('h1.DUwDvf') || null; return { mainPanel, tabBar, nameBlock }; } async function doAddToSheet(rec) { const res = await postJSON(ADD_URL, { SheetID:SHEET_ID, Field:rec }); if (DEBUG) console.log('doAddToSheet result', res); return !!(res && res.ok); } async function doUpdateSheetRow(rowIdx, column, newValue) { const res = await postJSON(UPDATE_URL, { sheetId: SHEET_ID, row: String(rowIdx), column: column, newValue: newValue }); if (DEBUG) console.log('doUpdateSheetRow result', rowIdx, column, newValue, res); return !!(res && res.ok); } function extractPlaceNameFromDOM() { // selectors that typically hold the place title; normalize text before returning const selectors = [ 'h1.DUwDvf', '.DUwDvf.lfPIob', '.lMbq3e h1.DUwDvf', '.a5H0ec', '.DUwDvf' ]; for (const sel of selectors) { const el = document.querySelector(sel); if (el && el.textContent) { const raw = el.textContent; const cleaned = normalizeNameRaw(raw); if (!cleaned) continue; if (cleaned === '0' || /^\d+$/.test(cleaned)) continue; if (cleaned.length < 3) continue; return cleaned; } } const metaTitle = document.querySelector('meta[property="og:title"]')?.content; if (metaTitle) { const t = normalizeNameRaw(String(metaTitle)); if (t && t !== '0' && !/^\d+$/.test(t) && t.length >= 3) return t; } return ''; } // panel observer kept light to avoid heavy processing let panelObserver = null; function startPanelObserver() { stopPanelObserver(); const target = document.querySelector('body'); if (!target) return; panelObserver = new MutationObserver(muts => { if (panelObserver._timer) clearTimeout(panelObserver._timer); panelObserver._timer = setTimeout(() => { panelObserver._timer = null; recomputeAndMaybeHandle(); }, 160); }); try { // observe only childList and subtree to reduce characterData attr churn panelObserver.observe(target, { childList:true, subtree:true }); } catch(e){} } function stopPanelObserver() { if (panelObserver) { try { panelObserver.disconnect(); } catch(e){} if (panelObserver._timer) { clearTimeout(panelObserver._timer); panelObserver._timer = null; } panelObserver = null; } } function recomputeAndMaybeHandle() { try { const canonical = getCanonicalHref(); const domName = extractPlaceNameFromDOM(); const key = computePlaceKey(); const canonicalChanged = (canonical !== lastCanonicalHref); const keyChanged = (key !== lastPlaceKey); const domNameChanged = (domName && domName !== lastDomName && domName !== '(place)'); if (keyChanged || canonicalChanged || domNameChanged) { lastCanonicalHref = canonical; lastDomName = domName; lastPlaceKey = key; if (key.startsWith('cid:') || key.startsWith('data:') || key.startsWith('place:')) { try { handlePlaceNavigation(); } catch(e){} } else { removeAllCatalogsImmediate(); stopPersistenceWatcher(); } } } catch(e){} } // add this helper to catch SPA navigation reliably function watchUrlChanges() { // wrap pushState and replaceState so we notice programmatic navigations const _push = history.pushState; const _replace = history.replaceState; history.pushState = function() { const res = _push.apply(this, arguments); try { window.dispatchEvent(new Event('mapsUrlChange')); } catch(e){} return res; }; history.replaceState = function() { const res = _replace.apply(this, arguments); try { window.dispatchEvent(new Event('mapsUrlChange')); } catch(e){} return res; }; // on popstate or our synthetic event, react immediately window.addEventListener('popstate', () => { try { window.dispatchEvent(new Event('mapsUrlChange')); } catch(e){} }); // our custom event handler: always recompute key and force refresh window.addEventListener('mapsUrlChange', () => { try { const newKey = computePlaceKey(); // if key changed, or even if same key but URL changed, force handle if (newKey !== lastPlaceKey) { lastPlaceKey = newKey; handlePlaceNavigation().catch(()=>{}); } else { // sometimes the key is equal but content changed; still force refresh handlePlaceNavigation().catch(()=>{}); } } catch(e){} }); } async function insertCatalogSingle() { if (insertLock) return null; insertLock = true; try { ensureFont(); removeAllCatalogsImmediate(); const { mainPanel, tabBar, nameBlock } = findInsertionTarget(); const c = buildCatalog(); if (tabBar && tabBar.parentElement) { try { tabBar.parentElement.insertBefore(c.wrap, tabBar); } catch(e){ (mainPanel || document.body).appendChild(c.wrap); } } else if (nameBlock && nameBlock.parentElement) { try { nameBlock.parentElement.insertBefore(c.wrap, nameBlock.nextElementSibling); } catch(e){ (mainPanel || document.body).appendChild(c.wrap); } } else { (mainPanel || document.body).appendChild(c.wrap); } // Prefer the last shown stable name if available, else DOM, else placeholder const domName = extractPlaceNameFromDOM(); const initialCandidate = validatePlaceName(lastShownPlaceName) || validatePlaceName(domName) || ''; if (!initialCandidate) { // ensure we have a stable visible placeholder rather than an invalid value c.head.textContent = '(place)'; } else { setCatalogHeader(c, initialCandidate); } // set up a debounced observer to update the header only when there is a validated new name if (nameDomObserver) { try { nameDomObserver.disconnect(); } catch(e){} nameDomObserver = null; } // choose an element to observe that holds the place title const observedEl = document.querySelector('h1.DUwDvf') || document.querySelector('.lMbq3e') || document.body; if (observedEl) { nameDomObserver = new MutationObserver(() => { if (nameDomObserver._t) clearTimeout(nameDomObserver._t); nameDomObserver._t = setTimeout(() => { nameDomObserver._t = null; try { const freshRaw = extractPlaceNameFromDOM(); const fresh = validatePlaceName(freshRaw); // update only when we have a valid and genuinely different name if (fresh && fresh !== lastShownPlaceName) { setCatalogHeader(c, fresh); } } catch(e){} }, 180); }); try { nameDomObserver.observe(observedEl, { childList:true, subtree:true, characterData:true }); } catch(e){} } const cidNowForStyle = getCurrentCid(); const cachedRec = cidNowForStyle ? INDEX.map.get(cidNowForStyle) : null; // sanitize initial grade before applying const initialGrade = sanitizeGrade(getGradeFromRecord(cachedRec)); await ensureButtonsReady(c, initialGrade); Object.values(c.buttons).forEach(btn=>{ if (btn._mapsgraderBound) return; btn._mapsgraderBound = true; btn.addEventListener('click', async () => { debugger; const grade = btn.dataset.g; const cidNow = getCurrentCid(); if (DEBUG) console.log('grade button clicked', grade, 'cid', cidNow); if (!cidNow) return; showLoader(c, true); const existing = INDEX.map.get(cidNow); const prevGrade = getGradeFromRecord(existing); if (existing) { existing['Kategorie'] = grade; existing['Category (A / B / C / D)'] = grade; const pos = INDEX.pos.get(cidNow); if (typeof pos === 'number') { ALLPLACESJSON[pos] = existing; } else { ALLPLACESJSON.push(existing); INDEX.pos.set(cidNow, ALLPLACESJSON.length - 1); } const savedOk = saveLocalPlaces(ALLPLACESJSON); if (DEBUG) console.log('saved local places ok', savedOk, 'items', ALLPLACESJSON.length); showLoader(c, false); flashSuccess(c); applyButtonStyles(c.buttons, sanitizeGrade(grade)); setTimeout(()=> applyButtonStyles(c.buttons, sanitizeGrade(grade)), 80); try { await autoUpdateGradeInMapsUI(prevGrade, grade); } catch(e){} (async () => { try { const rowIdx = INDEX.pos.get(cidNow) + 2; await doUpdateSheetRow(rowIdx, String(SHEET_COLUMNS.kategorie), grade); const address = existing['Adresse']; const lat = existing['Lat'] || ''; const lng = existing['Lng'] || ''; if (address) { const parsed = parsePostalCodeAndCity(address); if (parsed.postalCode) { await doUpdateSheetRow(rowIdx, String(SHEET_COLUMNS.postleitzahl), parsed.postalCode); } if (parsed.city) { await doUpdateSheetRow(rowIdx, String(SHEET_COLUMNS.stadt), parsed.city); } const latLonKey = 'Lat' + String.fromCharCode(45) + 'Lon'; const latLonValue = (isFinite(lat) && isFinite(lng)) ? `${lat},${lng}` : ''; if (latLonValue) { await doUpdateSheetRow(rowIdx, String(SHEET_COLUMNS.latLon), latLonValue); } } } catch(e){} })(); return; } else { let details = null; try { details = await getPlaceDetails(cidNow); } catch(e){ details = null; } if (!details) { showLoader(c,false); return; } const recordNumber = ALLPLACESJSON.length + 1; const email = details.email && /\S+@\S+\.\S+/.test(details.email) ? details.email : `${recordNumber}@mail.de`; const loc = details.geometry?.location || {}; // NEW: only keep street + number as 'Adresse' // Extract postal code and city from full formatted address BEFORE truncating it const fullAddress = details.formatted_address || ''; const streetOnly = extractStreetNumberFromFormatted(fullAddress) || ''; const { postalCode, city } = parsePostalCodeAndCity(fullAddress); const rec = { 'Record Number': recordNumber, 'Email': email, 'Firma': details.name || extractPlaceNameFromDOM() || '', 'Adresse': streetOnly, 'Postleitzahl': postalCode || '', 'Stadt': city || '', 'Telefon': details.formatted_phone_number || '', 'Website': details.website || '', 'Maps Link': `https://www.google.com/maps?cid=${cidNow}`, 'Kategorie': grade || '', 'Google Maps Label': details.name || '', 'ID': cidNow, 'Managing Director': '', 'Lead Source': 'GMaps Leads', 'Deal stage': 'Rohlead' }; rec['Category (A / B / C / D)'] = rec['Kategorie']; if (isFinite(loc.lat)) rec['Lat'] = loc.lat; if (isFinite(loc.lng)) rec['Lng'] = loc.lng; // Lat/Lon/other enrichments enrichRecordWithPostalCityAndLatLon(rec, fullAddress, rec['Lat'], rec['Lng']); ALLPLACESJSON.push(rec); INDEX.map.set(cidNow, rec); INDEX.pos.set(cidNow, ALLPLACESJSON.length - 1); const savedOk = saveLocalPlaces(ALLPLACESJSON); if (DEBUG) console.log('saved local places ok', savedOk, 'items', ALLPLACESJSON.length); showLoader(c, false); flashSuccess(c); applyButtonStyles(c.buttons, sanitizeGrade(grade)); setTimeout(()=> applyButtonStyles(c.buttons, sanitizeGrade(grade)), 80); try { await autoUpdateGradeInMapsUI('', grade); } catch(e){} (async () => { try { await doAddToSheet(rec); const rowIdx = INDEX.pos.get(cidNow) + 2; const parsed = parsePostalCodeAndCity(rec['Adresse']); if (parsed.postalCode) { await doUpdateSheetRow(rowIdx, String(SHEET_COLUMNS.postleitzahl), parsed.postalCode); } if (parsed.city) { await doUpdateSheetRow(rowIdx, String(SHEET_COLUMNS.stadt), parsed.city); } const latLonKey = 'Lat' + String.fromCharCode(45) + 'Lon'; const latLonValue = (isFinite(rec['Lat']) && isFinite(rec['Lng'])) ? `${rec['Lat']},${rec['Lng']}` : ''; if (latLonValue) { await doUpdateSheetRow(rowIdx, String(SHEET_COLUMNS.latLon), latLonValue); } } catch(e){} })(); return; } }); }); try { if (c._observer) { try { c._observer.disconnect(); } catch(e){} c._observer = null; } const obs = new MutationObserver(() => { if (obs._t) clearTimeout(obs._t); obs._t = setTimeout(() => { obs._t = null; const cid = getCurrentCid(); const rec = cid ? INDEX.map.get(cid) : null; const g = sanitizeGrade(getGradeFromRecord(rec)); applyButtonStyles(c.buttons, g); }, 80); }); obs.observe(c.wrap, { childList:true, subtree:true }); c._observer = obs; } catch(e){} setTimeout(() => { const cid = getCurrentCid(); const rec = cid ? INDEX.map.get(cid) : null; const g = sanitizeGrade(getGradeFromRecord(rec)); applyButtonStyles(c.buttons, g); }, 120); return c; } finally { insertLock = false; } } // NEUE FUNKTION zur Prüfung auf gültigen Namen/Adresse function isPlaceDataValid(details, domNameFallback) { console.log(details, domNameFallback); if (!details) { // Wenn keine API-Details vorhanden sind, ist nur der DOM-Name der einzige Fallback. return validatePlaceName(domNameFallback) !== ''; } // Prüft API-Name und DOM-Fallback const apiName = validatePlaceName(details.name || ''); const chosenName = apiName || validatePlaceName(domNameFallback || ''); if (!chosenName) return false; // Prüft API-Adresse. Die Adresse muss mindestens 3 Zeichen haben und darf nicht '0' sein. // Wir nutzen hier die Logik, die in extractStreetNumberFromFormatted verwendet wird, // aber ohne nur den ersten Teil zu nehmen, da wir hier nur auf Existenz prüfen. const address = details.formatted_address; if (address) { const cleanedAddress = String(address).replace(/[\u00A0\u200B\uFEFF]/g, ' ').replace(/\s+/g, ' ').trim(); // 1. Leere oder '0'-Adressen blockieren (bestehende Logik) if (!cleanedAddress || cleanedAddress === '0') return false; // 2. NEUE PRÜFUNG: Überprüft auf das Vorhandensein einer gültigen 5-stelligen Postleitzahl // Adressen wie "Speicherstraße 77, 44147 Dortmund" bestehen diesen Test. // Adressen wie "Lindenhorst, 44 Dortmund-Eving" (ohne 5-stellige PLZ) schlagen hier fehl. const postalCodeRegex = /\b\d{4,5}\b/; if (!postalCodeRegex.test(cleanedAddress)) { if (DEBUG) console.log('Address rejected: Missing 5-digit postal code in:', cleanedAddress); return false; } } // Wenn Name gültig ist und Adresse entweder existiert oder nicht leer/null/0 ist, ist es gültig. return true; } function startPersistenceWatcher() { stopPersistenceWatcher(); const obs = new MutationObserver(() => { const nodes = Array.from(document.querySelectorAll('#graderBox')); if (nodes.length === 0) { setTimeout(async () => { const currentKey = computePlaceKey(); if (!document.querySelector('#graderBox') && (currentKey.startsWith('cid:') || currentKey.startsWith('place:') || currentKey.startsWith('data:'))) { try { const c = await insertCatalogSingle(); if (c) { const cid = getCurrentCid(); const localRec = INDEX.map.get(cid); if (localRec) { const nameCandidate = (localRec['Google Maps Label'] || localRec['Firma']) || extractPlaceNameFromDOM() || '(place)'; setCatalogHeader(c, nameCandidate); const grade = sanitizeGrade(getGradeFromRecord(localRec)); applyButtonStyles(c.buttons, grade); } else { const details = cid ? (await getPlaceDetails(cid, true)) : null; const chosenName = validatePlaceName(details?.name) || validatePlaceName(extractPlaceNameFromDOM()) || ''; if (chosenName) { setCatalogHeader(c, chosenName); } else { // keep stable placeholder setCatalogHeader(c, '(place)'); } const grade = sanitizeGrade(getGradeFromRecord(INDEX.map.get(cid))); applyButtonStyles(c.buttons, grade); } } } catch(e){} } }, 120); } else if (nodes.length > 1) { nodes.slice(1).forEach(n => { try { n.remove(); } catch(e){} }); } }); obs.observe(document.body, { childList:true, subtree:true }); persistenceObserver = obs; } function stopPersistenceWatcher() { if (persistenceObserver) { try { persistenceObserver.disconnect(); } catch(e){} persistenceObserver = null; } } async function handlePlaceNavigation() { const cid = getCurrentCid(); if (!cid) { stopPersistenceWatcher(); removeAllCatalogsImmediate(); lastShownPlaceName = ''; return; } // remove old UI then insert fresh catalog removeAllCatalogsImmediate(); const c = await insertCatalogSingle(); if (!c) return; // bump fetch id so any earlier API responses are ignored currentDetailsFetchId = (currentDetailsFetchId || 0) + 1; const myFetchId = currentDetailsFetchId; // read DOM early as fallback const domNameEarly = extractPlaceNameFromDOM(); // try to fetch fresh details from Maps API and force it let details = null; try { details = await getPlaceDetails(cid, true); } catch(e){ details = null; } if (!isPlaceDataValid(details, domNameEarly)) { if (DEBUG) console.log('Invalid place data (name/address invalid or missing), disabling grader box.'); removeAllCatalogsImmediate(); stopPersistenceWatcher(); stopPanelObserver(); return; // Hält die weitere Verarbeitung an } // if this response is stale, bail out from applying its data if (myFetchId !== currentDetailsFetchId) { // a newer navigation happened, so ignore this result return; } // prefer API name when valid, fallback to DOM const apiName = validatePlaceName(details?.name || ''); const domName = validatePlaceName(domNameEarly || ''); const chosen = apiName || domName || ''; // only update if we have a validated name if (chosen) { setCatalogHeader(c, chosen); } else { // no valid candidate, preserve previous stable header or placeholder setCatalogHeader(c, '(place)'); } // enrich local record if needed const rec = INDEX.map.get(cid); if (details && rec) { if (!rec['Adresse'] && details.formatted_address) rec['Adresse'] = details.formatted_address; if (!rec['Google Maps Label'] && details.name) rec['Google Maps Label'] = details.name; if (!rec['Lat'] && details.geometry?.location?.lat) rec['Lat'] = details.geometry.location.lat; if (!rec['Lng'] && details.geometry?.location?.lng) rec['Lng'] = details.geometry.location.lng; enrichRecordWithPostalCityAndLatLon(rec, rec['Adresse'], rec['Lat'], rec['Lng']); try { saveLocalPlaces(ALLPLACESJSON); } catch(e){} } lastCanonicalHref = getCanonicalHref(); lastPlaceKey = computePlaceKey(); const grade = sanitizeGrade(getGradeFromRecord(INDEX.map.get(cid))); applyButtonStyles(c.buttons, grade); startPersistenceWatcher(); startPanelObserver(); } let lastUrlSeen = location.href; function checkUrlButIgnoreLatLng() { const url = location.href; if (url === lastUrlSeen) return; lastUrlSeen = url; const key = computePlaceKey(); if (key === lastPlaceKey) return; lastPlaceKey = key; if (key.startsWith('cid:') || key.startsWith('data:') || key.startsWith('place:')) { handlePlaceNavigation(); } else { removeAllCatalogsImmediate(); stopPersistenceWatcher(); stopPanelObserver(); } } fetchSheet().finally(()=> { lastPlaceKey = computePlaceKey(); lastCanonicalHref = getCanonicalHref(); lastDomName = extractPlaceNameFromDOM(); if (lastPlaceKey.startsWith('cid:') || lastPlaceKey.startsWith('data:') || lastPlaceKey.startsWith('place:')) { handlePlaceNavigation(); } // reduced interval to lower CPU load setInterval(checkUrlButIgnoreLatLng, 800); // start robust SPA url watcher try { watchUrlChanges(); } catch(e){} }); document.addEventListener('click', e => { if (e.target.closest && e.target.closest('#graderBox')) { setTimeout(() => { const head = document.querySelector('#gHead'); if (head) { const candidate = validatePlaceName(extractPlaceNameFromDOM()) || ''; if (candidate) { setCatalogHeader({ head }, candidate); } else { // ensure we don't set a bad value setCatalogHeader({ head }, '(place)'); } } }, 120); } }); window.addEventListener('beforeunload', () => { stopPersistenceWatcher(); stopPanelObserver(); removeAllCatalogsImmediate(); if (nameDomObserver) try { nameDomObserver.disconnect(); } catch(e){} }); })();