066ec44584
- Hide any card whose anchor carries `journey=FEATURED_LISTING_JOURNEY`
or whose container is tagged featured/sponsor/promoted.
- Detail-page URL regex now also matches `/classifieds/advert/{id}` and
`/cars/{slug}/{id}` in case AutoTrader redirects through either.
- Added a `[ATM]` console.log at init so unresolved detection issues can
be diagnosed from Firefox DevTools.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
334 lines
9.9 KiB
JavaScript
334 lines
9.9 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 ---
|
|
|
|
async function loadState() {
|
|
const res = await api.storage.local.get([STORAGE_KEY, SETTINGS_KEY]);
|
|
state.dismissed = res[STORAGE_KEY] || {};
|
|
state.hideDismissed = !!(res[SETTINGS_KEY] && res[SETTINGS_KEY].hideDismissed);
|
|
applyHideMode();
|
|
}
|
|
|
|
async function setDismissed(id, title) {
|
|
state.dismissed[id] = { ts: Date.now(), title: title || "" };
|
|
await api.storage.local.set({ [STORAGE_KEY]: state.dismissed });
|
|
}
|
|
|
|
async function clearDismissed(id) {
|
|
delete state.dismissed[id];
|
|
await api.storage.local.set({ [STORAGE_KEY]: state.dismissed });
|
|
}
|
|
|
|
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 || {};
|
|
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();
|
|
})();
|