diff --git a/apps/cli/package.json b/apps/cli/package.json
index c96a8a4..b9d4f32 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@c0upons/cli",
- "version": "1.9.3",
+ "version": "1.10.0",
"description": "c0upons CLI — search, submit, and manage coupons from the terminal",
"type": "module",
"bin": {
diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts
index 585d65a..a8523fa 100644
--- a/apps/cli/src/index.ts
+++ b/apps/cli/src/index.ts
@@ -158,6 +158,16 @@ program
} catch {
grocery.fail('Failed to reach c0upons.');
}
+ const peerpush = ora('Reading the PeerPush deals board...').start();
+ try {
+ const res = await fetch(`${BASE_URL}/api/sync/peerpush`, { method: 'POST' });
+ const data = (await res.json()) as { error?: string; skipped?: boolean; deals?: number; inserted?: number; updated?: number; pruned?: number };
+ if (!res.ok) peerpush.fail(String(data.error ?? res.statusText));
+ else if (data.skipped) peerpush.info(chalk.gray('Read recently; skipped.'));
+ else peerpush.succeed(`${String(data.inserted)} new, ${String(data.updated)} updated of ${String(data.deals)} deals, ${String(data.pruned)} expired removed`);
+ } catch {
+ peerpush.fail('Failed to reach c0upons.');
+ }
}
if (!opts.syncOnly) {
const spinner = ora('Reading deal pages for coupons without a code...').start();
diff --git a/apps/web/app/api/sync/peerpush/route.ts b/apps/web/app/api/sync/peerpush/route.ts
new file mode 100644
index 0000000..8ff8d0a
--- /dev/null
+++ b/apps/web/app/api/sync/peerpush/route.ts
@@ -0,0 +1,31 @@
+import { NextResponse } from 'next/server';
+import { getDb } from '@/lib/db';
+import { dbErrorResponse } from '@/lib/api-error';
+import { syncPeerpushDeals } from '@/lib/peerpush-sync';
+
+export const dynamic = 'force-dynamic';
+export const maxDuration = 60;
+
+/**
+ * Read peerpush.com/deals into stores and coupons now, instead of waiting for
+ * the daemon's daily run. Keyless like the other syncs: the deals are public
+ * and the write is an idempotent upsert; a run within an hour of the last one
+ * answers `skipped`, so a stranger cannot make us hammer PeerPush.
+ */
+async function run() {
+ try {
+ const result = await syncPeerpushDeals(getDb());
+ return NextResponse.json(result, { headers: { 'Cache-Control': 'no-store' } });
+ } catch (err) {
+ console.error('peerpush sync failed:', err);
+ return dbErrorResponse(err, 'peerpush sync failed');
+ }
+}
+
+export async function POST() {
+ return run();
+}
+
+export async function GET() {
+ return run();
+}
diff --git a/apps/web/app/api/v1/daemon/route.ts b/apps/web/app/api/v1/daemon/route.ts
new file mode 100644
index 0000000..1011022
--- /dev/null
+++ b/apps/web/app/api/v1/daemon/route.ts
@@ -0,0 +1,13 @@
+import { NextResponse } from 'next/server';
+import { getDaemon } from '@/lib/daemon';
+
+export const dynamic = 'force-dynamic';
+
+/** What the scraper daemon is doing: tasks, due work, failures, the last runs. Public, like every fleet daemon's. */
+export async function GET() {
+ const daemon = getDaemon();
+ if (!daemon) return NextResponse.json({ ok: false, running: false }, { status: 503, headers: { 'Cache-Control': 'no-store' } });
+ return NextResponse.json(await daemon.status(), {
+ headers: { 'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': '*' },
+ });
+}
diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx
index 994b502..0f0bce6 100644
--- a/apps/web/app/docs/page.tsx
+++ b/apps/web/app/docs/page.tsx
@@ -395,6 +395,20 @@ export default function DocsPage() {
example="curl -X POST https://c0upons.com/api/sync/grocery"
response={`{ "ok": true, "skipped": false, "listed": 212, "remaining": 209, "pruned": 0, "written": 331, "flyers": [{ "id": 8141217, "store": "Raley's", "postal_code": "95814", "items": 123, "written": 123 }] }`}
/>
+
+
{/* MCP */}
@@ -423,6 +437,7 @@ curl -X POST https://c0upons.com/mcp -H 'content-type: application/json' \\
['sync_deals', 'Pull the next deals from nichedb.dev.'],
['sync_reddit', 'Read the newest r/couponcodes posts.'],
['sync_grocery', 'Read the next grocery weekly ads.'],
+ ['sync_peerpush', 'Read the PeerPush deals board.'],
['reveal_pending', 'Read the pages of up to three code-less coupons.'],
].map(([name, desc]) => (
diff --git a/apps/web/instrumentation.ts b/apps/web/instrumentation.ts
index 91a8554..b7cc50e 100644
--- a/apps/web/instrumentation.ts
+++ b/apps/web/instrumentation.ts
@@ -1,7 +1,8 @@
/**
* Runs once when the Next.js server starts: starts the pollers that read
* r/couponcodes every five minutes (lib/reddit-poller) and the grocery
- * weekly ads every five (lib/flipp-poller). The edge runtime also calls this
+ * weekly ads every five (lib/flipp-poller), and the scraper daemon (lib/daemon:
+ * peerpush.com/deals daily). The edge runtime also calls this
* and has no timers or database, so it is skipped there.
*/
export async function register() {
@@ -10,4 +11,6 @@ export async function register() {
startRedditPoller();
const { startFlippPoller } = await import('./lib/flipp-poller');
startFlippPoller();
+ const { startDaemon } = await import('./lib/daemon');
+ startDaemon();
}
diff --git a/apps/web/lib/daemon.ts b/apps/web/lib/daemon.ts
new file mode 100644
index 0000000..18458a7
--- /dev/null
+++ b/apps/web/lib/daemon.ts
@@ -0,0 +1,98 @@
+import 'server-only';
+import { createDaemon, type Daemon } from '@profullstack/scraper-daemon';
+import { getDb } from './db';
+import { syncPeerpushDeals, USER_AGENT } from './peerpush-sync';
+
+/**
+ * c0upons' scraper daemon (@profullstack/scraper-daemon, the house updater for
+ * listing sites): BullMQ on c0upons' own Redis (REDIS_URL, the compose `redis`
+ * service), run as a role of the web process, no new service or domain. The
+ * listings stay in c0upons' own database; Redis only holds the queue and run
+ * records. Without REDIS_URL the package's local engine runs the same jobs
+ * in-process (dev, tests).
+ *
+ * Jobs:
+ * peerpush daily peerpush.com/deals -> coupons (lib/peerpush-sync.ts)
+ *
+ * The older sources (r/couponcodes, Flipp) still run on their own pollers.
+ *
+ * The package's watchdog exits the process when Redis stops answering, which
+ * here would take the website down with it; instead the daemon is stopped and
+ * rebuilt on a fresh connection a few minutes later.
+ */
+
+const KEY = Symbol.for('c0upons.scraperDaemon');
+type Holder = { daemon?: Daemon; retry?: ReturnType
};
+const holder = (): Holder => {
+ const g = globalThis as unknown as Record;
+ return (g[KEY] ??= {});
+};
+
+const RETRY_MS = 5 * 60_000;
+
+function build(): Daemon {
+ const daemon = createDaemon({
+ name: 'c0upons',
+ redis: process.env.REDIS_URL || null,
+ concurrency: 2,
+ fetch: { userAgent: USER_AGENT, delayMs: 2000 },
+ onRedisDown: () => {
+ console.error('[daemon] redis unreachable: stopping, retrying in 5 min');
+ restartLater();
+ },
+ onStall: ({ job }: { job: string }) => console.error(`[daemon] ${job} has not succeeded within its staleAfter`),
+ });
+
+ daemon.job({
+ name: 'peerpush',
+ every: '1d',
+ staleAfter: '3d',
+ timeout: '5m',
+ run: async (ctx) => {
+ const r = await syncPeerpushDeals(getDb(), {
+ fetchText: async (url) => (await ctx.fetch.text(url, { accept: 'text/html', signal: ctx.signal })).text,
+ // The daemon schedules itself; this only stops a restart on the local engine re-reading at once.
+ minMinutesBetweenRuns: 20 * 60,
+ });
+ if (!r.skipped) {
+ console.log(`[peerpush] ${r.deals} deals, ${r.inserted} new, ${r.updated} updated, ${r.pruned} expired removed`);
+ }
+ const { added, ...counts } = r;
+ return { ...counts, added: added.slice(0, 20) };
+ },
+ });
+ return daemon;
+}
+
+function restartLater(): void {
+ const h = holder();
+ const old = h.daemon;
+ h.daemon = undefined;
+ old?.stop().catch(() => {});
+ if (h.retry) return;
+ h.retry = setTimeout(() => {
+ h.retry = undefined;
+ startDaemon();
+ }, RETRY_MS);
+ h.retry.unref?.();
+}
+
+/** Start once per process. `SCRAPER_DAEMON=0` turns it off. */
+export function startDaemon(): void {
+ if (process.env.SCRAPER_DAEMON === '0') return;
+ const h = holder();
+ if (h.daemon) return;
+ try {
+ h.daemon = build();
+ Promise.resolve(h.daemon.start()).catch((err: unknown) => {
+ console.error('[daemon] start failed:', err instanceof Error ? err.message : err);
+ restartLater();
+ });
+ } catch (err) {
+ console.error('[daemon] start failed:', err instanceof Error ? err.message : err);
+ }
+}
+
+export function getDaemon(): Daemon | undefined {
+ return holder().daemon;
+}
diff --git a/apps/web/lib/mcp-tools.ts b/apps/web/lib/mcp-tools.ts
index 4b4c281..9b59260 100644
--- a/apps/web/lib/mcp-tools.ts
+++ b/apps/web/lib/mcp-tools.ts
@@ -6,6 +6,7 @@ import type { McpTool, McpServerInfo } from './mcp-protocol';
import { syncNichedbDeals } from './nichedb-sync';
import { syncRedditCouponcodes } from './reddit-sync';
import { syncFlippWeeklyAds } from './flipp-sync';
+import { syncPeerpushDeals } from './peerpush-sync';
import { RECHECK_HOURS, ensureRevealColumns, revealDeps, revealForCoupon, sweepReveals } from './reveal-coupon';
/**
@@ -23,6 +24,7 @@ export const SERVER: McpServerInfo = {
'get_coupon for one, and reveal_code when a coupon has no code: a real browser reads its deal page and clicks what a ' +
'shopper would (up to a minute). sync_deals pulls new deals in, sync_reddit reads the newest r/couponcodes posts, ' +
'sync_grocery reads the next grocery weekly ads (Raley\'s, Safeway, Walmart and the rest), and ' +
+ 'sync_peerpush reads the PeerPush founders\' deals board, and ' +
'reveal_pending reads a few code-less pages.',
};
@@ -150,6 +152,15 @@ export const TOOLS: McpTool[] = [
return syncFlippWeeklyAds(getDb());
},
},
+ {
+ name: 'sync_peerpush',
+ description:
+ "Read peerpush.com/deals (discount codes software founders attach to their PeerPush listings) into stores and coupons, and remove deals past their valid-until date. The site's scraper daemon runs this daily; throttled to one run per hour.",
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
+ async run() {
+ return syncPeerpushDeals(getDb());
+ },
+ },
{
name: 'reveal_pending',
description: 'Read the deal pages of up to three coupons that have no code yet, most voted first, and say how many are still waiting.',
diff --git a/apps/web/lib/peerpush-sync.ts b/apps/web/lib/peerpush-sync.ts
new file mode 100644
index 0000000..cd32f1c
--- /dev/null
+++ b/apps/web/lib/peerpush-sync.ts
@@ -0,0 +1,368 @@
+/**
+ * Pull PeerPush's deals board (https://peerpush.com/deals) into stores and coupons.
+ *
+ * PeerPush is a launch directory for software products, and its founders can
+ * attach a discount to their listing: a code, a title ("20% OFF"), a percent
+ * and a valid-until date. The /deals page lists every live one with the code
+ * in full (its own FAQ: "Codes are shown in full on this page"), and its
+ * robots.txt allows the path and signals `ai-input=yes`.
+ *
+ * WHERE THE DATA IS
+ *
+ * The page is a Next.js app router render. Its JSON-LD names the products but
+ * not the codes; the codes ride in the React Server Components payload, the
+ * `self.__next_f.push([1, "..."])` script chunks. Joined in order, those
+ * chunks hold each product as a plain JSON object starting
+ * `{"id":"","slug":"...","name":"..."` with `discountCode`,
+ * `discountTitle`, `discountPercent`, `discountValidUntil` and
+ * `discountCreatedAt` on it. This module finds every such object by brace
+ * matching (string-aware, so a `{` in a description does not throw it off)
+ * and keeps the ones that carry a deal.
+ *
+ * RSC escapes a few values: a string starting `$$` is a literal `$` ("$$20
+ * OFF" is "$20 OFF"), `$D` is a Date, `$undefined` is undefined, and any
+ * other `$` is a reference to another chunk (long descriptions are sent
+ * that way), which this reads as absent and falls back to the tagline.
+ *
+ * `?page=2` is ignored by PeerPush (same list); the `type=big|ending|free`
+ * views can surface deals the default view does not, so a run reads all four
+ * and dedupes by product id.
+ *
+ * A row is `(source 'peerpush', source_id )`: one deal per
+ * product, updated in place when the founder changes it. Expired rows are
+ * deleted the way the Flipp sync does, except ones a bounty points at.
+ *
+ * Scheduled daily by the scraper daemon (lib/daemon.ts); POST
+ * /api/sync/peerpush runs it by hand. No framework imports on purpose:
+ * `test/peerpush-sync.test.mjs` runs it under plain Node.
+ */
+
+import {
+ dateOnly,
+ displayName,
+ ensureSyncSchema,
+ formatDiscount,
+ getState,
+ setState,
+ upsertCoupon,
+ upsertStore,
+ type CouponRow,
+ type SqlDb,
+} from './nichedb-sync.ts';
+import { codeFromUrl, hostOf, parseDiscount, slugify } from './reddit-sync.ts';
+
+export const SOURCE = 'peerpush';
+export const DEALS_URL = 'https://peerpush.com/deals';
+export const DEAL_VIEWS = ['', '?type=big', '?type=ending', '?type=free'];
+export const MIN_MINUTES_BETWEEN_RUNS = 60;
+export const USER_AGENT = 'c0upons/1.10 (+https://c0upons.com; reads peerpush.com/deals daily)';
+
+/** The fields of a PeerPush product this sync reads. */
+export interface PeerpushDeal {
+ id: string;
+ slug: string;
+ name: string;
+ tagline: string | null;
+ websiteUrl: string | null;
+ logoUrl: string | null;
+ category: string | null;
+ discountCode: string | null;
+ discountTitle: string | null;
+ discountPercent: number | null;
+ discountValidUntil: string | null;
+ discountCreatedAt: string | null;
+}
+
+/** The RSC payload of a page: every `self.__next_f.push([1,"..."])` string, decoded and joined. */
+export function flightText(html: string): string {
+ const out: string[] = [];
+ const re = /self\.__next_f\.push\(\[1,("(?:[^"\\]|\\.)*")\]\)/g;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(html))) {
+ try {
+ out.push(JSON.parse(m[1]) as string);
+ } catch {
+ /* a chunk that is not a JSON string: skip it */
+ }
+ }
+ return out.join('');
+}
+
+/** The balanced JSON object starting at `start` (a `{`), or null. */
+function objectAt(text: string, start: number): string | null {
+ let depth = 0;
+ let inString = false;
+ let escaped = false;
+ for (let i = start; i < text.length; i++) {
+ const c = text[i];
+ if (inString) {
+ if (escaped) escaped = false;
+ else if (c === '\\') escaped = true;
+ else if (c === '"') inString = false;
+ continue;
+ }
+ if (c === '"') inString = true;
+ else if (c === '{') depth++;
+ else if (c === '}' && --depth === 0) return text.slice(start, i + 1);
+ }
+ return null;
+}
+
+/** An RSC-encoded value as the plain string it stands for, or null. */
+export function rscString(v: unknown): string | null {
+ if (typeof v !== 'string') return null;
+ if (v.startsWith('$$')) return v.slice(1);
+ if (v.startsWith('$D')) return v.slice(2);
+ if (v.startsWith('$')) return null; // $undefined, or a reference to another chunk
+ return v;
+}
+
+/** Trim, drop wrapping quotes a founder typed ("\"10% OFF\""), collapse whitespace. */
+function clean(s: string | null): string | null {
+ if (!s) return null;
+ const t = s.replace(/\s+/g, ' ').trim().replace(/^["'“”]+|["'“”]+$/g, '').trim();
+ return t || null;
+}
+
+/** Every product on the page that carries a deal, in page order, one per product id. */
+export function extractDeals(html: string): PeerpushDeal[] {
+ const text = flightText(html);
+ const seen = new Set();
+ const out: PeerpushDeal[] = [];
+ const re = /\{"id":"[a-z0-9]{20,32}","slug":"/g;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(text))) {
+ const raw = objectAt(text, m.index);
+ if (!raw || !raw.includes('"discount')) continue;
+ let o: Record;
+ try {
+ o = JSON.parse(raw);
+ } catch {
+ continue;
+ }
+ const id = String(o.id);
+ const code = clean(rscString(o.discountCode));
+ const title = clean(rscString(o.discountTitle));
+ if (seen.has(id) || (!code && !title)) continue;
+ const name = clean(rscString(o.name));
+ if (!name) continue;
+ seen.add(id);
+ const logo = o.logo && typeof o.logo === 'object' ? rscString((o.logo as { url?: unknown }).url) : null;
+ const cats = Array.isArray(o.categories) ? (o.categories as Array<{ name?: unknown }>) : [];
+ const pct = typeof o.discountPercent === 'number' && o.discountPercent > 0 ? o.discountPercent : null;
+ out.push({
+ id,
+ slug: String(o.slug),
+ name,
+ tagline: clean(rscString(o.tagline)),
+ websiteUrl: clean(rscString(o.websiteUrl)),
+ logoUrl: logo,
+ category: cats.length ? clean(rscString(cats[0].name)) : null,
+ discountCode: code,
+ discountTitle: title,
+ discountPercent: pct,
+ discountValidUntil: rscString(o.discountValidUntil),
+ discountCreatedAt: rscString(o.discountCreatedAt),
+ });
+ }
+ return out;
+}
+
+/** "https://x.com/?utm_source=peerpush&ref=1" -> "https://x.com/?ref=1": tracking off, the rest kept. */
+export function stripTracking(url: string): string {
+ try {
+ const u = new URL(url);
+ for (const k of [...u.searchParams.keys()]) if (/^utm_/i.test(k)) u.searchParams.delete(k);
+ return u.toString();
+ } catch {
+ return url;
+ }
+}
+
+/** A founder-typed code: no placeholder ("-", "n/a"), no sentence. A link is handled by the caller. */
+function plausibleCode(s: string): boolean {
+ if (/^(-+|n\/?a|none|no code|null)$/i.test(s)) return false;
+ return /^[A-Za-z0-9][A-Za-z0-9 _.-]{1,39}$/.test(s) && s.split(' ').length <= 3;
+}
+
+const APP_STORES = /(^|\.)(apps\.apple\.com|apps\.microsoft\.com|play\.google\.com|chromewebstore\.google\.com|chrome\.google\.com)$/i;
+
+/** `code=PEERPUSH` on an App Store offer-code redeem link. */
+function codeParam(url: string): string | null {
+ try {
+ const v = new URL(url).searchParams.get('code');
+ return v && plausibleCode(v) ? v : null;
+ } catch {
+ return null;
+ }
+}
+
+/** One deal to one coupon row with its store. */
+export function toCouponRow(deal: PeerpushDeal): CouponRow | null {
+ const site = deal.websiteUrl && /^https?:\/\//i.test(deal.websiteUrl) ? stripTracking(deal.websiteUrl) : null;
+ // An App Store / Microsoft Store / Play listing is the link, not the store's own website.
+ const host = site && !APP_STORES.test(hostOf(site) ?? '') ? hostOf(site) : null;
+ // A founder sometimes enters a redeem link where the code goes (App Store offer codes, a ref link).
+ let code: string | null = null;
+ let link = site;
+ if (deal.discountCode && /^https?:\/\//i.test(deal.discountCode)) {
+ link = deal.discountCode;
+ code = codeFromUrl(deal.discountCode) ?? codeParam(deal.discountCode);
+ } else if (deal.discountCode && plausibleCode(deal.discountCode)) {
+ code = deal.discountCode;
+ }
+ const offer = deal.discountTitle ?? (deal.discountPercent ? `${deal.discountPercent}% off` : null);
+ if (!offer && !code) return null;
+
+ const slug = slugify(deal.name);
+ if (!slug) return null;
+ const parsed = parseDiscount(offer);
+ // PeerPush computes discountPercent for every deal ("$20 OFF" arrives as 25), so the
+ // founder's own words win and the percent is only the fallback.
+ // A price ("$0.99/Month") is not a percent off, whatever PeerPush computed.
+ const usePct = !parsed.type && deal.discountPercent != null && !/\$/.test(offer ?? '');
+ const type = parsed.type ?? (usePct ? 'percent' : null);
+ const value = parsed.type ? parsed.value : usePct ? deal.discountPercent : null;
+ const title = offer ? `${deal.name}: ${offer}` : `${deal.name} discount code`;
+ const description = [deal.tagline, deal.category ? `Category: ${deal.category}.` : null, 'Listed by the founder on PeerPush.']
+ .filter(Boolean)
+ .join(' ');
+
+ return {
+ store: {
+ name: displayName(deal.name, slug),
+ slug,
+ website: host ? `https://${host}` : null,
+ logo_url: deal.logoUrl ?? (host ? `https://www.google.com/s2/favicons?domain=${host}&sz=128` : null),
+ },
+ source: SOURCE,
+ source_id: deal.id,
+ code,
+ title: title.slice(0, 300),
+ description: description.slice(0, 2000),
+ discount: formatDiscount(type, value),
+ discount_type: type,
+ discount_value: value,
+ expiry_date: dateOnly(deal.discountValidUntil),
+ url: link ?? `https://peerpush.com/p/${deal.slug}`,
+ image_url: deal.logoUrl,
+ created_at: deal.discountCreatedAt,
+ };
+}
+
+/* ------------------------------------------------------------------------ */
+
+/** How the sync reads a page: plain fetch by default, the daemon's polite fetcher when it runs there. */
+export type FetchText = (url: string) => Promise;
+
+export function plainFetchText(doFetch: typeof fetch = fetch): FetchText {
+ return async (url) => {
+ const res = await doFetch(url, { headers: { 'user-agent': USER_AGENT, accept: 'text/html' } });
+ if (!res.ok) throw new Error(`peerpush answered ${res.status} for ${url}`);
+ return res.text();
+ };
+}
+
+export interface PeerpushSyncOptions {
+ baseUrl?: string;
+ fetchText?: FetchText;
+ /** The throttle the public route gets; the daemon passes 0 (it schedules itself). */
+ minMinutesBetweenRuns?: number;
+ now?: () => Date;
+}
+
+export interface PeerpushSyncResult {
+ ok: true;
+ skipped: boolean;
+ reason?: string;
+ pages: number;
+ deals: number;
+ inserted: number;
+ updated: number;
+ declined: number;
+ pruned: number;
+ stores: number;
+ /** Titles of the deals this run saw for the first time. */
+ added: string[];
+}
+
+/**
+ * One run: read the four deal views, write every deal, prune the expired.
+ * A view that fails is skipped as long as one answered; none answering throws.
+ */
+export async function syncPeerpushDeals(db: SqlDb, opts: PeerpushSyncOptions = {}): Promise {
+ const base = opts.baseUrl ?? process.env.PEERPUSH_DEALS_URL ?? DEALS_URL;
+ const fetchText = opts.fetchText ?? plainFetchText();
+ const minMinutes = opts.minMinutesBetweenRuns ?? MIN_MINUTES_BETWEEN_RUNS;
+ const now = opts.now ?? (() => new Date());
+ const empty = { pages: 0, deals: 0, inserted: 0, updated: 0, declined: 0, pruned: 0, stores: 0, added: [] };
+
+ await ensureSyncSchema(db);
+ const lastRun = await getState(db, 'peerpush:last_run');
+ if (minMinutes > 0 && lastRun && now().getTime() - new Date(lastRun).getTime() < minMinutes * 60_000) {
+ return { ok: true, skipped: true, reason: 'ran recently', ...empty };
+ }
+ await setState(db, 'peerpush:last_run', now().toISOString());
+
+ const deals = new Map();
+ let pages = 0;
+ let lastError: unknown = null;
+ for (const view of DEAL_VIEWS) {
+ try {
+ const html = await fetchText(base + view);
+ pages++;
+ for (const d of extractDeals(html)) if (!deals.has(d.id)) deals.set(d.id, d);
+ } catch (err) {
+ lastError = err;
+ }
+ }
+ if (!pages) throw lastError instanceof Error ? lastError : new Error('no peerpush deal page answered');
+
+ const today = now().toISOString().slice(0, 10);
+ const storeIds = new Map();
+ const added: string[] = [];
+ let inserted = 0;
+ let updated = 0;
+ let declined = 0;
+ for (const deal of deals.values()) {
+ const row = toCouponRow(deal);
+ if (!row || (row.expiry_date && row.expiry_date < today)) {
+ declined++;
+ continue;
+ }
+ let storeId = storeIds.get(row.store.slug);
+ if (storeId === undefined) {
+ storeId = await upsertStore(db, row.store);
+ storeIds.set(row.store.slug, storeId);
+ }
+ const existing = await db.sql`SELECT id FROM coupons WHERE source = ${SOURCE} AND source_id = ${row.source_id} LIMIT 1`;
+ await upsertCoupon(db, storeId, row);
+ if (existing.length) updated++;
+ else {
+ inserted++;
+ added.push(row.title);
+ }
+ }
+
+ // Past their valid-until date: gone, unless a bounty points at the row (no ON DELETE there).
+ const expired = await db.sql`
+ SELECT id FROM coupons
+ WHERE source = ${SOURCE} AND expiry_date IS NOT NULL AND expiry_date <> '' AND expiry_date < ${today}
+ AND id NOT IN (SELECT coupon_id FROM bounties WHERE coupon_id IS NOT NULL)
+ `;
+ for (const r of expired) await db.sql`DELETE FROM coupons WHERE id = ${r.id}`;
+ await setState(db, 'peerpush:last_counts', JSON.stringify({ deals: deals.size, inserted, updated, at: now().toISOString() }));
+
+ return {
+ ok: true,
+ skipped: false,
+ pages,
+ deals: deals.size,
+ inserted,
+ updated,
+ declined,
+ pruned: expired.length,
+ stores: storeIds.size,
+ added,
+ };
+}
diff --git a/apps/web/lib/reddit-sync.ts b/apps/web/lib/reddit-sync.ts
index 7340ef3..7f12b8f 100644
--- a/apps/web/lib/reddit-sync.ts
+++ b/apps/web/lib/reddit-sync.ts
@@ -276,8 +276,8 @@ export function parseDiscount(
): { type: 'percent' | 'fixed' | null; value: number | null } {
for (const text of texts) {
if (!text) continue;
- const pct = /(\d{1,2}(?:\.\d+)?)\s*%\s*off/i.exec(text);
- if (pct) return { type: 'percent', value: Number(pct[1]) };
+ const pct = /(?=18" }, "optionalPeers": ["react"] }, "sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg=="],
+ "@profullstack/scraper-daemon": ["@profullstack/scraper-daemon@0.3.0", "", { "peerDependencies": { "bullmq": ">=6", "ioredis": ">=5" }, "optionalPeers": ["bullmq", "ioredis"], "bin": { "scraper-daemon": "bin/scraper-daemon.js" } }, "sha512-C/COc8Oef+/8/Bdu/uyvTZbeIO0rMm3q7wEJNzDqcuoZ+cEs6OEGzrKwCkA1N7zqyi3NASCAytfeFxJiiaG9TQ=="],
+
"@profullstack/stack": ["@profullstack/stack@0.5.0", "", { "dependencies": { "@profullstack/emailer": "^1.0.1", "@profullstack/form-guard": "^0.1.1", "@profullstack/referrals": "^0.1.0" }, "peerDependencies": { "@supabase/ssr": ">=0.5.0", "next": ">=13.0.0", "react": ">=18.0.0" }, "optionalPeers": ["@supabase/ssr", "next", "react"] }, "sha512-LbXovIhyzLEq+a+y3q8KWC+jN1tJjvSoUiFEhpiEBisQn5U+iutIGHDgg16Lul74kQaFZeFlbp7CMPAVJ8KL6g=="],
"@profullstack/x402-gateway": ["@profullstack/x402-gateway@0.13.1", "", { "dependencies": { "@profullstack/keys": "^0.1.0" } }, "sha512-V7liSO0tShZRlp8dancuDuUSF/rqrr5YF3bzLoZy9wn6ixf1RIRPYTepe72s36JOdSVzi3wfuhaZFm9y0RpV4w=="],
@@ -579,6 +598,8 @@
"browserslist": ["browserslist@4.29.4", "", { "dependencies": { "baseline-browser-mapping": "^2.11.29", "caniuse-lite": "^1.0.30001816", "electron-to-chromium": "^1.5.459", "node-releases": "^2.0.59", "update-browserslist-db": "^1.3.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA=="],
+ "bullmq": ["bullmq@6.5.3", "", { "dependencies": { "cron-parser": "5.10.2", "msgpackr": "2.1.2", "node-abort-controller": "3.1.1", "semver": "7.8.5", "tslib": "2.8.1" }, "peerDependencies": { "bullmq-otel": ">=2.0.0", "ioredis": ">=5.0.0", "pg": ">=8.0.0", "redis": ">=5.0.0" }, "optionalPeers": ["bullmq-otel", "ioredis", "pg", "redis"] }, "sha512-XIgBXLfZH1faMw969AcLdTgDa2OegVYHbj+qGjkesAwGTr0ox0Wg57EComEIzkTKplLUgpENcNR0LM6sxIIiug=="],
+
"cacheable": ["cacheable@2.5.0", "", { "dependencies": { "@cacheable/memory": "^2.2.0", "@cacheable/utils": "^2.5.0", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g=="],
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
@@ -597,6 +618,8 @@
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
+ "cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="],
+
"commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="],
"common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="],
@@ -607,6 +630,8 @@
"core-js": ["core-js@3.51.0", "", { "hasInstallScript": true }, "sha512-7bDKx/5jm5MxzsXxc93g6Z2+4ZsuT/MddrfRmTDJNAkWsYWHU8EVNEsBiIEWnMAMCHMmRfUEjkuo3jeNBOIHRw=="],
+ "cron-parser": ["cron-parser@5.10.2", "", { "dependencies": { "luxon": "^3.7.2" } }, "sha512-JfAYMY0LZaWQTuict8PhtF/ksSnq8xefSDmQUnnoqvkPYkU/y3UP0DTWzJKFBz0P1IXsDG8q0vY3zHkoFZOhvw=="],
+
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@@ -631,6 +656,8 @@
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
+ "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
+
"detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
@@ -809,6 +836,8 @@
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
+ "ioredis": ["ioredis@6.0.0", "", { "dependencies": { "@ioredis/commands": "2.0.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "standard-as-callback": "2.1.0" } }, "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w=="],
+
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
@@ -939,6 +968,8 @@
"lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="],
+ "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
+
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -957,6 +988,10 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+ "msgpackr": ["msgpackr@2.1.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-JHAdwubWNEadHQsFAib6uXOzKFxY8QOWHu9LCCksOINMC+TjgaKS2Tdrw7dW5SY0LLz8YK2/YqLSi2vigQt05w=="],
+
+ "msgpackr-extract": ["msgpackr-extract@3.0.6", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.6", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.6", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.6", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.6", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.6", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.6" }, "hasInstallScript": true, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-prUB8+Gc/1rEzaNo+3Rjo0Hdi6gFjQ9Td0mDsvAIkSGUyp6EADdEBiU71ZP5Mj4jCG+vai0qSNV+yisQAMYuSQ=="],
+
"nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
"napi-postinstall": ["napi-postinstall@0.3.7", "", { "bin": { "napi-patch": "./lib/patch.js", "napi-postinstall": "./lib/cli.js" } }, "sha512-jyU4cU4kVAmy/3qjP+DcoQTNXfINHc+dFw54gGqWC8nu0mpV0kOnk7kff+J+Q18SbX0k3Np8Rm7yFjd3phatNQ=="],
@@ -965,12 +1000,16 @@
"next": ["next@16.4.3", "", { "dependencies": { "@next/env": "16.4.3", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.4.3", "@next/swc-darwin-x64": "16.4.3", "@next/swc-linux-arm64-gnu": "16.4.3", "@next/swc-linux-arm64-musl": "16.4.3", "@next/swc-linux-x64-gnu": "16.4.3", "@next/swc-linux-x64-musl": "16.4.3", "@next/swc-win32-arm64-msvc": "16.4.3", "@next/swc-win32-x64-msvc": "16.4.3", "sharp": "^0.35.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-OqxKTGi9u80FydydsMNYNjnuje9Au+kvHlt2GZ//ftdpDHm1dxKqAaENoOOTX5wKzn+9vaKUNIfZeyZUuLhCnA=="],
+ "node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="],
+
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-exports-info": ["node-exports-info@1.6.2", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag=="],
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
+ "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
+
"node-releases": ["node-releases@2.0.60", "", {}, "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -1069,6 +1108,8 @@
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
+ "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
+
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
@@ -1127,6 +1168,8 @@
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
+ "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
+
"standardwebhooks": ["standardwebhooks@1.1.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ=="],
"stdin-discarder": ["stdin-discarder@0.3.2", "", {}, "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A=="],
@@ -1291,6 +1334,8 @@
"node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+ "node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
"ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="],
diff --git a/package.json b/package.json
index 3bbc5dc..c8ba7fd 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "c0upons",
- "version": "1.9.3",
+ "version": "1.10.0",
"private": true,
"type": "module",
"packageManager": "bun@1.4.4",
diff --git a/test/peerpush-sync.test.mjs b/test/peerpush-sync.test.mjs
new file mode 100644
index 0000000..19c5342
--- /dev/null
+++ b/test/peerpush-sync.test.mjs
@@ -0,0 +1,200 @@
+// PeerPush deals (peerpush.com/deals) are the fourth thing that puts rows into
+// c0upons. The page is a Next.js RSC render, so this builds one in the shape
+// peerpush.com served on 2026-11-02 (products as JSON objects inside
+// self.__next_f.push chunks, split mid-object, with RSC's $$ / $D / $[
+// escapes) and runs the sync end to end against the real migration on a
+// local libSQL file: first run inserts, second updates in place, a later run
+// removes a deal past its valid-until date.
+import { test, before, after } from 'node:test';
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { createRequire } from 'node:module';
+
+const require = createRequire(new URL('../apps/web/package.json', import.meta.url));
+const pgUrl = process.env.C0UPONS_TEST_DATABASE_URL;
+const { createClient } = await import(require.resolve(pgUrl ? '@profullstack/libsql-pg' : '@libsql/client'));
+const { syncPeerpushDeals, extractDeals, toCouponRow, flightText, rscString, stripTracking } = await import(
+ '../apps/web/lib/peerpush-sync.ts'
+);
+const { parseDiscount } = await import('../apps/web/lib/reddit-sync.ts');
+
+const dir = mkdtempSync(join(tmpdir(), 'c0upons-peerpush-'));
+const url = pgUrl ?? `file:${join(dir, 'local.db')}`;
+let client;
+const db = {
+ sql: async (strings, ...values) => {
+ const rs = await client.execute({ sql: strings.join('?'), args: values.map((v) => (v === undefined ? null : v)) });
+ return rs.rows;
+ },
+};
+
+const product = (id, slug, name, deal, extra = {}) => ({
+ id,
+ slug,
+ name,
+ upvotes: 3,
+ tagline: `${name} does things`,
+ description: '$86',
+ websiteUrl: `https://${slug}.example/?utm_source=peerpush&ref=pp`,
+ logo: { id: 'logo' + id, url: `https://static.peerpush.test/${slug}.png`, type: 'image' },
+ categories: [{ id: 'c1', name: 'Productivity', slug: 'productivity' }],
+ upvoted: false,
+ discountPercent: deal.pct ?? null,
+ discountCode: deal.code ?? null,
+ discountTitle: deal.title ?? null,
+ discountValidUntil: deal.until ? `$D${deal.until}T00:00:00.000Z` : null,
+ videoUrl: null,
+ aiBadge: '$undefined',
+ discountCreatedAt: '$D2026-10-20T11:59:39.240Z',
+ ...extra,
+});
+
+const ID = (n) => `cmvs4qhi306il072f1ivv9${String(n).padStart(4, '0')}`;
+const KLIPORT = product(ID(1), 'kliport', 'Kliport', { code: 'SAVE50', title: '50% OFF', pct: 50, until: '2026-12-31' });
+const ZOLTRA = product(ID(2), 'zoltra', 'Zoltra', { code: 'PEER-20', title: '$$20 OFF', pct: 25, until: '2031-10-16' });
+const LAYERZ = product(ID(3), 'layerz', 'Layerz', {
+ code: 'https://apps.apple.com/redeem?ctx=offercodes&id=6499101592&code=PEERPUSH',
+ title: '50% OFF',
+ pct: 50,
+ until: '2026-12-31',
+}, { websiteUrl: 'https://apps.apple.com/us/app/layerz/id6499101592' });
+const AIOMIZE = product(ID(4), 'aiomize-nhxd', 'Aiomize', { code: '-', title: 'Pay for a 4 month subscription get 6 month', pct: 30, until: '2026-12-31' });
+const XIMOS = product(ID(5), 'ximos', 'Ximos', { code: 'XIMOS099', title: '$$0.99/Month', pct: 99, until: '2026-12-31' });
+const ARROW = product(ID(6), 'arrowdeck', 'ArrowDeck', { code: 'KY2DF-7CPWJ-MKHJ2-VR2MC-2GTVZ', title: '"100% OFF"', until: '2026-11-15' });
+const NO_DEAL = product(ID(7), 'groupings', 'Groupings', {});
+
+/** A page the way Next streams it: one row of products, cut into chunks at arbitrary points. */
+function page(products) {
+ const payload = `0:["$","div",null,{"children":[]}]\n2a:${JSON.stringify(['$', '$L2b', null, { products }])}\n86:T40,long description\n`;
+ const cuts = [0, 37, 911, 2400, payload.length].filter((n, i, a) => n <= payload.length && a.indexOf(n) === i);
+ const chunks = cuts.slice(1).map((end, i) => payload.slice(cuts[i], end));
+ const scripts = chunks.map((c) => ``).join('');
+ return `Deals | PeerPush${scripts}`;
+}
+
+const BASE = 'https://peerpush.test/deals';
+let views;
+const fetchText = async (u) => {
+ if (!(u in views)) throw new Error(`unexpected fetch ${u}`);
+ if (views[u] instanceof Error) throw views[u];
+ return views[u];
+};
+
+before(() => {
+ execFileSync('node', ['apps/web/scripts/migrate.mjs'], {
+ cwd: new URL('..', import.meta.url),
+ env: { ...process.env, DATABASE_URL: url, TURSO_DATABASE_URL: url, TURSO_AUTH_TOKEN: '' },
+ stdio: 'pipe',
+ });
+ client = createClient({ url });
+});
+
+after(() => {
+ client?.close();
+ rmSync(dir, { recursive: true, force: true });
+});
+
+test('rsc escapes decode to the values they stand for', () => {
+ assert.equal(rscString('$$20 OFF'), '$20 OFF');
+ assert.equal(rscString('$D2026-12-31T00:00:00.000Z'), '2026-12-31T00:00:00.000Z');
+ assert.equal(rscString('$undefined'), null);
+ assert.equal(rscString('$86'), null);
+ assert.equal(rscString('SAVE50'), 'SAVE50');
+ assert.match(flightText(page([KLIPORT])), /"discountCode":"SAVE50"/);
+});
+
+test('every product with a deal is found, across chunk boundaries, once', () => {
+ const deals = extractDeals(page([KLIPORT, ZOLTRA, NO_DEAL, LAYERZ, KLIPORT]));
+ assert.deepEqual(deals.map((d) => d.slug), ['kliport', 'zoltra', 'layerz']);
+ assert.equal(deals[1].discountTitle, '$20 OFF');
+ assert.equal(deals[0].logoUrl, 'https://static.peerpush.test/kliport.png');
+ assert.equal(deals[0].category, 'Productivity');
+});
+
+test('a deal maps to a coupon: founder words over the computed percent, codes from redeem links', () => {
+ const [k, z, l, a, x, ar] = extractDeals(page([KLIPORT, ZOLTRA, LAYERZ, AIOMIZE, XIMOS, ARROW])).map(toCouponRow);
+ assert.equal(k.code, 'SAVE50');
+ assert.equal(k.discount, '50%');
+ assert.equal(k.title, 'Kliport: 50% OFF');
+ assert.equal(k.url, 'https://kliport.example/?ref=pp');
+ assert.equal(k.store.website, 'https://kliport.example');
+ assert.equal(k.expiry_date, '2026-12-31');
+ assert.equal(k.source, 'peerpush');
+ assert.equal(k.source_id, ID(1));
+
+ // "$20 OFF" arrives with discountPercent 25: the title wins.
+ assert.deepEqual([z.discount_type, z.discount_value, z.discount], ['fixed', 20, '$20 off']);
+ // A redeem link in the code field: the link is the url, its code= param the code, no App Store "website".
+ assert.equal(l.code, 'PEERPUSH');
+ assert.match(l.url, /^https:\/\/apps\.apple\.com\/redeem/);
+ assert.equal(l.store.website, null);
+ // "-" is no code; the percent is the fallback when the words carry none.
+ assert.equal(a.code, null);
+ assert.equal(a.discount, '30%');
+ assert.equal(a.store.slug, 'aiomize');
+ // A price is not 99% off.
+ assert.equal(x.discount, null);
+ // Quotes a founder typed are dropped; three-digit percents parse.
+ assert.equal(ar.title, 'ArrowDeck: 100% OFF');
+ assert.equal(ar.discount, '100%');
+});
+
+test('parseDiscount reads 100% off as 100, not 0', () => {
+ assert.deepEqual(parseDiscount('100% OFF (First 100 redemptions only)'), { type: 'percent', value: 100 });
+ assert.deepEqual(parseDiscount('save 2.5% off'), { type: 'percent', value: 2.5 });
+ assert.deepEqual(parseDiscount('1000% off'), { type: null, value: null });
+});
+
+test('stripTracking keeps everything but utm_*', () => {
+ assert.equal(stripTracking('https://a.example/?utm_source=x&utm_medium=y&ref=1'), 'https://a.example/?ref=1');
+ assert.equal(stripTracking('not a url'), 'not a url');
+});
+
+test('sync: reads all four views, inserts, updates in place, throttles, prunes the expired', async () => {
+ views = {
+ [BASE]: page([KLIPORT, ZOLTRA, NO_DEAL]),
+ [`${BASE}?type=big`]: page([KLIPORT, ARROW]),
+ [`${BASE}?type=ending`]: new Error('peerpush answered 502'),
+ [`${BASE}?type=free`]: page([LAYERZ]),
+ };
+ const day1 = () => new Date('2026-11-02T12:00:00Z');
+ const r1 = await syncPeerpushDeals(db, { baseUrl: BASE, fetchText, now: day1 });
+ assert.equal(r1.skipped, false);
+ assert.equal(r1.pages, 3);
+ assert.equal(r1.deals, 4);
+ assert.equal(r1.inserted, 4);
+ assert.ok(r1.added.includes('Zoltra: $20 OFF'));
+
+ const rows = await db.sql`SELECT c.code, c.title, c.expiry_date, s.slug FROM coupons c JOIN stores s ON s.id = c.store_id WHERE c.source = 'peerpush' ORDER BY s.slug`;
+ assert.deepEqual(rows.map((r) => r.slug), ['arrowdeck', 'kliport', 'layerz', 'zoltra']);
+
+ // An hour's throttle for the public route.
+ const again = await syncPeerpushDeals(db, { baseUrl: BASE, fetchText, now: () => new Date('2026-11-02T12:30:00Z') });
+ assert.equal(again.skipped, true);
+
+ // Next day the founder changes the code: same row, new code.
+ views[BASE] = page([{ ...KLIPORT, discountCode: 'SAVE60', discountTitle: '60% OFF' }, ZOLTRA]);
+ const r2 = await syncPeerpushDeals(db, { baseUrl: BASE, fetchText, now: () => new Date('2026-11-03T12:00:00Z') });
+ assert.equal(r2.inserted, 0);
+ assert.equal(r2.updated, 4);
+ const [k] = await db.sql`SELECT code, title FROM coupons WHERE source = 'peerpush' AND source_id = ${ID(1)}`;
+ assert.deepEqual([k.code, k.title], ['SAVE60', 'Kliport: 60% OFF']);
+
+ // After ArrowDeck's 2026-11-15 valid-until: removed, and not re-added while PeerPush still lists it.
+ const r3 = await syncPeerpushDeals(db, { baseUrl: BASE, fetchText, now: () => new Date('2026-11-16T12:00:00Z') });
+ assert.equal(r3.pruned, 1);
+ assert.equal(r3.declined, 1);
+ const left = await db.sql`SELECT COUNT(*) AS n FROM coupons WHERE source = 'peerpush'`;
+ assert.equal(Number(left[0].n), 3);
+});
+
+test('sync throws when no view answers', async () => {
+ views = {};
+ await assert.rejects(
+ syncPeerpushDeals(db, { baseUrl: BASE, fetchText, minMinutesBetweenRuns: 0 }),
+ /unexpected fetch/
+ );
+});
]