diff --git a/README.md b/README.md index 3f4520e..b731972 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Adapters are one file each in `packages/adapters/src`. One hundred and twenty-ei | marketplace | `d0rz`, `bl0ggers` | no | | ai-media | `aiornot` | no | | forums | `tsbb` | no | +| coupons | `c0upons` (what c0upons.com took in on its own: submitted codes and its r/couponcodes listings; rows it copied from `deals` are not read back) | no | | hosting | `findhost`, `buyvps`, `vultr-plans`, `linode-types`, `scaleway-instances`, `ovh-vps`, `storefront` (WHMCS, Blesta and WooCommerce order forms), `lowendbox`, `openserver`; `hetzner-plans`, `digitalocean-sizes`, `upcloud-plans` | no for the first eight (FindHost data is CC BY 4.0: credit FindHost, findhost.app; `OBSCURA_MCP_URL` optional for JavaScript-only shops); Hetzner (`HETZNER_API_TOKEN`), DigitalOcean (`DIGITALOCEAN_TOKEN`) and UpCloud (`UPCLOUD_USERNAME`, `UPCLOUD_PASSWORD`) each need a read-only credential and stay paused without one | | threats | `openthreat` (ThreatCrush first; any reporter serving `/.well-known/openthreat.json`) | no | | profiles | `openprofiles` (p0dcasters and OutreachGraph listings first; any app that lists the OpenProfile.md files it serves), `sportarr-persons` (Sportarr's 111k names, kept only when Wikidata knows the person as a human with a sport, with their socials), `sportsdb-players` | no | @@ -79,7 +80,7 @@ One record per page, read the way [OpenSite](https://logicsrc.com/opensite) says ### House aggregators -Eight of the sites we run publish a public feed or API of their own, and each is read here through it, keyless, the way any other reader would. The endpoint is the one checked live on 2026-09-12; the slug is the seeded source. +Nine of the sites we run publish a public feed or API of their own, and each is read here through it, keyless, the way any other reader would. The endpoint is the one checked live on 2026-09-12 (c0upons on 2026-09-13); the slug is the seeded source. | Site | Endpoint | Collection | Source slug | Kinds | | --- | --- | --- | --- | --- | @@ -91,6 +92,7 @@ Eight of the sites we run publish a public feed or API of their own, and each is | agenticjobs.work | `/api/v1/jobs` (offset paged, 100 a page) | jobs | `agenticjobs-postings` | `job` | | outreachgraph.com | `/api/v1/public/directory` (cursor paged, 200 a page; companies and sites by domain, people only when self-published) | directory | `outreachgraph-directory` | `company`, `site`, `person` | | tsbb.dev | `/api/v1/forums`, then `/f/{slug}/feed.xml` per forum | forums | `tsbb-topics` | `post` | +| c0upons.com | `/api/coupons` (offset paged, 200 a page, store joined in; rows with `source: nichedb` skipped) | coupons | `c0upons-coupons` | `coupon` | | c0ncerts.com | none yet: `/api/events` answers 501 "coming soon" and `/api/v1/events` 404s | — | — | — | saasrow's `/api/v1/listings` is per-account and needs a key, so only the public products directory is read. A submission aiornot carries on more than one feed is stored once and tagged with each feed. tsbb's cross-board `/api/v1/latest` does not say which forum a topic is in, which is why the walk is per forum. diff --git a/packages/adapters/src/c0upons.js b/packages/adapters/src/c0upons.js new file mode 100644 index 0000000..bc8761f --- /dev/null +++ b/packages/adapters/src/c0upons.js @@ -0,0 +1,180 @@ +import { decodeEntities, defineAdapter } from '@nichedb/core/adapter'; + +/** + * Coupons, from c0upons.com. + * + * c0upons is the house coupon site. Most of what it lists it copied from this + * database's `deals` collection, so those rows are NOT read back: they are + * already here under their original sources, and a copy of a copy would only + * double them. What c0upons has that nichedb does not is everything it took + * in on its own: codes people submitted on the site, and the listings it reads + * from r/couponcodes every five minutes. Those rows carry `source` of null + * (submitted) or `reddit`, and they are what this adapter brings home. + * + * `/api/coupons` is the public read of every coupon with its store joined in, + * keyless, paged by `limit` and `offset`, most voted first. The walk reads + * whole pages and keeps only the rows that did not come from here, so a + * run's page count is the site's size, not the count it brings back. + */ + +const BASE = 'https://c0upons.com/api/coupons'; +const SITE = 'https://c0upons.com'; + +/** Rows per page. The route caps nothing; this is one request's worth. */ +const PAGE = 200; + +/** Pages per run. Ten covers the site five times over as of 2026-09-13 (815 coupons). */ +const DEFAULT_PAGES = 10; + +/** Where c0upons copied the row from when it is one of ours. */ +const OURS = 'nichedb'; + +const clean = (s) => { + const t = decodeEntities(String(s ?? '')) + .replace(/\s+/g, ' ') + .trim(); + return t || null; +}; + +const HTTP = /^https?:\/\//i; +const urlOrNull = (v) => (typeof v === 'string' && HTTP.test(v.trim()) ? v.trim() : null); + +const number = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null); + +/** The store's domain, which c0upons only knows through the favicon URL it built from it. */ +export function domainFromLogo(logoUrl) { + const url = urlOrNull(logoUrl); + if (!url) return null; + try { + const u = new URL(url); + const d = u.searchParams.get('domain'); + return d && /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(d) ? d.toLowerCase() : null; + } catch { + return null; + } +} + +/** + * One coupon, or null if it has no id or title, or came from this database + * in the first place. + */ +export function toItem(row) { + const id = number(row?.id); + if (id === null) return null; + if (row.source === OURS) return null; + const title = clean(row.title); + if (!title) return null; + + const storeKey = clean(row.store_slug); + const store = clean(row.store_name) ?? storeKey; + const code = clean(row.code); + const discountType = + row.discount_type === 'percent' || row.discount_type === 'fixed' ? row.discount_type : null; + const source = clean(row.source) ?? 'submitted'; + const created = row.created_at ? new Date(row.created_at) : null; + const domain = domainFromLogo(row.store_logo); + + return { + externalId: String(id), + kind: 'coupon', + title, + summary: clean(row.description)?.slice(0, 600) ?? null, + /* + * The coupon page rather than the deal it points at. The page carries the + * code, the votes and the store; the deal's own address is in + * `data.dealUrl` for whoever wants to skip the site. + */ + url: `${SITE}/coupons/${id}`, + imageUrl: urlOrNull(row.image_url) ?? urlOrNull(row.store_logo), + publishedAt: created && !Number.isNaN(created.getTime()) ? created : null, + tags: [ + 'c0upons', + storeKey, + `source:${source}`, + code ? 'coupon-code' : null, + discountType ? `discount:${discountType}` : null, + ].filter(Boolean), + data: { + store, + storeKey, + storeDomain: domain, + code, + discountType, + discountValue: number(row.discount_value), + discount: clean(row.discount), + expires: clean(row.expiry_date), + dealUrl: urlOrNull(row.url), + votes: number(row.votes) ?? 0, + verified: row.verified === 1 || row.verified === true, + source, + sourceId: clean(row.source_id), + codeSource: clean(row.code_source), + }, + }; +} + +export const c0upons = defineAdapter({ + name: 'c0upons', + title: 'c0upons coupons', + collection: 'coupons', + description: + "Every coupon c0upons.com took in on its own: codes people submitted on the site and the listings it reads from r/couponcodes, each under its store with the code, discount and expiry as fields and a link to the coupon page. Rows c0upons copied from this database's deals collection are left where they already are.", + docs: 'https://c0upons.com/docs', + kinds: ['coupon'], + cadenceMinutes: 15, + configFields: [ + { + key: 'pages', + label: 'Pages per run', + type: 'number', + required: false, + help: 'Two hundred coupons a page, most voted first. Ten pages is far more than the site holds today.', + }, + ], + defaults: { pages: DEFAULT_PAGES }, + defaultSources: [ + { + slug: 'c0upons-coupons', + name: 'Coupons: c0upons.com', + config: { pages: DEFAULT_PAGES }, + }, + ], + async pull({ config, http, log, deadline }) { + const pages = Math.min(Math.max(Number(config.pages) || DEFAULT_PAGES, 1), 50); + const items = []; + const seen = new Set(); + let read = 0; + let ours = 0; + + for (let page = 0; page < pages; page++) { + if (Date.now() > deadline) break; + const offset = page * PAGE; + let rows; + try { + rows = await http.json(`${BASE}?limit=${PAGE}&offset=${offset}`, { timeoutMs: 20_000 }); + } catch (err) { + log(`offset ${offset} failed (${err.message.slice(0, 60)})`); + break; + } + if (!Array.isArray(rows) || rows.length === 0) break; + read += rows.length; + for (const row of rows) { + if (row?.source === OURS) { + ours++; + continue; + } + const item = toItem(row); + // Offset paging over a list ordered by votes can repeat a row across a + // page boundary when a vote lands mid-walk; one row wins. + if (item && !seen.has(item.externalId)) { + seen.add(item.externalId); + items.push(item); + } + } + if (rows.length < PAGE) break; + } + + log(`${items.length} coupons of ${read} read, ${ours} already ours`); + return { items, note: `${items.length} coupons` }; + }, +}); diff --git a/packages/adapters/src/index.js b/packages/adapters/src/index.js index 53df233..b0bd806 100644 --- a/packages/adapters/src/index.js +++ b/packages/adapters/src/index.js @@ -12,6 +12,7 @@ import { bl0ggers } from './bl0ggers.js'; import { blsSeries } from './bls.js'; import { brisk } from './brisk.js'; import { buyvps } from './buyvps.js'; +import { c0upons } from './c0upons.js'; import { cfpbComplaints } from './cfpb.js'; import { channels } from './channels.js'; import { clinicalTrials } from './clinicaltrials.js'; @@ -258,6 +259,7 @@ export const ADAPTERS = [ aiornot, agenticjobs, tsbb, + c0upons, // Hosting: who sells servers, at what price, and the deals the small ones announce. findhost, vultrPlans, diff --git a/packages/adapters/test/fixtures/c0upons-coupons.json b/packages/adapters/test/fixtures/c0upons-coupons.json new file mode 100644 index 0000000..82832bf --- /dev/null +++ b/packages/adapters/test/fixtures/c0upons-coupons.json @@ -0,0 +1,94 @@ +[ + { + "id": 647, + "store_id": 143, + "code": "tdsynnexus", + "title": "ChatGPT Business: 2 seats for the price of 1 for 48 months ($25/mo off)", + "description": "Open the link (or enter the code at checkout), choose ChatGPT Business on monthly billing with exactly 2 seats: one seat is free for 48 months, so 2 seats bill $25/month instead of $50. Reported working on 2026-09-13 (uscardforum thread 518012, post 81). US region, monthly plans only, one promotion per account, and not for accounts that already have Plus. OpenAI partner code (TD SYNNEX US); it may stop working at any time.", + "discount": "$25 off", + "discount_type": "fixed", + "discount_value": 25, + "expiry_date": null, + "url": "https://chatgpt.com/?promoCode=tdsynnexus", + "image_url": null, + "votes": 0, + "verified": 0, + "created_at": "2026-09-13 09:04:11", + "source": null, + "source_id": null, + "code_checked_at": null, + "code_source": null, + "store_name": "OpenAI", + "store_slug": "openai", + "store_logo": null + }, + { + "id": 60, + "store_id": 13, + "code": null, + "title": "Nike Men's Jordan Flight Court Shoes for $52 + free shipping", + "description": "Ending today, you can get these Nike Jordan Flight Court Shoes for just $52 in Black / White.", + "discount": null, + "discount_type": null, + "discount_value": null, + "expiry_date": null, + "url": "https://www.dealnews.com/Nike-Mens-Jordan-Flight-Court-Shoes-for-52-free-shipping/22337799.html", + "image_url": null, + "votes": 0, + "verified": 0, + "created_at": "2026-09-12T17:03:46.000Z", + "source": "nichedb", + "source_id": "dealnews-latest:22337799", + "code_checked_at": "2026-09-13T01:20:00.000Z", + "code_source": null, + "store_name": "Dick's Sporting Goods", + "store_slug": "dicks-sporting-goods", + "store_logo": "https://www.google.com/s2/favicons?domain=dickssportinggoods.com&sz=128" + }, + { + "id": 796, + "store_id": 144, + "code": null, + "title": "$10 off $60 Woolino", + "description": "https://prz.io/O5oZ5jeCL", + "discount": "$10 off", + "discount_type": "fixed", + "discount_value": 10, + "expiry_date": null, + "url": "https://prz.io/O5oZ5jeCL", + "image_url": null, + "votes": 0, + "verified": 0, + "created_at": "2026-09-13T10:25:40+00:00", + "source": "reddit", + "source_id": "couponcodes:1wf40lq", + "code_checked_at": null, + "code_source": null, + "store_name": "Woolino", + "store_slug": "woolino", + "store_logo": null + }, + { + "id": 801, + "store_id": 147, + "code": "RC-670718-74969-31", + "title": "Deutscher Starlink-Gutschein: Hol dir einen kostenlosen Bonusmonat", + "description": "Starlink-Gutschein: https://www.starlink.com/?referral=RC-670718-74969-31 Der Gratismonat wird nicht auf der Landingpage angezeigt, aber sobald du deine Adresse eingibst und weiterklickst, erscheint er im Checkout-Bereich.", + "discount": null, + "discount_type": null, + "discount_value": null, + "expiry_date": null, + "url": "https://www.starlink.com/?referral=RC-670718-74969-31", + "image_url": null, + "votes": 0, + "verified": 0, + "created_at": "2026-09-11T03:12:09+00:00", + "source": "reddit", + "source_id": "couponcodes:1wd4fc2", + "code_checked_at": null, + "code_source": null, + "store_name": "Starlink", + "store_slug": "starlink", + "store_logo": "https://www.google.com/s2/favicons?domain=starlink.com&sz=128" + } +] diff --git a/packages/core/src/seed.js b/packages/core/src/seed.js index 1f19be7..1fcf841 100644 --- a/packages/core/src/seed.js +++ b/packages/core/src/seed.js @@ -183,6 +183,12 @@ export const COLLECTIONS = [ description: 'What is on sale and which coupon codes work right now, from the deal communities and editorial deal desks that publish keyless feeds: the Slickdeals front page, popular list and coupon-code search, DealNews with the retailer, price and expiry as fields, Dealcatcher, Ben’s Bargains, and r/deals and r/coupons. Every row names its store under the same key across sources, and a code written in the post is lifted into a field, so a coupon site can list it without reading the thread.', }, + { + slug: 'coupons', + name: 'Coupons', + description: + 'The coupons c0upons.com took in on its own: codes people submitted on the site and the listings it reads from r/couponcodes every five minutes, each under its store key with the code, discount and expiry as fields and a link to the coupon page. What c0upons copied from the deals collection stays in deals and is not repeated here.', + }, { slug: 'sports', name: 'Sports', @@ -1548,6 +1554,20 @@ export const DEFAULT_FEEDS = [ name: 'Videos to judge', query: { kinds: ['submission'], tags: ['video'] }, }, + { + collection: 'coupons', + slug: 'coupons-latest', + name: 'Newest coupons', + description: 'Every coupon c0upons.com took in on its own, newest first.', + query: { kinds: ['coupon'] }, + }, + { + collection: 'coupons', + slug: 'coupons-with-codes', + name: 'Coupons with a code', + description: 'Only the coupons that carry a code to type at checkout.', + query: { kinds: ['coupon'], tags: ['coupon-code'] }, + }, { collection: 'forums', slug: 'forum-posts', name: 'Forum posts', query: { kinds: ['post'] } }, { collection: 'forums', diff --git a/test/adapters.test.js b/test/adapters.test.js index 5587391..7ec70fb 100644 --- a/test/adapters.test.js +++ b/test/adapters.test.js @@ -75,6 +75,7 @@ describe('registry', () => { 'profiles', 'directory', 'sites', + 'coupons', ]).toContain(a.collection); } expect(adapterByName('steam').title).toContain('Steam'); diff --git a/test/house-aggregators.test.js b/test/house-aggregators.test.js index 0348910..9e3c090 100644 --- a/test/house-aggregators.test.js +++ b/test/house-aggregators.test.js @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { readFile } from 'node:fs/promises'; import * as agenticjobs from '../packages/adapters/src/agenticjobs.js'; import * as aiornot from '../packages/adapters/src/aiornot.js'; +import * as c0upons from '../packages/adapters/src/c0upons.js'; import { ADAPTERS, adapterByName } from '../packages/adapters/src/index.js'; import * as marketplace from '../packages/adapters/src/marketplacefeeds.js'; import * as p0dcasters from '../packages/adapters/src/p0dcasters.js'; @@ -59,6 +60,7 @@ const HOUSE = { aiornot: { collection: 'ai-media', kinds: ['submission'], source: 'aiornot-media' }, agenticjobs: { collection: 'jobs', kinds: ['job'], source: 'agenticjobs-postings' }, tsbb: { collection: 'forums', kinds: ['post'], source: 'tsbb-topics' }, + c0upons: { collection: 'coupons', kinds: ['coupon'], source: 'c0upons-coupons' }, }; describe('registration', () => { @@ -78,8 +80,8 @@ describe('registration', () => { }); } - test('the four new collections exist and every seeded feed names one that does', () => { - for (const slug of ['saas', 'marketplace', 'ai-media', 'forums']) { + test('the five house collections exist and every seeded feed names one that does', () => { + for (const slug of ['saas', 'marketplace', 'ai-media', 'forums', 'coupons']) { expect(COLLECTIONS.some((c) => c.slug === slug)).toBe(true); } const collections = new Set(COLLECTIONS.map((c) => c.slug)); @@ -430,3 +432,84 @@ describe('tsbb', () => { expect(out.items.length).toBe(1); }); }); + +describe('c0upons', () => { + test('a submitted coupon carries its store, code and discount, and links to its page', async () => { + const rows = JSON.parse(await fixture('c0upons-coupons.json')); + const it = c0upons.toItem(rows[0]); + expect(it.kind).toBe('coupon'); + expect(it.externalId).toBe('647'); + expect(it.title).toBe( + 'ChatGPT Business: 2 seats for the price of 1 for 48 months ($25/mo off)', + ); + expect(it.url).toBe('https://c0upons.com/coupons/647'); + expect(it.tags).toEqual([ + 'c0upons', + 'openai', + 'source:submitted', + 'coupon-code', + 'discount:fixed', + ]); + expect(it.data.store).toBe('OpenAI'); + expect(it.data.storeKey).toBe('openai'); + expect(it.data.storeDomain).toBeNull(); + expect(it.data.code).toBe('tdsynnexus'); + expect(it.data.discountType).toBe('fixed'); + expect(it.data.discountValue).toBe(25); + expect(it.data.dealUrl).toBe('https://chatgpt.com/?promoCode=tdsynnexus'); + expect(it.data.source).toBe('submitted'); + expect(it.publishedAt).toBeInstanceOf(Date); + expect(normaliseItem(it)).toBeTruthy(); + }); + + test('a row c0upons copied from this database is not read back', async () => { + const rows = JSON.parse(await fixture('c0upons-coupons.json')); + expect(rows[1].source).toBe('nichedb'); + expect(c0upons.toItem(rows[1])).toBeNull(); + expect(c0upons.toItem({ id: 'x', title: 'no id' })).toBeNull(); + expect(c0upons.toItem({ id: 5, title: ' ' })).toBeNull(); + }); + + test('a reddit listing keeps its source, the deal link and the store domain the favicon names', async () => { + const rows = JSON.parse(await fixture('c0upons-coupons.json')); + const woolino = c0upons.toItem(rows[2]); + expect(woolino.tags).toEqual(['c0upons', 'woolino', 'source:reddit', 'discount:fixed']); + expect(woolino.data.code).toBeNull(); + expect(woolino.data.sourceId).toBe('couponcodes:1wf40lq'); + expect(woolino.data.dealUrl).toBe('https://prz.io/O5oZ5jeCL'); + expect(woolino.imageUrl).toBeNull(); + + const starlink = c0upons.toItem(rows[3]); + expect(starlink.data.storeDomain).toBe('starlink.com'); + expect(starlink.imageUrl).toBe('https://www.google.com/s2/favicons?domain=starlink.com&sz=128'); + expect(c0upons.domainFromLogo('https://example.com/logo.png')).toBeNull(); + }); + + test('the walk reads whole pages, keeps what is not ours, and stops at a short page', async () => { + const rows = JSON.parse(await fixture('c0upons-coupons.json')); + const lines = []; + const { ctx: c, seen } = ctx( + 'c0upons', + { + 'https://c0upons.com/api/coupons?limit=200&offset=0': JSON.stringify(rows), + }, + { log: (m) => lines.push(m) }, + ); + const out = await adapterByName('c0upons').pull(c); + expect(seen).toEqual(['https://c0upons.com/api/coupons?limit=200&offset=0']); + expect(out.items.map((i) => i.externalId)).toEqual(['647', '796', '801']); + expect(lines[0]).toBe('3 coupons of 4 read, 1 already ours'); + }); + + test('a full page asks for the next one and a repeat across the boundary counts once', async () => { + const rows = JSON.parse(await fixture('c0upons-coupons.json')); + const full = Array.from({ length: 200 }, (_, i) => ({ ...rows[2], id: 1000 + i })); + const { ctx: c, seen } = ctx('c0upons', { + 'https://c0upons.com/api/coupons?limit=200&offset=0': JSON.stringify(full), + 'https://c0upons.com/api/coupons?limit=200&offset=200': JSON.stringify([full[199], rows[0]]), + }); + const out = await adapterByName('c0upons').pull(c); + expect(seen.length).toBe(2); + expect(out.items.length).toBe(201); + }); +});