Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
| --- | --- | --- | --- | --- |
Expand All @@ -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.
Expand Down
180 changes: 180 additions & 0 deletions packages/adapters/src/c0upons.js
Original file line number Diff line number Diff line change
@@ -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` };
},
});
2 changes: 2 additions & 0 deletions packages/adapters/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
94 changes: 94 additions & 0 deletions packages/adapters/test/fixtures/c0upons-coupons.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
20 changes: 20 additions & 0 deletions packages/core/src/seed.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions test/adapters.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ describe('registry', () => {
'profiles',
'directory',
'sites',
'coupons',
]).toContain(a.collection);
}
expect(adapterByName('steam').title).toContain('Steam');
Expand Down
Loading
Loading