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/ + ); +});