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>
This commit is contained in:
+287
@@ -0,0 +1,287 @@
|
||||
// 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();
|
||||
})();
|
||||
Reference in New Issue
Block a user