diff --git a/apps/server/src/engine/price.ts b/apps/server/src/engine/price.ts new file mode 100644 index 000000000..9c888471d --- /dev/null +++ b/apps/server/src/engine/price.ts @@ -0,0 +1,10 @@ +// A match is rejected when the next character continues the numeric token +// (digit, a comma followed by a digit, a decimal dot followed by a digit) or +// attaches a magnitude/unit letter ($9M, $9k). A sentence comma, slash or +// other punctuation still ends the price. +const PRICE_PATTERN = /(?:\$|USD)\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)(?!\d|[A-Za-z]|,\d|\.\d)/g; + +export function matchesPrice(text: string, threshold: number): boolean { + const matches = [...text.matchAll(PRICE_PATTERN)]; + return matches.some((m) => Number(m[1].replace(/,/g, "")) < threshold); +} diff --git a/apps/server/src/engine/service.ts b/apps/server/src/engine/service.ts index 0e071ba87..8eb2ab446 100644 --- a/apps/server/src/engine/service.ts +++ b/apps/server/src/engine/service.ts @@ -42,6 +42,7 @@ import { pageLines, withoutRelativeTimes, } from "./page-diff.ts"; +import { matchesPrice } from "./price.ts"; import { LostLeaseError, type TaskContext, TaskWorker } from "./worker.ts"; const hash = (text: string) => createHash("sha256").update(text).digest("hex"); @@ -1094,7 +1095,7 @@ export class AgentService { ? Boolean(previousHash && previousHash !== currentHash) : monitor.condition === "contains" ? text.toLowerCase().includes(monitor.value.toLowerCase()) - : this.matchesPrice(text, Number(monitor.value)); + : matchesPrice(text, Number(monitor.value)); const previouslyMatched = Boolean(task.state.matched); // For change watches, keep the page lines and a relative-time-free fingerprint of the whole text. const change = monitor.condition === "change"; @@ -1192,8 +1193,4 @@ export class AgentService { plan: task.plan.map((s) => ({ ...s, status: "succeeded" })), }; } - private matchesPrice(text: string, threshold: number) { - const matches = [...text.matchAll(/(?:\$|USD)\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)/g)]; - return matches.some((m) => Number(m[1].replace(/,/g, "")) < threshold); - } } diff --git a/tests/monitor-price-token-boundaries.test.ts b/tests/monitor-price-token-boundaries.test.ts new file mode 100644 index 000000000..e6ae93ee1 --- /dev/null +++ b/tests/monitor-price-token-boundaries.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createApp } from "../apps/server/src/app.ts"; +import { matchesPrice } from "../apps/server/src/engine/price.ts"; +import type { AgentNotification } from "../packages/domain/src/agent.ts"; +import { browserFixture } from "./helpers/browser.ts"; + +const THRESHOLD = 10; + +for (const [text, expected, why] of [ + ["Price: $1,00", false, "truncated thousands group is not a price"], + ["Price: $1,0", false, "truncated thousands group is not a price"], + ["Price: $9.999", false, "a third decimal digit is not a price"], + ["Price: USD 1,00", false, "truncated thousands group is not a price"], + ["Price: $9M", false, "a magnitude suffix is not a bare price"], + ["Price: $9k", false, "a magnitude suffix is not a bare price"], + ["Price: $1,000,00", false, "a second thousands comma is not a price"], + ["Price: $1,000", false, "1000 is not under the threshold"], + ["Price: $1,000.00", false, "1000 is not under the threshold"], + ["Only $5,", true, "a sentence comma still ends the price"], + ["Price: $9, limited time", true, "a sentence comma still ends the price"], + ["$5, $20", true, "a comma-separated list keeps both prices"], + ["Now $9, was $20", true, "the lower price is still found"], + ["Price: $9.99/month", true, "a slash still ends the price"], + ["Price: $9", true, "a plain price under the threshold matches"], +] as const) { + test(`matchesPrice ${expected ? "matches" : "rejects"} ${JSON.stringify(text)} (${why})`, () => { + assert.equal(matchesPrice(text, THRESHOLD), expected); + }); +} + +test("price watch reaches the worker outcome path through matchesPrice", async (t) => { + const url = "https://example.com/product"; + const fixture = await browserFixture(t, (path, body) => ({ + data: path.endsWith("/read") + ? { url, title: "Product", text: "Only $5, today only", truncated: false } + : { + id: body.id, + title: "Product", + url, + status: "active", + updatedAt: new Date().toISOString(), + }, + })); + const server = await createApp(fixture.db, fixture.config); + t.after(() => server.agent.stop()); + const owner = "price-boundary-user"; + const monitor = await server.agent.createMonitor(owner, { + title: "Price watch", + url, + condition: "price_below", + value: "10", + }); + await server.agent.worker.tick(); + const task = await server.agent.getTask(owner, monitor.taskId); + assert.equal(task.status, "scheduled"); + assert.equal(task.state.matched, true); + const notifications = await fixture.db.list(owner, "notifications"); + assert.equal(notifications.filter((entry) => entry.taskId === task.id).length, 1); +});