// ───────────────────────────────────────────────────────────────────────────── // Knockoff content script (all DOM work lives here; logic is in detector.js) // // Runs on Amazon pages. Finds product tiles, asks the detector for a verdict // on each, then hides / dims / labels them per the user's settings. Also // badges the brand byline on product detail pages. // ───────────────────────────────────────────────────────────────────────────── (function () { "use strict"; // ── Server endpoints ──────────────────────────────────────────────────────── // This public snapshot is self-contained: out of the box it makes ZERO calls // to any Knockoff server and runs entirely on the bundled data files. The // three URLs below are the extension's only first-party network dependency, // and they ship blank. If you run your own backend, point them at it to // re-enable the optional networked features documented on each; leaving them // blank keeps everything local. // Community allowlist refresh: with a URL set, re-fetch the curated brand list // once a day so new brands don't require an extension update. Blank = the // bundled snapshot in data/community-brands.js is the whole list. var BRANDS_URL = ""; var BRANDS_REFRESH_MS = 24 * 60 * 60 * 1000; // Runtime config (DOM selectors + parsing tunables). With a URL set it's // refreshed daily so an Amazon layout fix can ship as a config push instead of // a release. Blank = the bundled data/config.js is authoritative. var CONFIG_URL = ""; var CONFIG = KO_DEFAULT_CONFIG; // One-click misclassification reports POST here. Blank = a report opens a // prefilled GitHub issue instead. var REPORT_ENDPOINT = ""; var REPO_URL = "https://github.com/Shpigford/knockoff"; var DEFAULTS = { enabled: true, action: "dim", // hide | dim | label level: "standard", // relaxed | standard | strict flagChineseMajor: false, // also flag established Chinese brands showKnownBadge: false, // show a ✓ badge on recognized brands too hideSponsored: false, // hide Amazon "Sponsored" search tiles (opt-in) allow: [], // user allowlist (display names) block: [], // user blocklist (display names) minRating: 0, // rating filter: 0 = off, else 3.0–5.0 minReviews: 0, // review filter: 0 = off, else min review count filterUnrated: false // also filter listings with no rating at all }; var settings = Object.assign({}, DEFAULTS); var userAllow = new Set(); var userBlock = new Set(); var searchAllow = new Set(); // normalized tokens from the current search query var stats = { scanned: 0, filtered: 0, byVerdict: {} }; var revealed = false; // session-only "show hidden items" toggle var introShown = true; // one-time first-catch toast; true until storage says otherwise // Lifetime tally shown in the popup. Deduped per ASIN per page load so // rescans (settings changes) don't double-count; drift across concurrent // tabs is fine; it's a running tally, not accounting. var countedKeys = new Set(); var lifetimePending = 0; var lifetimeTimer = null; function bumpLifetime(key) { if (!key || countedKeys.has(key)) return; countedKeys.add(key); lifetimePending++; if (lifetimeTimer) return; lifetimeTimer = setTimeout(function () { var add = lifetimePending; lifetimePending = 0; lifetimeTimer = null; chrome.storage.local.get({ lifetimeFiltered: 0 }).then(function (s) { chrome.storage.local.set({ lifetimeFiltered: s.lifetimeFiltered + add }); }); }, 800); } // Tile / title / brand-row selectors live in CONFIG (data/config.js), so a // layout fix can ship as a config push. First element matching any selector // in a priority list, in order (longest-lived layout last); null if none. function firstMatch(root, selectors) { for (var i = 0; i < selectors.length; i++) { var el = root.querySelector(selectors[i]); if (el) return el; } return null; } // Engraved-line SVG glyphs (24 viewBox, 2px round stroke). Static strings // authored here; never interpolate page content into these. var S = ''; var ICONS = { tag: S + '', tagSlash: S + '', alert: S + '', dashed: S + '', seal: S + '', shield: S + '', ban: S + '', x: S + '', flag: S + '', star: S + '' }; var VERDICT_META = { blocked: { icon: "tagSlash", label: "On your blocklist" }, flagged: { icon: "tagSlash", label: "Likely pseudo-brand" }, suspect: { icon: "alert", label: "Suspect brand" }, unbranded: { icon: "alert", label: "Unbranded" }, unknown: { icon: "dashed", label: "Unrecognized" }, known: { icon: "seal", label: "Established" }, allowed: { icon: "seal", label: "Trusted by you" }, lowrated: { icon: "star", label: "Low rating" } }; // ── Storage ──────────────────────────────────────────────────────────────── function loadSettings() { return chrome.storage.sync.get(DEFAULTS).then(function (stored) { settings = Object.assign({}, DEFAULTS, stored); userAllow = new Set(settings.allow.map(Knockoff.normalize)); userBlock = new Set(settings.block.map(Knockoff.normalize)); }); } function parseLines(text) { return text.split("\n").map(function (s) { return s.trim(); }).filter(Boolean); } // Fold an untrusted remote config over the bundled defaults, key by key, // keeping each remote value only when it validates and the default otherwise. // Every selector is syntax-checked (a bad selector throws in querySelector) // and every list/number is bounded, so a malformed or hostile /config push // can only ever fall back to data/config.js — it can't break the page, and // nothing here is code: selectors are strings the shipped querySelector runs. function isSelector(s) { if (typeof s !== "string" || !s || s.length > 200) return false; try { document.querySelector(s); return true; } catch (e) { return false; } } function selectorList(v, fallback) { if (!Array.isArray(v) || !v.length || v.length > 20) return fallback; var out = v.filter(isSelector); return out.length ? out : fallback; } // Plain-string lists (labels, seller IDs): bounded in count and length, // and optionally shape-checked. Same fail-to-default posture as selectors. function stringList(v, fallback, re) { if (!Array.isArray(v) || !v.length || v.length > 40) return fallback; var out = v.filter(function (s) { return typeof s === "string" && s && s.length <= 60 && (!re || re.test(s)); }); return out.length ? out : fallback; } function mergeConfig(remote) { var d = KO_DEFAULT_CONFIG; if (!remote || typeof remote !== "object") return d; var rs = remote.selectors || {}; var maxLen = (remote.limits || {}).brandRowMaxLen; return { selectors: { tiles: selectorList(rs.tiles, d.selectors.tiles), title: selectorList(rs.title, d.selectors.title), titleFallback: selectorList(rs.titleFallback, d.selectors.titleFallback), brandRow: selectorList(rs.brandRow, d.selectors.brandRow), mediaWork: isSelector(rs.mediaWork) ? rs.mediaWork : d.selectors.mediaWork }, limits: { brandRowMaxLen: (typeof maxLen === "number" && maxLen >= 5 && maxLen <= 200) ? maxLen : d.limits.brandRowMaxLen } }; } function loadCommunityList() { return chrome.storage.local.get( ["communityBrands", "remoteFlagged", "communityFetchedAt", "koConfig", "koConfigAt"] ).then(function (c) { // Apply the cached remote config (if any) before the first scan. CONFIG = mergeConfig(c.koConfig); var stale = !c.communityFetchedAt || Date.now() - c.communityFetchedAt > BRANDS_REFRESH_MS; if (stale && BRANDS_URL) { Promise.all([ fetch(BRANDS_URL).then(function (r) { return r.ok ? r.text() : Promise.reject(r.status); }), // curated blocklist additions; an empty *successful* response is a // valid state, but on an error keep the cached copy rather than // overwrite it with nothing until the next refresh. fetch(REPORT_ENDPOINT + "/flagged").then(function (r) { return r.ok ? r.text() : null; }) ]) .then(function (texts) { var brands = parseLines(texts[0]); var flagged = texts[1] === null ? (c.remoteFlagged || []) : parseLines(texts[1]); if (brands.length > 1000) { // sanity check before trusting the fetch chrome.storage.local.set({ communityBrands: brands, remoteFlagged: flagged, communityFetchedAt: Date.now() }); // pre-0.3 versions cached under these keys chrome.storage.local.remove(["abfList", "abfFetchedAt"]); Knockoff.buildIndexes(brands, flagged); rescan(); } }) .catch(function () { /* offline or rate-limited; bundled snapshot still works */ }); } // Config refresh is independent of the brand list (its own clock), so a // bad/absent config never blocks a brand refresh or vice versa. Stored raw // and validated on apply, so an install always has the bundled fallback. var cfgStale = !c.koConfigAt || Date.now() - c.koConfigAt > BRANDS_REFRESH_MS; if (cfgStale && CONFIG_URL) { fetch(CONFIG_URL).then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); }) .then(function (cfg) { chrome.storage.local.set({ koConfig: cfg, koConfigAt: Date.now() }); CONFIG = mergeConfig(cfg); rescan(); }) .catch(function () { /* offline or bad config; bundled selectors hold */ }); } return c; }); } function saveUserLists() { chrome.storage.sync.set({ allow: settings.allow, block: settings.block }); } // Add/remove a brand on a user list, dedped by normalized key. function setListMembership(list, brandName, member) { var key = Knockoff.normalize(brandName); var arr = settings[list].filter(function (b) { return Knockoff.normalize(b) !== key; }); if (member) arr.push(brandName); settings[list] = arr; if (list === "allow") userAllow = new Set(arr.map(Knockoff.normalize)); else userBlock = new Set(arr.map(Knockoff.normalize)); saveUserLists(); } // ── Tile processing ──────────────────────────────────────────────────────── // Localized "Sponsored ... –" prefix Amazon puts on sponsored-tile aria-labels. var SPONSORED_PREFIX = /^(Sponsored|Gesponsert|Sponsoris|Sponsorizzat|Patrocinad|Gesponsord|Sponsrad|Sponsorowan|Sponsorlu|スポンサー)[^–-]*[–-]\s*/i; function tileTitle(tile) { // textContent, not aria-label: sponsored tiles prefix their aria-label // with a localized "Sponsored Ad – ..." which would be read as the brand. // The title is the line inside the product link (its

carries // a-text-normal); the brand-byline layouts add a second, smaller

for // the brand ahead of it, so CONFIG.selectors.title is ordered to skip it. var h2 = firstMatch(tile, CONFIG.selectors.title); var text = h2 ? h2.textContent || h2.getAttribute("aria-label") || "" // p13n "faceout" tiles carry no h2: the title is a non-bold // .a-size-base-plus span (its bold siblings are the brand row and an // "In cart" status). The brand is embedded at the front of this title, // so classify() reads it from there like any other layout. : (firstMatch(tile, CONFIG.selectors.titleFallback) || {}).textContent || ""; return text.replace(SPONSORED_PREFIX, ""); } // Some layouts render the brand in its own row above the title. When that // row exists it is authoritative: Amazon has been stripping the brand out of // the title itself, so this is the only place the brand survives. Selectors // live in CONFIG so a layout change is a config push, not a release. function tileBrandRow(tile) { var el = tile.querySelector(CONFIG.selectors.brandRow.join(",")); var text = el && el.textContent ? el.textContent.trim() : ""; return text && text.length <= CONFIG.limits.brandRowMaxLen && !/\d{3,}/.test(text) ? text : ""; } // A book / music / movie tile carries Amazon's format-swatch links // (Paperback, Kindle Edition, Audiobook, Blu-ray, Prime Video, Audio CD...). // The label text is localized-ish, but the element's class pair is stable // across marketplaces, so we key off the element, not the word — physical // goods tiles never render it (verified 0/60 on a "screwdriver set" search). // This catches media works on an all-departments (search-alias=aps) search, // where the page-level department skip in scan() can't fire because there's // no book/music/movie alias to match. Skipping is the safe direction: a tile // we sit out is simply left unfiltered, never mislabeled. function tileIsMediaWork(tile) { return !!tile.querySelector(CONFIG.selectors.mediaWork); } // Get product rating. Prefer alt text as star icons increment by half stars function tileRating(tile) { var alt = tile.querySelector(".a-icon-alt"); var fromAlt = alt ? Knockoff.parseRating(alt.textContent) : null; if (fromAlt !== null) return fromAlt; var star = tile.querySelector('i[class*="a-star-"]'); var m = star && star.className.match(/a-star-(?:[a-z]+-)?(\d)(?:-(\d))?/); return m ? parseFloat(m[1] + (m[2] ? "." + m[2] : "")) : null; } // Get review count. Prefer the count link's aria-label / text (it carries the // exact number even when the visible text is abbreviated). querySelector on a // comma list returns the first match in DOM order, not the first selector, so // try the trustworthy link selectors first and only then the generic span — // and gate that span on count-shaped text so a stray number (price, rank, // "20% off") can't be misread as a review count. function tileReviews(tile) { var link = tile.querySelector( 'a[href*="customerReviews"], a[aria-label$="ratings"], a[aria-label$="rating"]' ); if (link) { var n = Knockoff.parseReviewCount(link.getAttribute("aria-label") || link.textContent || ""); if (n !== null) return n; } var span = tile.querySelector("span.a-size-base.s-underline-text"); var text = span ? span.textContent.trim() : ""; if (/^\(?\s*[\d.,]+\s*[kKmM]?\+?\s*\)?$/.test(text)) return Knockoff.parseReviewCount(text); return null; } // Rating verdict for products that pass the brand pipeline. // Carries the brand so the badge menu's Trust/Block still act on it. function ratingResult(rating, reviews, failures, brandResult) { var bits = failures.map(function (f) { if (f === "unrated") return "no ratings yet"; if (f === "rating") return "rated " + rating + ", below your " + settings.minRating + " minimum"; return "only " + reviews + " review" + (reviews === 1 ? "" : "s") + ", below your " + settings.minReviews + " minimum"; }); return { verdict: "lowrated", brand: brandResult.brand, key: brandResult.key, reason: bits.join("; ") }; } function processTile(tile) { if (tile.hasAttribute("data-ko-verdict")) return; // Books/music/movies on an all-departments search: the title is the work, // not a brand-led product name, so classification misfires ("The Canterbury // Tales" → unbranded). Mark it media and sit out, same as a media category. if (tileIsMediaWork(tile)) { tile.setAttribute("data-ko-verdict", "media"); return; } // A dedicated brand byline is authoritative — classify it as the brand // directly, so a real brand whose name opens with an ordinary word // ("Pet Junkie") isn't misread as unbranded once Amazon strips it from the // title. No byline row: read the brand from the front of the title. var brandRow = tileBrandRow(tile); var title = tileTitle(tile); if (!brandRow && !title.trim()) return; var result = brandRow ? Knockoff.classifyBrand(brandRow, settings, userAllow, userBlock, title) : Knockoff.classify(title, settings, userAllow, userBlock); var brandAct = Knockoff.shouldAct(result.verdict, settings.level); // A tile whose extracted brand is exactly a word the shopper searched for // is probably the category noun they asked for. Spare heuristic-only // catches, but keep explicit user/seed blocklists enforced. if (brandAct && result.key && searchAllow.has(result.key) && (result.verdict === "suspect" || result.verdict === "unknown")) { brandAct = false; } // The rating gate is independent of the brand verdict (spared tiles // included); only a user-allowlisted brand bypasses it. var rating = tileRating(tile); var reviews = tileReviews(tile); var ratingFails = result.verdict !== "allowed" ? Knockoff.ratingFailures(rating, reviews, settings) : []; var act = brandAct || ratingFails.length > 0; // Filtered for rating alone: badge as low-rated; otherwise keep the brand verdict. var displayResult = (act && !brandAct) ? ratingResult(rating, reviews, ratingFails, result) : result; tile.setAttribute("data-ko-verdict", displayResult.verdict); if (result.brand) tile.setAttribute("data-ko-brand", result.brand); stats.scanned++; stats.byVerdict[displayResult.verdict] = (stats.byVerdict[displayResult.verdict] || 0) + 1; if (act) { stats.filtered++; bumpLifetime(tile.getAttribute("data-asin") || result.key || title.slice(0, 40)); tile.classList.add("ko-act", "ko-" + settings.action); addBadge(tile, displayResult); } else if (settings.showKnownBadge || result.verdict === "allowed") { if (result.verdict === "known" || result.verdict === "allowed") { addBadge(tile, result); } } } function addBadge(tile, result) { if (tile.querySelector(".ko-badge")) return; var meta = VERDICT_META[result.verdict]; var badge = document.createElement("button"); badge.className = "ko-badge ko-v-" + result.verdict; badge.type = "button"; badge.innerHTML = ICONS[meta.icon]; // static markup; brand text goes in via textContent below var label = document.createElement("span"); label.textContent = result.brand || "No brand"; badge.appendChild(label); badge.title = "Knockoff: " + meta.label + " · " + result.reason + " (click for options)"; badge.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); toggleMenu(badge, tile, result); }); tile.style.position = "relative"; tile.appendChild(badge); } // Badge click menu: verdict header, reason, actions, report footer. function toggleMenu(badge, tile, result) { var existing = tile.querySelector(".ko-menu"); if (existing) { existing.remove(); return; } document.querySelectorAll(".ko-menu").forEach(function (m) { m.remove(); }); var meta = VERDICT_META[result.verdict]; var menu = el("div", "ko-menu"); // Search tiles anchor the menu at the tile's top-right, under the chip. // On product pages the chip sits inline mid-page inside a full-width // container, so anchor to the chip itself instead. if (badge.classList.contains("ko-pdp-badge")) { var left = Math.max(0, Math.min(badge.offsetLeft, tile.clientWidth - 244)); menu.style.left = left + "px"; menu.style.right = "auto"; menu.style.top = (badge.offsetTop + badge.offsetHeight + 6) + "px"; menu.style.transformOrigin = "top left"; } // Header: brand name with the verdict (dot + label) right-aligned var head = el("div", "ko-menu-head"); var brandRow = el("div", "ko-menu-brand"); var name = document.createElement("span"); name.textContent = result.brand || "This listing"; var verdictEl = el("span", "ko-menu-verdict ko-v-" + result.verdict); verdictEl.textContent = meta.label; brandRow.appendChild(name); brandRow.appendChild(verdictEl); head.appendChild(brandRow); menu.appendChild(head); var reason = el("div", "ko-menu-reason"); reason.textContent = sentence(result.reason); menu.appendChild(reason); menu.appendChild(el("div", "ko-menu-sep")); var group = el("div", "ko-menu-group"); if (result.brand) { var allowed = userAllow.has(result.key); var blocked = userBlock.has(result.key); group.appendChild(menuButton("shield", allowed ? "Stop trusting this brand" : "Trust this brand", function () { setListMembership("allow", result.brand, !allowed); if (!allowed) setListMembership("block", result.brand, false); } )); group.appendChild(menuButton("ban", blocked ? "Unblock this brand" : "Block this brand", function () { setListMembership("block", result.brand, !blocked); if (!blocked) setListMembership("allow", result.brand, false); } )); } // Clears the flag on this one item for the session: un-dims/un-hides it // and removes the chip, without touching the brand's standing. group.appendChild(menuButton("x", "Dismiss for this item", function () { tile.classList.remove("ko-act", "ko-hide", "ko-dim", "ko-label"); var chip = tile.querySelector(".ko-badge"); if (chip) chip.remove(); menu.remove(); })); menu.appendChild(group); // Report footer: name the brand and set the shared lists straight. Built // (and gated) by reportFoot — it also covers a filtered tile we read no // brand from, so a real brand we missed can still be named. var foot = reportFoot(tile, result); if (foot) { menu.appendChild(el("div", "ko-menu-sep")); menu.appendChild(foot); } tile.appendChild(menu); } function el(tag, className) { var node = document.createElement(tag); node.className = className; return node; } // First letter up, terminal period; detector reasons are fragments. function sentence(s) { if (!s) return ""; s = s.charAt(0).toUpperCase() + s.slice(1); return /[.!?]$/.test(s) ? s : s + "."; } // Misclassification reports keep the shared lists honest. `brand` is the name // the shopper confirmed — edited when we truncated ("Geometric" → "Geometric // Future") or missed it. With a report endpoint configured this is a // fire-and-forget POST; without one it opens a prefilled GitHub issue instead. function sendReport(brand, suggestion, verdict, asin, productTitle, reason) { if (!REPORT_ENDPOINT) { var title = (suggestion === "is_junk" ? "Junk brand: " : "Real brand: ") + brand; var body = "Brand: " + brand + "\nCurrent verdict: " + verdict + (asin ? "\nExample ASIN: " + asin : "") + "\nMarketplace: " + location.hostname; window.open(REPO_URL + "/issues/new?title=" + encodeURIComponent(title) + "&body=" + encodeURIComponent(body), "_blank"); return; } fetch(REPORT_ENDPOINT + "/report", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ brand: brand, suggestion: suggestion, verdict: verdict, asin: asin || null, marketplace: location.hostname, extVersion: chrome.runtime.getManifest().version, // Review context: what the product was and why it got that verdict. title: (productTitle || "").slice(0, 150) || null, reason: (reason || "").slice(0, 200) || null }) }).catch(function () { /* fire-and-forget */ }); } // Leading n words of a string — a brand prefill for tiles we read no brand from. function firstWords(s, n) { return (s || "").trim().split(/\s+/).slice(0, n).join(" "); } // Report footer: name the brand, then tell the shared lists we got it wrong. // Two steps, so the shopper can fix a truncated or missed brand before it // ships: the button reveals a prefilled, editable field; confirming sends it. // A filtered tile asserts "this is actually real" — and we trust the corrected // name locally so it stops being hidden right away, even on an unbranded tile // we never read a name from (the case issue #95 is about). An unfiltered // branded tile reports the reverse ("this is junk"). Rating-only filtering // isn't a brand call, so it gets no report path. function reportFoot(tile, result) { if (result.verdict === "lowrated") return null; var filtered = Knockoff.shouldAct(result.verdict, settings.level); if (!filtered && !result.brand) return null; var isReal = filtered; var suggestion = isReal ? "not_junk" : "is_junk"; var foot = el("div", "ko-menu-foot"); var form = el("div", "ko-report-form"); var input = document.createElement("input"); input.type = "text"; input.className = "ko-report-input"; input.maxLength = 64; input.placeholder = "Brand name"; input.value = result.brand || firstWords(tileTitle(tile), 2); // Keep typing/clicking in the field from bubbling to the tile link. input.addEventListener("click", function (e) { e.stopPropagation(); }); input.addEventListener("keydown", function (e) { e.stopPropagation(); if (e.key === "Enter") { e.preventDefault(); submit(); } }); var send = document.createElement("button"); send.type = "button"; send.className = "ko-menu-btn ko-report-send"; send.innerHTML = ICONS.seal; // static markup only send.title = "Send report"; send.setAttribute("aria-label", "Send report"); send.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); submit(); }); form.appendChild(input); form.appendChild(send); foot.appendChild(menuButton("flag", isReal ? "Report as a real brand" : "Report as junk", function () { foot.textContent = ""; foot.appendChild(form); input.focus(); input.select(); })); function submit() { var brand = input.value.trim(); if (!brand) { input.focus(); return; } sendReport(brand, suggestion, result.verdict, tile.getAttribute("data-asin"), tileTitle(tile), result.reason); // Naming a real brand also trusts it for you, so the tile stops being // hidden (storage change → rescan). Clear any stale block on the name. if (isReal) { setListMembership("block", brand, false); setListMembership("allow", brand, true); } foot.textContent = ""; var done = menuButton("seal", isReal ? "Reported — trusted for you" : "Reported. Thank you", function () {}); done.disabled = true; foot.appendChild(done); } return foot; } function menuButton(icon, text, onClick) { var b = document.createElement("button"); b.type = "button"; b.className = "ko-menu-btn"; b.innerHTML = ICONS[icon]; // static markup only; label goes in as text var labelWrap = el("span", "ko-menu-label"); labelWrap.textContent = text; b.appendChild(labelWrap); b.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); onClick(); }); return b; } // ── Filtered-count pill ──────────────────────────────────────────────────── // Floating pill so hidden results are never silently gone. function updatePill() { var pill = document.getElementById("ko-pill"); if (!settings.enabled || settings.action !== "hide" || stats.filtered === 0) { if (pill) pill.remove(); return; } if (!pill) { pill = document.createElement("button"); pill.id = "ko-pill"; pill.type = "button"; pill.title = "Filtered by Knockoff"; pill.addEventListener("click", function () { revealed = !revealed; document.documentElement.classList.toggle("ko-revealed", revealed); updatePill(); }); document.body.appendChild(pill); } // Only rewrite on change; our own MutationObserver watches the whole // body, and an unconditional write would re-trigger it forever. var state = stats.filtered + ":" + revealed; if (pill.getAttribute("data-ko-state") === state) return; var grew = stats.filtered > parseInt(pill.getAttribute("data-ko-count") || "0", 10); pill.setAttribute("data-ko-state", state); pill.setAttribute("data-ko-count", stats.filtered); pill.innerHTML = ICONS.tagSlash; // static markup; counts added as text nodes var count = document.createElement("b"); if (grew) count.className = "ko-tick"; // spring the number when it climbs count.textContent = stats.filtered; pill.appendChild(count); pill.appendChild(document.createTextNode(" filtered")); var action = document.createElement("i"); action.textContent = revealed ? "· Re-hide" : "· Show"; pill.appendChild(action); } // ── First-catch toast ────────────────────────────────────────────────────── // Once ever: the first time a page actually has something filtered, confirm // the extension is working and point at the toolbar button — the moment the // panel becomes relevant is the moment to teach it. function maybeShowIntroToast() { if (introShown || stats.filtered === 0) return; introShown = true; chrome.storage.local.set({ introShown: true }); var toast = el("div", ""); toast.id = "ko-intro"; toast.setAttribute("role", "status"); // announce once to screen readers var logo = el("span", "ko-intro-logo"); logo.innerHTML = PANEL_LOGO; // static markup only toast.appendChild(logo); var msg = document.createElement("span"); var count = document.createElement("b"); count.textContent = stats.filtered; msg.appendChild(document.createTextNode("Knockoff filtered ")); msg.appendChild(count); msg.appendChild(document.createTextNode( " listing" + (stats.filtered === 1 ? "" : "s") + " on this page. " + "The toolbar button opens the\u00a0panel.")); // nbsp: no widow word var ok = document.createElement("button"); ok.type = "button"; ok.textContent = "Got it"; ok.addEventListener("click", function () { toast.remove(); }); toast.appendChild(msg); toast.appendChild(ok); // Sit above the count pill when both are up (hide mode). if (document.getElementById("ko-pill")) toast.classList.add("ko-intro-raised"); document.body.appendChild(toast); } // ── Product detail page byline ───────────────────────────────────────────── function processProductPage() { processPdpByline(); processPdpSeller(); } function processPdpByline() { var byline = document.getElementById("bylineInfo"); if (!byline || document.querySelector(".ko-pdp-brand")) return; var brandName = KnockoffPdp.brandFromByline(byline, location.href); if (!brandName) return; // The byline text IS the brand, so classify it authoritatively (never // "unbranded") — same as the search-tile brand row. var result = Knockoff.classifyBrand(brandName, settings, userAllow, userBlock); // On the product page, always label, never hide the page out from under // the user, and include known/unknown verdicts for context. var meta = VERDICT_META[result.verdict]; if (!meta) return; // e.g. "foreign": a non-Latin byline we don't badge var badge = document.createElement("button"); badge.type = "button"; badge.className = "ko-badge ko-pdp-badge ko-pdp-brand ko-v-" + result.verdict; badge.innerHTML = ICONS[meta.icon]; // static markup; label added as text node var pdpLabel = document.createElement("span"); pdpLabel.textContent = meta.label; badge.appendChild(pdpLabel); badge.title = "Knockoff: " + result.reason + " (click for options)"; badge.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); toggleMenu(badge, byline.parentElement, result); }); byline.parentElement.style.position = "relative"; byline.insertAdjacentElement("afterend", badge); } // "Sold by" seller check. Warn-only: a chip appears when the seller name // reads like a pseudo-brand (or is listed/blocked); clean or merely // unrecognized sellers get nothing — a warning that fires on every // marketplace seller would just be noise. The chip is informational, not // a menu: user lists are brand-keyed, and quietly feeding seller names // into them from a click would muddy what those lists mean. var SELLER_META = { blocked: { icon: "tagSlash", label: "Seller on your blocklist" }, flagged: { icon: "tagSlash", label: "Likely junk seller" } }; function processPdpSeller() { // #sellerProfileTriggerId is Amazon's marketplace-global id for the // third-party "Sold by" link; absent when Amazon itself is the seller. var el = document.getElementById("sellerProfileTriggerId") || document.querySelector('#merchant-info a[href*="seller="]'); if (!el || document.querySelector(".ko-pdp-seller")) return; var name = (el.textContent || "").trim(); if (!name) return; var result = Knockoff.classifySeller(name, userAllow, userBlock); var meta = SELLER_META[result.verdict]; if (!meta) return; // known/unknown/allowed: stay quiet var badge = document.createElement("span"); badge.className = "ko-badge ko-pdp-badge ko-pdp-seller ko-v-" + result.verdict; badge.innerHTML = ICONS[meta.icon]; // static markup; label added as text node var label = document.createElement("span"); label.textContent = meta.label; badge.appendChild(label); badge.title = "Knockoff: " + sentence(result.reason); // The byline row under the title, beside the brand chip — all Knockoff // chrome on one line. The buy box's narrow Sold-by column (where the // seller link itself lives) can't fit a chip without wrecking its // label/value grid; the link stays as the fallback for bylineless pages. var anchor = document.querySelector(".ko-pdp-brand") || document.getElementById("bylineInfo") || el; anchor.insertAdjacentElement("afterend", badge); } // ── Control panel ────────────────────────────────────────────────────────── // Toggled by the toolbar button (via the background worker). Lives in the // page, next to the results it changes, so settings apply live as you flip // them, and the counts tick in place while you scroll. var PANEL_LOGO = ''; var LEVEL_HINTS = { relaxed: "Only notorious pseudo-brands and your blocklist.", standard: "Also filters suspect-looking names and unbranded listings.", strict: "Allowlist-only: anything unrecognized is filtered." }; function togglePanel() { if (document.getElementById("ko-panel")) closePanel(); else buildPanel(); } function closePanel() { var p = document.getElementById("ko-panel"); if (p) p.remove(); document.removeEventListener("mousedown", panelOutsideClick, true); document.removeEventListener("keydown", panelEscape, true); } function panelOutsideClick(e) { var p = document.getElementById("ko-panel"); if (p && !p.contains(e.target)) closePanel(); } function panelEscape(e) { if (e.key === "Escape") closePanel(); } function segControl(key, options) { var track = el("div", "ko-seg"); track.setAttribute("data-ko-seg", key); options.forEach(function (opt) { var b = document.createElement("button"); b.type = "button"; b.textContent = opt.label; b.setAttribute("data-v", opt.value); b.addEventListener("click", function () { var patch = {}; patch[key] = opt.value; chrome.storage.sync.set(patch); // onChanged re-applies + re-renders }); track.appendChild(b); }); return track; } function buildPanel() { var panel = el("div", ""); panel.id = "ko-panel"; // header: mark, name, master switch var head = el("div", "ko-panel-head"); var brand = el("div", "ko-panel-brand"); var logo = el("span", "ko-panel-logo"); logo.innerHTML = PANEL_LOGO; // static markup var name = el("span", "ko-panel-name"); name.textContent = "Knockoff"; brand.appendChild(logo); brand.appendChild(name); var sw = el("label", "ko-switch"); var swInput = document.createElement("input"); swInput.type = "checkbox"; swInput.id = "ko-panel-enabled"; swInput.addEventListener("change", function () { chrome.storage.sync.set({ enabled: swInput.checked }); }); sw.appendChild(swInput); sw.appendChild(el("span", "ko-switch-slider")); head.appendChild(brand); head.appendChild(sw); panel.appendChild(head); // stat row var statsRow = el("div", "ko-panel-stats"); var num = el("span", "ko-panel-num"); num.id = "ko-panel-num"; var copy = el("span", "ko-panel-statcopy"); var over = el("span", "ko-panel-overline"); over.textContent = "Filtered on this page"; var sub = el("span", "ko-panel-sub"); sub.id = "ko-panel-sub"; copy.appendChild(over); copy.appendChild(sub); statsRow.appendChild(num); statsRow.appendChild(copy); panel.appendChild(statsRow); // controls var card = el("div", "ko-panel-card"); var l1 = el("div", "ko-panel-label"); l1.textContent = "Flagged items are"; card.appendChild(l1); card.appendChild(segControl("action", [ { value: "hide", label: "Hidden" }, { value: "dim", label: "Dimmed" }, { value: "label", label: "Labeled" } ])); card.appendChild(el("div", "ko-panel-rule")); var l2 = el("div", "ko-panel-label"); l2.textContent = "Filter level"; card.appendChild(l2); card.appendChild(segControl("level", [ { value: "relaxed", label: "Relaxed" }, { value: "standard", label: "Standard" }, { value: "strict", label: "Strict" } ])); var hint = el("p", "ko-panel-hint"); hint.id = "ko-panel-hint"; card.appendChild(hint); // Hide-sponsored is orthogonal to the brand filter (it's a DOM property, // not a verdict), so it gets its own toggle rather than a segmented control. card.appendChild(el("div", "ko-panel-rule")); var spRow = el("label", "ko-panel-toggle"); var spText = el("span", "ko-panel-toggle-label"); spText.textContent = "Hide sponsored listings"; var spSwitch = el("span", "ko-switch"); var spInput = document.createElement("input"); spInput.type = "checkbox"; spInput.id = "ko-panel-sponsored"; spInput.addEventListener("change", function () { chrome.storage.sync.set({ hideSponsored: spInput.checked }); }); spSwitch.appendChild(spInput); spSwitch.appendChild(el("span", "ko-switch-slider")); spRow.appendChild(spText); spRow.appendChild(spSwitch); card.appendChild(spRow); // Rating & review cutoffs: coarse presets for honing results while // shopping. Exact thresholds live on the options page. Values are numeric // (0 = off) so they round-trip with the detector and the options controls. card.appendChild(el("div", "ko-panel-rule")); var l3 = el("div", "ko-panel-label"); l3.textContent = "Minimum rating"; card.appendChild(l3); card.appendChild(segControl("minRating", [ { value: 0, label: "Off" }, { value: 4, label: "4★" }, { value: 4.5, label: "4.5★" } ])); card.appendChild(el("div", "ko-panel-rule")); var l4 = el("div", "ko-panel-label"); l4.textContent = "Minimum reviews"; card.appendChild(l4); card.appendChild(segControl("minReviews", [ { value: 0, label: "Off" }, { value: 100, label: "100+" }, { value: 1000, label: "1K+" } ])); card.appendChild(el("div", "ko-panel-rule")); var unRow = el("label", "ko-panel-toggle"); var unText = el("span", "ko-panel-toggle-label"); unText.textContent = "Filter unrated listings"; var unSwitch = el("span", "ko-switch"); var unInput = document.createElement("input"); unInput.type = "checkbox"; unInput.id = "ko-panel-unrated"; unInput.addEventListener("change", function () { chrome.storage.sync.set({ filterUnrated: unInput.checked }); }); unSwitch.appendChild(unInput); unSwitch.appendChild(el("span", "ko-switch-slider")); unRow.appendChild(unText); unRow.appendChild(unSwitch); card.appendChild(unRow); panel.appendChild(card); // footer var foot = el("div", "ko-panel-foot"); var optLink = document.createElement("button"); optLink.type = "button"; optLink.className = "ko-panel-link"; optLink.textContent = "Brand lists & settings"; optLink.addEventListener("click", function () { chrome.runtime.sendMessage({ type: "ko-open-options" }); }); var version = el("span", "ko-panel-version"); version.textContent = "v" + chrome.runtime.getManifest().version; foot.appendChild(optLink); foot.appendChild(version); panel.appendChild(foot); document.body.appendChild(panel); document.addEventListener("mousedown", panelOutsideClick, true); document.addEventListener("keydown", panelEscape, true); updatePanelState(); } // Refresh the panel's numbers and control states from current settings, // called after every scan so the count ticks live while scrolling. function updatePanelState() { var panel = document.getElementById("ko-panel"); if (!panel) return; panel.classList.toggle("ko-panel-off", !settings.enabled); document.getElementById("ko-panel-enabled").checked = settings.enabled; document.getElementById("ko-panel-sponsored").checked = settings.hideSponsored; document.getElementById("ko-panel-unrated").checked = settings.filterUnrated; document.getElementById("ko-panel-num").textContent = stats.filtered; document.getElementById("ko-panel-hint").textContent = LEVEL_HINTS[settings.level]; panel.querySelectorAll("[data-ko-seg]").forEach(function (track) { var key = track.getAttribute("data-ko-seg"); track.querySelectorAll("button").forEach(function (b) { // data-v is a string; minRating/minReviews are numbers, so compare as // strings. A custom options-set value (e.g. 250) matches no preset and // simply leaves the segment unhighlighted. b.classList.toggle("ko-seg-active", b.getAttribute("data-v") === String(settings[key])); }); }); chrome.storage.local.get({ lifetimeFiltered: 0 }).then(function (s) { var sub = document.getElementById("ko-panel-sub"); if (!sub) return; sub.textContent = "of " + stats.scanned + " listings · " + s.lifetimeFiltered.toLocaleString() + " all-time"; }); } // ── Scanning ─────────────────────────────────────────────────────────────── // The page's department, as a search alias ("stripbooks", "tools", ...). // The search dropdown is the authoritative signal: it reflects the current // department on every path — i= searches, left-nav rh=n: refinements (whose // node IDs are marketplace-specific), and /b?node= browse pages. The URL i= // param is only a fallback for layouts without the dropdown. function pageSearchAlias() { var dd = document.getElementById("searchDropdownBox"); if (dd && dd.value) return dd.value.replace("search-alias=", ""); return new URLSearchParams(location.search).get("i") || ""; } // The current search query, as a Set of normalized tokens. A title whose // extracted brand IS a word the shopper searched for isn't a pseudo-brand — // it's the category noun ("headboard", "HEADBOARD") they asked for — so // processTile spares it. `k` is the modern query param; `field-keywords` is // the legacy fallback still used on some locales/paths. function pageSearchTokens() { var p = new URLSearchParams(location.search); var q = p.get("k") || p.get("field-keywords") || ""; var set = new Set(); q.trim().split(/\s+/).forEach(function (w) { var key = Knockoff.normalize(w); if (key) set.add(key); }); return set; } // Wipe all Knockoff marks from the page. Used before re-applying from // scratch, and when an in-page navigation lands on a media category where // previously-badged tiles must be released. function clearMarks() { stats = { scanned: 0, filtered: 0, byVerdict: {} }; document.querySelectorAll("[data-ko-verdict]").forEach(function (tile) { tile.removeAttribute("data-ko-verdict"); tile.removeAttribute("data-ko-brand"); tile.classList.remove("ko-act", "ko-hide", "ko-dim", "ko-label"); }); document.querySelectorAll(".ko-badge, .ko-menu, #ko-pill").forEach(function (el) { el.remove(); }); } function hasSearchState() { return stats.scanned || stats.filtered || document.querySelector("[data-ko-verdict], #ko-pill"); } function scan() { // Sponsored-hiding is a DOM property, not a brand verdict, so it's a plain // CSS toggle (see styles.css) that stays active even in media categories. document.documentElement.classList.toggle( "ko-hide-sponsored", settings.enabled && settings.hideSponsored); if (settings.enabled) { if (Knockoff.isMediaAlias(pageSearchAlias())) { // Books, music, movies...: titles are works, not brand-led product // names, so classification is skipped wholesale (see detector.js). // Clearing marks handles in-page flips into a media category; the // wipe re-triggers the observer once, then finds nothing and settles. if (hasSearchState()) clearMarks(); } else { searchAllow = pageSearchTokens(); document.querySelectorAll(CONFIG.selectors.tiles.join(",")).forEach(processTile); } // Product pages stay badged regardless: the dropdown can carry a stale // department onto a PDP, and book PDPs are inherently safe (their // byline is an author div the brand extractor returns nothing for). processProductPage(); } updatePill(); updatePanelState(); maybeShowIntroToast(); } // Wipe all Knockoff state from the page and re-apply from scratch. // Used when settings or lists change. function rescan() { clearMarks(); scan(); } var scanTimer = null; function scheduleScan() { if (scanTimer) return; scanTimer = setTimeout(function () { scanTimer = null; scan(); }, 150); } // ── Wiring ───────────────────────────────────────────────────────────────── chrome.storage.onChanged.addListener(function (changes, area) { // The options page's "Refresh now" (or another tab's daily refresh) // wrote a fresh community list; fold it in without waiting for a reload. // A fresh remote config and a fresh brand list can arrive in the SAME // storage write — the options "Refresh now" sets both keys at once — so // handle them together, not as mutually-exclusive branches. Apply the // config first (validated in mergeConfig; a bad push falls back to // defaults) so any rescan below runs with the new selectors. if (area === "local") { if (changes.koConfig) CONFIG = mergeConfig(changes.koConfig.newValue); if (changes.communityBrands || changes.remoteFlagged) { chrome.storage.local.get(["communityBrands", "remoteFlagged"]).then(function (c) { Knockoff.buildIndexes(c.communityBrands || null, c.remoteFlagged || null); rescan(); }); return; } if (changes.koConfig) { rescan(); return; } } if (area !== "sync") return; loadSettings().then(rescan); }); // Toolbar button (relayed by the background worker) toggles the panel. // Respond explicitly; a silent listener closes the port with lastError // set, which the background reads as "no content script here". chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) { if (msg && msg.type === "ko-toggle-panel") { togglePanel(); sendResponse({ ok: true }); } }); // Close any open badge menu on an outside click. document.addEventListener("mousedown", function (event) { if (event.target.closest && (event.target.closest(".ko-menu") || event.target.closest(".ko-badge"))) return; document.querySelectorAll(".ko-menu").forEach(function (menu) { menu.remove(); }); }, true); chrome.storage.local.get({ introShown: false }).then(function (s) { introShown = s.introShown; }); loadSettings().then(loadCommunityList) .then(function (cached) { Knockoff.buildIndexes(cached.communityBrands || null, cached.remoteFlagged || null); scan(); new MutationObserver(scheduleScan).observe(document.body, { childList: true, subtree: true }); }); })();