// AutoTrader Marker — content script // Attaches a "Not wanted" button to each listing card and stores dismissals in browser.storage.local. // // DOM contract (verified against autotrader.co.uk 2026-04): // Listing card:
  • // Title: // Detail link: // The `data-testid="id-*"` attribute carries the listing id directly, which is // more robust than parsing anchor hrefs. Anchor-walk fallback kept for edge // cases (featured/sponsored slots that render differently). const api = typeof browser !== "undefined" ? browser : chrome; const STORAGE_KEY = "dismissedListings"; const SETTINGS_KEY = "settings"; const state = { dismissed: {}, hideDismissed: false, }; // --- storage --- // // Primary: browser.storage.local (per-extension, syncs across tabs via // storage.onChanged). Secondary: window.localStorage scoped to // autotrader.co.uk — survives extension reinstall, because it's the site's // storage, not the extension's. On load, if extension storage is empty but // the site backup has entries, we re-hydrate. On every write we mirror to the // site backup so the two stay in lockstep. const BACKUP_KEY = "atm:dismissedListings:v1"; function readSiteBackup() { try { const raw = window.localStorage.getItem(BACKUP_KEY); return raw ? JSON.parse(raw) : null; } catch { return null; } } function writeSiteBackup(dismissed) { try { window.localStorage.setItem(BACKUP_KEY, JSON.stringify(dismissed)); } catch { // localStorage full/disabled — nothing to do } } async function loadState() { const res = await api.storage.local.get([STORAGE_KEY, SETTINGS_KEY]); let dismissed = res[STORAGE_KEY] || {}; // Re-hydrate from site backup if extension storage lost its entries // (e.g. after an unlisted reinstall that didn't upgrade in place). const backup = readSiteBackup(); if (backup && Object.keys(dismissed).length === 0 && Object.keys(backup).length > 0) { dismissed = backup; await api.storage.local.set({ [STORAGE_KEY]: dismissed }); console.log("[ATM] restored", Object.keys(dismissed).length, "dismissals from site backup"); } else if (Object.keys(dismissed).length > 0) { // Keep site backup fresh even if we didn't need it this load writeSiteBackup(dismissed); } state.dismissed = dismissed; state.hideDismissed = !!(res[SETTINGS_KEY] && res[SETTINGS_KEY].hideDismissed); applyHideMode(); } async function persistDismissed() { await api.storage.local.set({ [STORAGE_KEY]: state.dismissed }); writeSiteBackup(state.dismissed); } async function setDismissed(id, title) { state.dismissed[id] = { ts: Date.now(), title: title || "" }; await persistDismissed(); } async function clearDismissed(id) { delete state.dismissed[id]; await persistDismissed(); } function applyHideMode() { document.documentElement.classList.toggle("atm-hide-dismissed", state.hideDismissed); } // --- listing detection --- function extractIdFromLi(li) { // Primary: data-testid="id-202601279443591" const testid = li.getAttribute("data-testid") || ""; const m = testid.match(/^id-(\d+)$/); if (m) return m[1]; // Secondary: the li's own id attribute uses same pattern const liId = li.id || ""; const m2 = liId.match(/^id-(\d+)$/); if (m2) return m2[1]; // Last resort: derive from a /car-details/ anchor inside const a = li.querySelector('a[href*="/car-details/"]'); if (a) { const m3 = a.getAttribute("href").match(/\/car-details\/(\d+)/); if (m3) return m3[1]; } return null; } function extractTitle(card) { const t = card.querySelector('[data-testid="search-listing-title"]'); if (t) return t.textContent.trim().slice(0, 200); const h = card.querySelector("h2, h3"); return h ? h.textContent.trim().slice(0, 200) : ""; } // A card is "sponsored/featured" if its detail link carries // journey=FEATURED_LISTING_JOURNEY (AutoTrader's own tagging) or it lives // inside an explicitly sponsored container. function isSponsoredCard(card) { const anchors = card.querySelectorAll('a[href*="/car-details/"]'); for (const a of anchors) { const href = a.getAttribute("href") || ""; if (/journey=FEATURED_LISTING_JOURNEY/i.test(href)) return true; } const testid = (card.getAttribute("data-testid") || "").toLowerCase(); if (testid.includes("featured") || testid.includes("sponsor") || testid.includes("promoted")) { return true; } // Walk up one level to catch wrapper markers const parent = card.parentElement; if (parent) { const ptid = (parent.getAttribute("data-testid") || "").toLowerCase(); if (ptid.includes("featured") || ptid.includes("sponsor") || ptid.includes("promoted")) { return true; } } return false; } function findAllCards() { // Primary selector — verified stable on autotrader.co.uk const lis = document.querySelectorAll('li[data-testid^="id-"]'); const cards = new Map(); for (const li of lis) { if (isSponsoredCard(li)) { li.classList.add("atm-sponsored"); continue; } const id = extractIdFromLi(li); if (id) cards.set(li, id); } return cards; } // --- UI injection --- function decorateCard(card, id) { if (card.dataset.atmId === id && card.querySelector(":scope > .atm-btn")) return; card.dataset.atmId = id; card.classList.add("atm-card"); // Remove stale button const existing = card.querySelector(":scope > .atm-btn"); if (existing) existing.remove(); const btn = document.createElement("button"); btn.type = "button"; btn.className = "atm-btn"; btn.addEventListener("click", async (e) => { e.preventDefault(); e.stopPropagation(); if (state.dismissed[id]) { await clearDismissed(id); } else { await setDismissed(id, extractTitle(card)); } refreshCardState(card, id); }); card.appendChild(btn); refreshCardState(card, id); } function refreshCardState(card, id) { const dismissed = !!state.dismissed[id]; card.classList.toggle("atm-dismissed", dismissed); const btn = card.querySelector(":scope > .atm-btn"); if (btn) { btn.textContent = dismissed ? "✓ Dismissed — undo" : "✗ Not wanted"; btn.setAttribute("aria-pressed", dismissed ? "true" : "false"); } } function decorateAll() { const cards = findAllCards(); for (const [card, id] of cards) { decorateCard(card, id); } // Also refresh any previously-decorated cards whose state may have changed document.querySelectorAll(".atm-card[data-atm-id]").forEach((card) => { if (!cards.has(card)) refreshCardState(card, card.dataset.atmId); }); } // --- observers --- let rafPending = false; function scheduleDecorate() { if (rafPending) return; rafPending = true; requestAnimationFrame(() => { rafPending = false; decorateAll(); }); } const mo = new MutationObserver(() => scheduleDecorate()); function startObserving() { mo.observe(document.body, { childList: true, subtree: true }); } // --- detail page --- // URL pattern: /car-details/{digits}. A fixed FAB + optional top banner // doesn't depend on the page layout, so it's resilient to redesigns. function getDetailListingId() { // Accept /car-details/{id}, /classifieds/advert/{id}, trailing slashes, slugs const patterns = [ /^\/car-details\/(\d+)/i, /^\/classifieds\/advert\/(\d+)/i, /\/cars?\/[^/]+\/(\d{10,})/i, ]; for (const re of patterns) { const m = location.pathname.match(re); if (m) return m[1]; } return null; } function extractDetailTitle() { const h1 = document.querySelector("h1"); if (h1 && h1.textContent.trim()) return h1.textContent.trim().slice(0, 200); return (document.title || "").replace(/\s*\|\s*AutoTrader.*$/i, "").trim().slice(0, 200); } function ensureDetailUI(id) { let fab = document.getElementById("atm-fab"); if (!fab) { fab = document.createElement("button"); fab.id = "atm-fab"; fab.type = "button"; fab.className = "atm-fab"; fab.addEventListener("click", async (e) => { e.preventDefault(); if (state.dismissed[id]) { await clearDismissed(id); } else { await setDismissed(id, extractDetailTitle()); } refreshDetailUI(id); }); document.body.appendChild(fab); } let banner = document.getElementById("atm-banner"); if (!banner) { banner = document.createElement("div"); banner.id = "atm-banner"; banner.className = "atm-banner"; const label = document.createElement("span"); label.className = "atm-banner-label"; const undo = document.createElement("button"); undo.type = "button"; undo.className = "atm-banner-undo"; undo.textContent = "Undo"; undo.addEventListener("click", async () => { await clearDismissed(id); refreshDetailUI(id); }); banner.appendChild(label); banner.appendChild(undo); document.body.appendChild(banner); } refreshDetailUI(id); } function refreshDetailUI(id) { const dismissed = !!state.dismissed[id]; const fab = document.getElementById("atm-fab"); if (fab) { fab.textContent = dismissed ? "✓ Dismissed — undo" : "✗ Not wanted"; fab.setAttribute("aria-pressed", dismissed ? "true" : "false"); fab.classList.toggle("atm-fab-dismissed", dismissed); } const banner = document.getElementById("atm-banner"); if (banner) { banner.classList.toggle("atm-banner-visible", dismissed); const label = banner.querySelector(".atm-banner-label"); if (label) label.textContent = "You marked this car as not wanted"; } } // --- React to storage changes (popup / other tabs) --- api.storage.onChanged.addListener((changes, area) => { if (area !== "local") return; if (changes[STORAGE_KEY]) { state.dismissed = changes[STORAGE_KEY].newValue || {}; writeSiteBackup(state.dismissed); // keep site backup in sync with popup-driven changes document.querySelectorAll(".atm-card[data-atm-id]").forEach((card) => { refreshCardState(card, card.dataset.atmId); }); const detailId = getDetailListingId(); if (detailId) refreshDetailUI(detailId); } if (changes[SETTINGS_KEY]) { state.hideDismissed = !!(changes[SETTINGS_KEY].newValue && changes[SETTINGS_KEY].newValue.hideDismissed); applyHideMode(); } }); // --- bootstrap + SPA navigation --- function removeDetailUI() { document.getElementById("atm-fab")?.remove(); document.getElementById("atm-banner")?.remove(); } let currentDetailId = null; function applyPageMode() { const detailId = getDetailListingId(); if (detailId) { currentDetailId = detailId; ensureDetailUI(detailId); } else { if (currentDetailId) { removeDetailUI(); currentDetailId = null; } decorateAll(); startObserving(); } } function watchUrlChanges() { let lastUrl = location.href; const check = () => { if (location.href !== lastUrl) { lastUrl = location.href; applyPageMode(); } }; // Cover both history API and back/forward const wrap = (fn) => function () { const ret = fn.apply(this, arguments); queueMicrotask(check); return ret; }; history.pushState = wrap(history.pushState); history.replaceState = wrap(history.replaceState); window.addEventListener("popstate", check); } (async function init() { await loadState(); const detailId = getDetailListingId(); // Diagnostic log — open Firefox DevTools console to confirm detection. // Prefixed so you can filter with "ATM" in the console. console.log("[ATM]", { pathname: location.pathname, href: location.href, detectedDetailId: detailId, dismissedCount: Object.keys(state.dismissed).length, }); applyPageMode(); watchUrlChanges(); })();