// ─────────────────────────────────────────────────────────────────────────────
// 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 = '',
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
});
});
})();