ed92298b79
- Mirror dismissals to window.localStorage on autotrader.co.uk ("atm:..."
key). If browser.storage.local comes up empty after a quirky reinstall,
the content script rehydrates from the site backup on the next page load.
- Replace the SVG toolbar icon with PNG renders (16/32/48/96/128) — some
Firefox installs don't render extension SVG icons reliably in the
toolbar / extensions flyout.
- Sharp added as a devDependency to rebuild icons from icon.svg.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
380 lines
12 KiB
JavaScript
380 lines
12 KiB
JavaScript
// 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: <li data-testid="id-{listingId}">
|
|
// Title: <a data-testid="search-listing-title">
|
|
// Detail link: <a href="/car-details/{listingId}?...">
|
|
// 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();
|
|
})();
|