Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions extension/src/rules/__tests__/countdown-timer-redact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ describe("matchesTimerPattern", () => {
expect(matchesTimerPattern("15 minutes remaining")).toBe(true);
});

it("matches urgency suffix synonyms (#203 item 21)", () => {
expect(matchesTimerPattern("5 hours to claim")).toBe(true);
expect(matchesTimerPattern("45 minutes to save")).toBe(true);
});

it("matches expiry-lead phrasings (#203 item 21)", () => {
expect(matchesTimerPattern("Sale ends in 3h")).toBe(true);
expect(matchesTimerPattern("Offer expires in 45 minutes")).toBe(true);
expect(matchesTimerPattern("Closes in 2d")).toBe(true);
expect(matchesTimerPattern("Ends in 30 seconds")).toBe(true);
});

it("does not match arbitrary numbers", () => {
expect(matchesTimerPattern("Item #123456")).toBe(false);
expect(matchesTimerPattern("Save 30%")).toBe(false);
Expand Down Expand Up @@ -125,6 +137,31 @@ describe("countdownTimerRedactRule", () => {
expect(document.querySelector(`.${PLACEHOLDER_CLASS}`)).not.toBeNull();
});

it("hides an expiry-lead countdown that decreased (#203 item 21)", () => {
document.body.innerHTML = `<div id="t">Sale ends in 2h 15m</div>`;
countdownTimerRedactRule.apply(document.body);

const timer = document.querySelector("#t");
if (timer) {
timer.textContent = "Sale ends in 2h 14m";
}

jest.advanceTimersByTime(SNAPSHOT_DELAY_MS);

expect(document.querySelector("#t")).toBeNull();
expect(document.querySelector(`.${PLACEHOLDER_CLASS}`)).not.toBeNull();
});

it("leaves a static expiry-lead badge alone (decrement gate)", () => {
document.body.innerHTML = `<div id="t">Expires in 30 days</div>`;
countdownTimerRedactRule.apply(document.body);

jest.advanceTimersByTime(SNAPSHOT_DELAY_MS);

expect(document.querySelector("#t")).not.toBeNull();
expect(document.querySelector(`.${PLACEHOLDER_CLASS}`)).toBeNull();
});

it("leaves static clock-shaped text alone", () => {
document.body.innerHTML = `<span>Posted at 12:34</span>`;
countdownTimerRedactRule.apply(document.body);
Expand Down
20 changes: 20 additions & 0 deletions extension/src/rules/__tests__/scarcity-redact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ describe("matchesScarcityPattern", () => {
"8 purchased in the past day",
"5 have it in their cart",
"3 in carts",
// Synonym evasion bypasses (#203 item 21).
"Just 3 left",
"Just 2 in stock",
"3 items left",
"5 units remaining",
"2 pieces left",
"While supplies last",
"While stocks last",
"Selling quickly",
"Going quickly",
"Flying off the shelves",
"Going off shelves",
"12 added to cart in the last hour",
"8 shoppers added this to their bag",
"5 others added to wishlist",
])("matches urgency phrasing: %s", (text) => {
expect(matchesScarcityPattern(text)).toBe(true);
});
Expand All @@ -67,6 +82,11 @@ describe("matchesScarcityPattern", () => {
"Ships in 2 days",
"Save 30%",
"Free shipping",
// "Just" alone is not an urgency signal.
"Just released",
"Just for you",
// Bare "added to cart" without a count is a UI confirmation, not scarcity.
"Added to cart",
])("leaves non-urgency text alone: %s", (text) => {
expect(matchesScarcityPattern(text)).toBe(false);
});
Expand Down
11 changes: 9 additions & 2 deletions extension/src/rules/countdown-timer-redact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,20 @@ const COLON_PATTERN = /(?<!\d)\d{1,3}:[0-5]\d(?::[0-5]\d)?(?!\d)/;
const MULTI_UNIT_PATTERN =
/\b\d+\s*(?:d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:utes?)?)?|s(?:ec(?:onds?)?)?)\b[\s,:]*\b\d+\s*(?:d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:utes?)?)?|s(?:ec(?:onds?)?)?)\b/i;
const URGENCY_UNIT_PATTERN =
/\b\d+\s*(?:hours?|hrs?|minutes?|mins?|seconds?|secs?)\s+(?:left|remaining|to\s+go|until)\b/i;
/\b\d+\s*(?:hours?|hrs?|minutes?|mins?|seconds?|secs?)\s+(?:left|remaining|to\s+go|to\s+claim|to\s+save|until)\b/i;
// "Sale ends in 3h", "Offer expires in 45 minutes", "Closes in 2d" — common
// countdown lead-ins where the urgency word precedes the value. Candidate
// only; redaction still requires the decrement check in reconcileCandidates,
// so a static "Expires in 30 days" badge is never replaced.
const EXPIRY_LEAD_PATTERN =
/\b(?:ends?|expires?|closes?)\s+in\s+\d+\s*(?:d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:utes?)?)?|s(?:ec(?:onds?)?)?)\b/i;

export function matchesTimerPattern(text: string): boolean {
return (
COLON_PATTERN.test(text) ||
MULTI_UNIT_PATTERN.test(text) ||
URGENCY_UNIT_PATTERN.test(text)
URGENCY_UNIT_PATTERN.test(text) ||
EXPIRY_LEAD_PATTERN.test(text)
);
}

Expand Down
15 changes: 12 additions & 3 deletions extension/src/rules/scarcity-redact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,24 +28,33 @@ const MAX_CANDIDATE_LENGTH = 80;
const MAX_CANDIDATE_DESCENDANTS = 20;

const SCARCITY_PATTERNS: RegExp[] = [
/\bonly\s+\d+\s+(?:left|remaining|in\s+stock|available)\b/i,
/\b(?:only|just)\s+\d+\s+(?:left|remaining|in\s+stock|available)\b/i,
/\b\d+\s+(?:left|remaining)\s+in\s+stock\b/i,
/\b\d+\s+(?:items?|units?|pieces?)\s+(?:left|remaining)\b/i,
/\b(?:low|limited)\s+(?:stock|inventory|availability|quantit(?:y|ies)|supply)\b/i,
/\bstock(?:\s+is)?\s+running\s+low\b/i,
/\b\d+\s+(?:in\s+stock|available)\b/i,
// "While supplies last" / "while stocks last" — fixed phrase, no FP risk.
/\bwhile\s+(?:supplies|stocks?)\s+last\b/i,
];

const DEMAND_PATTERNS: RegExp[] = [
/\b(?:almost|nearly)\s+(?:gone|out|sold(?:\s+out)?)\b/i,
/\bselling\s+(?:fast|out)\b/i,
/\bgoing\s+fast\b/i,
/\bselling\s+(?:fast|out|quickly)\b/i,
/\bgoing\s+(?:fast|quickly)\b/i,
/\bhigh\s+demand\b/i,
// "Flying off the shelves" / "going off shelves" — retail-only idiom; 80-char
// leaf-candidate gate makes prose mentions (e.g. news copy) unreachable.
/\b(?:flying|going)\s+off\s+(?:the\s+)?shelves\b/i,
];

const ACTIVITY_PATTERNS: RegExp[] = [
/\b\d+\s+(?:people|users|shoppers|customers|others?)\s+(?:are\s+)?(?:viewing|looking|watching)\b/i,
/\b\d+\s+(?:sold|bought|purchased)\s+(?:in\s+the\s+)?(?:last|past)\s+\w+/i,
/\b\d+\s+(?:in|have\s+(?:this\s+|it\s+)?in)\s+(?:their\s+)?carts?\b/i,
// "12 added to cart in the last hour" / "8 added to bag" — covers the
// verb-swap from "viewing/watching" to "added".
/\b\d+\s+(?:people\s+|shoppers\s+|others\s+)?added\s+(?:this\s+)?to\s+(?:their\s+)?(?:carts?|baskets?|bags?|wishlists?)\b/i,
];

const ALL_PATTERNS: RegExp[] = [
Expand Down
Loading