|
| 1 | +/** |
| 2 | + * Coupons from the email intake, posted to the c0upons.com/bbs forums. |
| 3 | + * |
| 4 | + * Every coupon submit@c0upons.com turns into gets a thread in the forum that |
| 5 | + * fits it (Grocery & Weekly Ads, Tech Deals, Freebies...), with a link back to |
| 6 | + * its page here. Only the email intake does this: the nichedb, Flipp and Reddit |
| 7 | + * syncs bring in hundreds at a time, and a forum that is a mirror of the site |
| 8 | + * is a forum nobody reads. |
| 9 | + * |
| 10 | + * The board is tsbb, which takes `POST /api/v1/forums/<slug>/topics` with a |
| 11 | + * `tsbb_` bearer token minted for the poster account. Its flood guard refuses a |
| 12 | + * second post by the same account inside `posts.floodSeconds` (15 by default), |
| 13 | + * and one email can carry several offers, so posts go one at a time with a gap, |
| 14 | + * and a `flooding` answer waits and tries once more. |
| 15 | + * |
| 16 | + * `bbs_posts` remembers what was posted, so a coupon is never posted twice, |
| 17 | + * whatever retries upstream. |
| 18 | + */ |
| 19 | +import type { SqlDb } from './nichedb-sync.ts'; |
| 20 | + |
| 21 | +export const SITE = 'https://c0upons.com'; |
| 22 | + |
| 23 | +export interface BbsCoupon { |
| 24 | + id: number; |
| 25 | + store: string; |
| 26 | + storeSlug: string; |
| 27 | + title: string; |
| 28 | + code: string | null; |
| 29 | + description: string | null; |
| 30 | + discount: string | null; |
| 31 | + expiry_date: string | null; |
| 32 | +} |
| 33 | + |
| 34 | +/** The forums a coupon can land in, strongest signal first. Slugs are the board's. */ |
| 35 | +const FORUMS: Array<{ slug: string; words: RegExp }> = [ |
| 36 | + { |
| 37 | + slug: 'grocery', |
| 38 | + words: |
| 39 | + /\b(grocer(y|ies)|supermarket|weekly ad|produce|snacks?|coffee|cereal|food|meal kits?|kroger|safeway|albertsons|aldi|publix|whole foods|trader joe'?s|instacart|hello ?fresh|heb|meijer|food lion|giant eagle|wegmans)\b/i, |
| 40 | + }, |
| 41 | + { |
| 42 | + slug: 'tech-deals', |
| 43 | + words: |
| 44 | + /\b(tech|electronics?|laptops?|computers?|pcs?|phones?|iphone|android|tablets?|ipad|tvs?|monitors?|headphones?|earbuds|speakers?|gaming|xbox|playstation|ps5|nintendo|switch|ssd|gpus?|graphics card|vpn|software|apps?|smart ?home|camera|best ?buy|newegg|apple|samsung|dell|lenovo|logitech|anker)\b/i, |
| 45 | + }, |
| 46 | + { |
| 47 | + slug: 'home-deals', |
| 48 | + words: |
| 49 | + /\b(home|garden|furniture|mattress(es)?|bedding|kitchen|cookware|appliances?|tools?|hardware|patio|lawn|decor|rugs?|harbor freight|home depot|lowe'?s|wayfair|ikea|overstock|bed bath)\b/i, |
| 50 | + }, |
| 51 | + { |
| 52 | + slug: 'fashion-deals', |
| 53 | + words: |
| 54 | + /\b(clothing|clothes|apparel|fashion|shoes|sneakers|boots|dress(es)?|jeans|jackets?|handbags?|jewelry|watches|beauty|makeup|skin ?care|cosmetics|fragrance|perfume|hair ?care|nike|adidas|sephora|ulta|old navy|gap|h&m|zara|nordstrom|macy'?s)\b/i, |
| 55 | + }, |
| 56 | + { |
| 57 | + slug: 'travel-deals', |
| 58 | + words: |
| 59 | + /\b(travel|flights?|airlines?|airfare|hotels?|resorts?|cruises?|car rentals?|vacations?|getaways?|expedia|booking\.com|priceline|hotels\.com|airbnb|vrbo|southwest|delta|united)\b/i, |
| 60 | + }, |
| 61 | +]; |
| 62 | + |
| 63 | +/** "Free shipping" is a perk, not a freebie. */ |
| 64 | +const FREEBIE = /\b(free(?!\s+(shipping|delivery|returns|trial))|freebies?|giveaway|free samples?|on the house)\b/i; |
| 65 | + |
| 66 | +/** |
| 67 | + * Which forum a coupon belongs in. |
| 68 | + * |
| 69 | + * Coupons and stores carry no category, so this reads the words. A freebie is a |
| 70 | + * freebie wherever it is from; otherwise the topic forum that matches most, and |
| 71 | + * failing that Coupons & Promo Codes for a code or Hot Deals for a sale. |
| 72 | + */ |
| 73 | +export function pickForum(c: Pick<BbsCoupon, 'store' | 'title' | 'description' | 'code' | 'discount'>): string { |
| 74 | + const text = `${c.store} ${c.title} ${c.description ?? ''}`; |
| 75 | + if (FREEBIE.test(c.title) && !/\d+\s*%|\$\s*\d/.test(c.discount ?? '')) return 'freebies'; |
| 76 | + let best: { slug: string; hits: number } | null = null; |
| 77 | + for (const f of FORUMS) { |
| 78 | + const hits = (text.match(new RegExp(f.words.source, 'gi')) ?? []).length; |
| 79 | + if (hits && (!best || hits > best.hits)) best = { slug: f.slug, hits }; |
| 80 | + } |
| 81 | + if (best) return best.slug; |
| 82 | + return c.code ? 'coupons' : 'hot-deals'; |
| 83 | +} |
| 84 | + |
| 85 | +const clean = (s: string | null | undefined) => (s ?? '').replace(/\s+/g, ' ').trim(); |
| 86 | + |
| 87 | +/** The thread: a title a deal hunter scans for, and a body that sends them to the coupon. */ |
| 88 | +export function topicFor(c: BbsCoupon): { title: string; body: string } { |
| 89 | + const store = clean(c.store); |
| 90 | + const title = clean(c.title); |
| 91 | + const head = title.toLowerCase().includes(store.toLowerCase()) ? title : `${store}: ${title}`; |
| 92 | + const withCode = c.code && !head.includes(c.code) ? `${head} (code ${c.code})` : head; |
| 93 | + const lines = [ |
| 94 | + `**Store:** [${store}](${SITE}/stores/${c.storeSlug})`, |
| 95 | + c.code ? `**Code:** \`${c.code}\`` : '**Code:** none needed', |
| 96 | + c.discount ? `**Deal:** ${clean(c.discount)}` : '', |
| 97 | + c.expiry_date ? `**Expires:** ${clean(c.expiry_date).slice(0, 10)}` : '', |
| 98 | + '', |
| 99 | + clean(c.description) && clean(c.description) !== title ? clean(c.description).slice(0, 1000) : '', |
| 100 | + '', |
| 101 | + `**[Get this deal on c0upons](${SITE}/coupons/${c.id})**`, |
| 102 | + '', |
| 103 | + '_Found in a retailer email to submit@c0upons.com. Codes can expire or have limits; reply if it worked or not._', |
| 104 | + ]; |
| 105 | + return { |
| 106 | + title: withCode.slice(0, 160), |
| 107 | + body: lines.filter((l, i, all) => l !== '' || (all[i - 1] ?? '') !== '').join('\n').trim(), |
| 108 | + }; |
| 109 | +} |
| 110 | + |
| 111 | +export async function ensureBbsSchema(db: SqlDb): Promise<void> { |
| 112 | + await db.sql` |
| 113 | + CREATE TABLE IF NOT EXISTS bbs_posts ( |
| 114 | + coupon_id INTEGER PRIMARY KEY, |
| 115 | + forum TEXT NOT NULL, |
| 116 | + topic_url TEXT, |
| 117 | + status TEXT NOT NULL, |
| 118 | + error TEXT, |
| 119 | + created_at DATETIME DEFAULT CURRENT_TIMESTAMP |
| 120 | + ) |
| 121 | + `; |
| 122 | +} |
| 123 | + |
| 124 | +export interface BbsDeps { |
| 125 | + fetch?: typeof fetch; |
| 126 | + sleep?: (ms: number) => Promise<void>; |
| 127 | + /** Defaults to BBS_API_URL, else https://c0upons.com/bbs. */ |
| 128 | + baseUrl?: string; |
| 129 | + /** Defaults to BBS_POST_TOKEN. Without one nothing is posted. */ |
| 130 | + token?: string; |
| 131 | + /** Gap between two posts; tsbb's flood guard is 15 s by default. */ |
| 132 | + gapMs?: number; |
| 133 | +} |
| 134 | + |
| 135 | +export interface BbsPostResult { |
| 136 | + coupon_id: number; |
| 137 | + forum: string; |
| 138 | + status: 'posted' | 'already' | 'failed' | 'disabled'; |
| 139 | + url?: string; |
| 140 | + error?: string; |
| 141 | +} |
| 142 | + |
| 143 | +/** |
| 144 | + * Post each coupon once, one at a time. |
| 145 | + * |
| 146 | + * Read config at call time, not module load: Next inlines process.env at build. |
| 147 | + */ |
| 148 | +export async function postCouponsToBbs(db: SqlDb, coupons: BbsCoupon[], deps: BbsDeps = {}): Promise<BbsPostResult[]> { |
| 149 | + const token = deps.token ?? process.env.BBS_POST_TOKEN ?? ''; |
| 150 | + const base = (deps.baseUrl ?? process.env.BBS_API_URL ?? `${SITE}/bbs`).replace(/\/+$/, ''); |
| 151 | + const doFetch = deps.fetch ?? fetch; |
| 152 | + const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms))); |
| 153 | + const gap = deps.gapMs ?? 16_000; |
| 154 | + const out: BbsPostResult[] = []; |
| 155 | + if (!coupons.length) return out; |
| 156 | + if (!token) { |
| 157 | + return coupons.map((c) => ({ coupon_id: c.id, forum: pickForum(c), status: 'disabled' as const, error: 'BBS_POST_TOKEN is not set' })); |
| 158 | + } |
| 159 | + await ensureBbsSchema(db); |
| 160 | + |
| 161 | + let last = 0; |
| 162 | + for (const c of coupons) { |
| 163 | + const forum = pickForum(c); |
| 164 | + const prior = await db.sql`SELECT status, topic_url FROM bbs_posts WHERE coupon_id = ${c.id} LIMIT 1`; |
| 165 | + if (prior.length && prior[0].status === 'posted') { |
| 166 | + out.push({ coupon_id: c.id, forum, status: 'already', url: String(prior[0].topic_url ?? '') }); |
| 167 | + continue; |
| 168 | + } |
| 169 | + const topic = topicFor(c); |
| 170 | + let result: BbsPostResult = { coupon_id: c.id, forum, status: 'failed' }; |
| 171 | + for (let attempt = 0; attempt < 2; attempt++) { |
| 172 | + const wait = last + gap - Date.now(); |
| 173 | + if (wait > 0) await sleep(wait); |
| 174 | + let res: Response; |
| 175 | + try { |
| 176 | + res = await doFetch(`${base}/api/v1/forums/${encodeURIComponent(forum)}/topics`, { |
| 177 | + method: 'POST', |
| 178 | + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, |
| 179 | + body: JSON.stringify({ title: topic.title, body: topic.body, format: 'markdown' }), |
| 180 | + }); |
| 181 | + } catch (err) { |
| 182 | + result = { coupon_id: c.id, forum, status: 'failed', error: String((err as Error)?.message ?? err).slice(0, 300) }; |
| 183 | + break; |
| 184 | + } |
| 185 | + last = Date.now(); |
| 186 | + const data = (await res.json().catch(() => ({}))) as { url?: string; error?: string; code?: string }; |
| 187 | + if (res.ok && data.url) { |
| 188 | + result = { coupon_id: c.id, forum, status: 'posted', url: `${base}${data.url}` }; |
| 189 | + break; |
| 190 | + } |
| 191 | + const why = `${res.status} ${data.code ?? data.error ?? ''}`.trim(); |
| 192 | + result = { coupon_id: c.id, forum, status: 'failed', error: why.slice(0, 300) }; |
| 193 | + // The board's flood guard is the one refusal worth waiting out. |
| 194 | + if (!(res.status === 400 && /flood/i.test(why))) break; |
| 195 | + } |
| 196 | + await db.sql` |
| 197 | + INSERT INTO bbs_posts (coupon_id, forum, topic_url, status, error) |
| 198 | + VALUES (${c.id}, ${forum}, ${result.url ?? null}, ${result.status}, ${result.error ?? null}) |
| 199 | + ON CONFLICT(coupon_id) DO UPDATE SET forum = excluded.forum, topic_url = excluded.topic_url, |
| 200 | + status = excluded.status, error = excluded.error |
| 201 | + `; |
| 202 | + out.push(result); |
| 203 | + } |
| 204 | + return out; |
| 205 | +} |
0 commit comments