Files
autotrader-marker/content.js
T
tony eabab39210 Initial commit: AutoTrader Marker Firefox extension
Mark AutoTrader listings as 'not wanted' on both search results and detail
pages. Includes the unlisted signing / auto-update pipeline (web-ext +
dist/updates.json polled by Firefox).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 12:33:45 +00:00

288 lines
8.4 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) : "";
}
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) {
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() {
const m = location.pathname.match(/^\/car-details\/(\d+)/);
return m ? m[1] : 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();
applyPageMode();
watchUrlChanges();
})();