openvibers/openvibe.sources Commits
Sources: extract facts a feed's items state (coupon codes from DealNews for Coupons) (#45)
Changes
@@@ -126,6 +126,15 @@ number or boolean, or an array of up to 20 of them, matching any): a record thatcondition is left out before `maxItems` is counted and reported as `meta.filtered`, e.g.`"where": { "productFamily": "Lightsail Instance", "attributes.operatingSystem": "Linux" }`.++**Facts stated in prose (`extract`, rss/atom only).** A feed endpoint may carry++`extract: { kind, require, fields }` ([server/adapters/extract.js](server/adapters/extract.js)). Each field is read from++the item's own `title` or `summary` by a pattern with one capturing group. With `as: "date"`, "October 20" becomes++2026-10-20: without a year, the first such day on or after the item's publication. An item that yields every `require`d++field is stored as `kind` with those fields; any other item is skipped and counted in the run's `items_skipped` (the++plain feed source keeps it). The registry refuses a pattern that does not compile or has no group, an unknown option,++and `extract` on an api source. `dealnews-coupon-codes` (category `coupons`) is the first: the code, the shop and the++stated end date of the DealNews deals that state a code, exactly as written.++### ItemsEach item: `source_key`, `category`, `kind`, `identity`, `canonical_url` (tracking parameters and@@@ -193,6 +202,7 @@ person before enabling:|---|---|---|---|| `nasa-news-releases` | news | rss | nasa.gov news-release feed || `nodejs-blog` | blog | rss | nodejs.org blog feed |++| `dealnews-coupon-codes` | coupons | rss | The DealNews feed with `extract`: only items that state a coupon code and the shop, as kind `coupon` (code, merchant, expires) || `steam-reviews-portal-2` | reviews | api | Steam `appreviews` for app 620 — signals only, review text not mapped || `steam-reviews-<game>` (18 more, 2026-10-14) | reviews | api | The same Steam endpoint, terms and mapping for Portal, Stardew Valley, Terraria, Hollow Knight, Hades, Slay the Spire, The Witcher 3, Baldur's Gate 3, Counter-Strike 2, Dota 2, Elden Ring, Celeste, Dead Cells, Valheim, Half-Life 2, Vampire Survivors, Outer Wilds and Lethal Company (app ids checked against Steam's appdetails) || `dealnews-daily` | deals | rss | dealnews.com RSS (Crawl-delay 2) |@@@ -134,6 +134,65 @@"type": "string"},"description": "Extra fields copied as the API gave them (scalars). A path is relative to the record; $key is the map key; $root.<path> reads the response from its root, where a {$key} segment is the record's map key; * steps into an object's single child."++ },++ "extract": {++ "type": "object",++ "additionalProperties": false,++ "required": [++ "kind",++ "require",++ "fields"++ ],++ "description": "rss and atom sources only (Sources#45): facts an item states in its own title or summary, read by the source's patterns. An item that yields every REQUIRE'd field is stored as KIND with those fields; any other item is skipped. Each field's PATTERN has one capturing group; AS date turns a stated day such as October 20 into YYYY-MM-DD (without a year, the first such day on or after the item's publication).",++ "properties": {++ "kind": {++ "type": "string",++ "pattern": "^[a-z][a-z0-9_]{0,40}$"++ },++ "require": {++ "type": "array",++ "minItems": 1,++ "maxItems": 12,++ "items": {++ "type": "string",++ "pattern": "^[a-z][a-z0-9_]{0,40}$"++ }++ },++ "fields": {++ "type": "object",++ "minProperties": 1,++ "maxProperties": 12,++ "propertyNames": {++ "pattern": "^[a-z][a-z0-9_]{0,40}$"++ },++ "additionalProperties": {++ "type": "object",++ "additionalProperties": false,++ "required": [++ "from",++ "pattern"++ ],++ "properties": {++ "from": {++ "enum": [++ "title",++ "summary"++ ]++ },++ "pattern": {++ "type": "string",++ "minLength": 1,++ "maxLength": 300++ },++ "as": {++ "enum": [++ "date"++ ]++ }++ }++ }++ }++ }}}}@@@ -12,7 +12,7 @@"dotenv": "^16.4.7","express": "^4.21.2","fast-xml-parser": "^4.5.3",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.146.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.163.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3","pg": "^8.23.0"@@@ -769,9 +769,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.146.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.146.0",-- "integrity": "sha512-A/JyuuPvQd0JytCOnjSfO6GQgHuNsg8UjAsWLKriSuiDpHQKHvZFsxZ+U/riFN7H5KDxgMB8Okv2nLRyLuNIAQ==",++ "version": "0.163.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.163.0",++ "integrity": "sha512-ASRT/sE3h9O+1sXXlLUKY5RfKmceqCSGKiM1iV4o+OZYBbORwwU+gMon4kEC2tar0/8Z/X/fIPNFufxBStK8bA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -27,7 +27,7 @@"dotenv": "^16.4.7","express": "^4.21.2","fast-xml-parser": "^4.5.3",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.146.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.163.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3","pg": "^8.23.0"@@@ -1054,6 +1054,49 @@"review_required": true,"sensitivity": "none","default_indexability": "noindex"++ },++ {++ "key": "dealnews-coupon-codes",++ "name": "DealNews: coupon codes its deals state",++ "type": "rss",++ "category": "coupons",++ "homepage_url": "https://www.dealnews.com/",++ "endpoints": [++ {++ "url": "https://www.dealnews.com/?rss=1",++ "extract": {++ "kind": "coupon",++ "require": [++ "code",++ "merchant"++ ],++ "fields": {++ "code": {++ "from": "summary",++ "pattern": "(?:coupon|promo|discount) code \"([A-Z0-9][A-Z0-9-]{3,31})\""++ },++ "expires": {++ "from": "summary",++ "pattern": "(?:Deal|Sale|Offer|It) ends ([A-Z][a-z]{2,8}\\.? \\d{1,2}(?:, \\d{4})?)",++ "as": "date"++ },++ "merchant": {++ "from": "summary",++ "pattern": "(?:Buy|Shop) Now at ([A-Z][A-Za-z0-9&.' -]{1,40}?)(?= Features| Shop| Buy|\\.|$)"++ }++ }++ }++ }++ ],++ "robots_note": "robots.txt checked 2026-09-22: Crawl-delay 2 (honoured); the feed URL is not disallowed.",++ "terms_note": "The same feed and terms as dealnews-daily (Copyright DealNews, all rights reserved). Only items whose text states a coupon code and the shop are kept, as kind coupon with the code, the shop's name and the stated end date exactly as written, plus the headline and link; never a claim that a code works. Verify terms before enabling.",++ "min_interval_ms": 60000,++ "poll_interval_sec": 3600,++ "max_items": 100,++ "enabled": false,++ "review_required": true,++ "sensitivity": "none",++ "default_indexability": "noindex"}]}nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++/**++ * `extract` on an rss/atom endpoint: fields read out of each item's own title or summary by a pattern, for a source++ * whose items state structured facts in prose (DealNews: 'Apply coupon code "UXBMXG9V" … Deal ends October 20 … at++ * Amazon'). An item that yields every `require`d field becomes `kind` with those fields; any other item is skipped++ * (it is another source's item: the plain feed keeps it). Nothing is inferred: a field is exactly the text the++ * pattern captured, or a date the item states.++ *++ * endpoint.extract = {++ * kind: 'coupon',++ * require: ['code'],++ * fields: {++ * code: { from: 'summary', pattern: '(?:coupon|promo) code "([A-Z0-9-]{4,32})"' },++ * expires: { from: 'summary', pattern: 'ends ([A-Z][a-z]+ \\d{1,2}(?:, \\d{4})?)', as: 'date' },++ * merchant: { from: 'summary', pattern: 'Buy Now at ([A-Z][\\w&.\' -]{1,40}?)(?: Features|\\.|$)' },++ * },++ * }++ *++ * `as: 'date'` turns "October 20" or "October 20, 2026" into 2026-10-20: without a year, the first such day on or++ * after the item's own publication date (a sale that ends "October 20" published in December ends next year).++ */++const FROM = new Set(['title', 'summary']);++const NAME = /^[a-z][a-z0-9_]{0,40}$/;++const MONTHS = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'];++++/** Why `extract` is not usable, or null. */++function check(x) {++ if (!x || typeof x !== 'object' || Array.isArray(x)) return 'extract must be an object';++ for (const k of Object.keys(x)) if (!['kind', 'require', 'fields'].includes(k)) return `extract.${k} is not known`;++ if (typeof x.kind !== 'string' || !NAME.test(x.kind)) return 'extract.kind is a short lower-case name';++ if (!x.fields || typeof x.fields !== 'object' || Array.isArray(x.fields)) return 'extract.fields is an object';++ const names = Object.keys(x.fields);++ if (!names.length || names.length > 12) return 'extract.fields has 1 to 12 fields';++ for (const n of names) {++ const f = x.fields[n];++ if (!NAME.test(n)) return `extract.fields.${n}: a field name is a short lower-case name`;++ if (!f || typeof f !== 'object') return `extract.fields.${n} is an object`;++ for (const k of Object.keys(f)) if (!['from', 'pattern', 'as'].includes(k)) return `extract.fields.${n}.${k} is not known`;++ if (!FROM.has(f.from)) return `extract.fields.${n}.from is title or summary`;++ if (typeof f.pattern !== 'string' || !f.pattern || f.pattern.length > 300) return `extract.fields.${n}.pattern is a regular expression of at most 300 characters`;++ try {++ if (!/\((?!\?)/.test(f.pattern)) return `extract.fields.${n}.pattern needs one capturing group`;++ new RegExp(f.pattern, 'i');++ } catch (e) { return `extract.fields.${n}.pattern: ${e.message}`; }++ if (f.as !== undefined && f.as !== 'date') return `extract.fields.${n}.as is date`;++ }++ if (!Array.isArray(x.require) || !x.require.length || !x.require.every((r) => names.includes(r))) return 'extract.require lists one or more of its fields';++ return null;++}++++/** "October 20" / "Oct 20, 2026" → YYYY-MM-DD, the first such day on or after `published` (ISO), or null. */++function statedDate(text, published) {++ const m = /^([A-Za-z]{3,9})\.? (\d{1,2})(?:, (\d{4}))?$/.exec(String(text).trim());++ if (!m) return null;++ const month = MONTHS.findIndex((x) => x.startsWith(m[1].toLowerCase().slice(0, 3)) && (m[1].length === 3 || x === m[1].toLowerCase()));++ const day = Number(m[2]);++ if (month < 0 || day < 1 || day > 31) return null;++ const base = published ? new Date(published) : new Date();++ let year = m[3] ? Number(m[3]) : base.getUTCFullYear();++ const at = (y) => new Date(Date.UTC(y, month, day));++ if (!m[3] && at(year).getTime() < Date.UTC(base.getUTCFullYear(), base.getUTCMonth(), base.getUTCDate())) year += 1;++ const d = at(year);++ if (d.getUTCMonth() !== month) return null; // February 30++ return d.toISOString().slice(0, 10);++}++++/** The items that yield every required field, as `kind` with those fields. → { items, skipped } */++function apply(items, x) {++ const rules = Object.entries(x.fields).map(([name, f]) => ({ name, from: f.from, re: new RegExp(f.pattern, 'i'), as: f.as }));++ const out = [];++ let skipped = 0;++ for (const it of items) {++ const fields = {};++ for (const r of rules) {++ const m = r.re.exec(String(it[r.from] || ''));++ if (!m || m[1] == null) continue;++ const v = m[1].trim().slice(0, 300);++ const value = r.as === 'date' ? statedDate(v, it.published_at) : v;++ if (value) fields[r.name] = value;++ }++ if (!x.require.every((n) => fields[n])) { skipped++; continue; }++ out.push({ ...it, kind: x.kind, fields: { ...(it.fields || {}), ...fields } });++ }++ return { items: out, skipped };++}++++module.exports = { check, apply, statedDate };@@@ -19,6 +19,7 @@*/const { ids } = require('openvibe-contracts');const { ADAPTERS } = require('./adapters');++const extract = require('./adapters/extract');const { sha256, decodeBody } = require('./adapters/util');const FAILURE_STATES = new Set(['http_error', 'timeout', 'robots_denied', 'parse_error', 'rate_limited']);@@@ -178,6 +179,11 @@ function createIngest({ db, config, registry, items, robots, fetcher, spacer, emtry {const text = decodeBody(res.body, res.headers['content-type']);parsed = adapter.parse(text, { url: res.finalUrl, endpoint, summaryMax: config.items.summaryMax, maxItems: source.max_items });++ // An endpoint that reads facts out of its items' prose keeps only the items that state them (adapters/extract.js).++ if (endpoint.extract) {++ const x = extract.apply(parsed.items, endpoint.extract);++ parsed = { ...parsed, items: x.items, skipped: parsed.skipped + x.skipped };++ }} catch (err) {Object.assign(run, { state: 'parse_error', error_code: err.code === 'parse_error' ? 'unreadable' : 'adapter_error', detail: String(err.message).slice(0, 500) });if (err.code !== 'parse_error') log.error(`[ingest] ${source.key}: adapter crashed: ${err.stack || err}`);@@@ -10,6 +10,7 @@* an adapter existing is not permission to ingest (roadmap anti-goal 26).*/const { checkApiMapping } = require('./adapters');++const extract = require('./adapters/extract');const TYPES = ['rss', 'atom', 'sitemap', 'jsonld', 'api', 'manual'];const CATEGORIES = ['news', 'blog', 'reviews', 'deals', 'coupons', 'trade', 'hosting'];@@@ -77,7 +78,13 @@ function validateSource(input, { maxItemsCap = 500 } = {}) {// `where` is only copied when given, so records without conditions stay byte-identical.if (e.where) out.where = e.where;} else {-- for (const k of Object.keys(e)) if (k !== 'url') throw new RegistryError(`endpoints[${i}].${k} is only for api sources`);++ // A feed may read structured facts out of its items' prose (adapters/extract.js).++ for (const k of Object.keys(e)) if (k !== 'url' && !(k === 'extract' && (input.type === 'rss' || input.type === 'atom'))) throw new RegistryError(`endpoints[${i}].${k} is only for api sources`);++ if (e.extract !== undefined) {++ const err = extract.check(e.extract);++ if (err) throw new RegistryError(`endpoints[${i}]: ${err}`);++ out.extract = e.extract;++ }}return out;});nnew file mode 100644@@@ -0,0 +1,75 @@++'use strict';++/**++ * `extract` on a feed endpoint (server/adapters/extract.js): facts a feed's items state in prose — a coupon code, the++ * shop, the stated end date — read by the source's own patterns. An item that states every required field becomes++ * that kind with those fields; any other item is not this source's. Dates without a year land on or after the item's++ * publication. The registry refuses a broken pattern, an unknown option, or extract on an api source; the seed++ * (dealnews-coupon-codes) reads real DealNews wording.++ */++const assert = require('assert');++const extract = require('../server/adapters/extract');++const { boot, site, sourceDef, rss, suite } = require('./helpers');++const seeds = require('../seeds/sources.json').sources;++++const t = suite('extract');++const SEED = seeds.find((s) => s.key === 'dealnews-coupon-codes');++const X = SEED.endpoints[0].extract;++// Real DealNews item text (2026-10-14), one that names no code, and one with a code but no shop.++const ITEMS = [++ { identity: 'd1', kind: 'article', title: 'Bestek 300W DC to AC Car Power Inverter for $19 + free shipping w/ Prime', published_at: '2026-10-14T12:00:00.000Z',++ summary: 'Handy for road trips, and at $19 it\'s $16 below the $35 list price. Apply coupon code "UXBMXG9V" for a savings of $16. Deal ends October 20. Buy Now at Amazon Features 300W continuous', fields: { categories: [] } },++ { identity: 'd2', kind: 'article', title: 'Office 2024', published_at: '2026-10-14T12:00:00.000Z',++ summary: 'DigitalKeysBox offers Microsoft Office Standard 2024 for just $11 when you apply promo code "DEALNEWSOCT11" at checkout. Buy Now at DigitalKeysBox', fields: {} },++ { identity: 'd3', kind: 'article', title: 'A sale', published_at: '2026-10-14T12:00:00.000Z', summary: 'Up to 60% off everything. Buy Now at Harbor Freight', fields: {} },++ { identity: 'd4', kind: 'article', title: 'A code with no shop', published_at: '2026-10-14T12:00:00.000Z', summary: 'Apply coupon code "NOSHOP12" to save.', fields: {} }, { identity: 'd5', kind: 'article', title: 'Shake Shack', published_at: '2026-10-14T12:00:00.000Z', summary: 'Get a free Single Cheeseburger with $10 purchase by applying promo code "SCARYGOOD". Shop Now at Shake Shack', fields: {} },++];++++t('the DealNews seed keeps the items that state a code and a shop, with exactly what they state', async () => {++ assert.strictEqual(extract.check(X), null);++ const out = extract.apply(ITEMS, X);++ assert.strictEqual(out.skipped, 2);++ assert.deepStrictEqual(out.items.map((i) => [i.identity, i.kind, i.fields.code, i.fields.merchant, i.fields.expires || null]),++ [['d1', 'coupon', 'UXBMXG9V', 'Amazon', '2026-10-20'], ['d2', 'coupon', 'DEALNEWSOCT11', 'DigitalKeysBox', null], ['d5', 'coupon', 'SCARYGOOD', 'Shake Shack', null]]);++ assert.deepStrictEqual(out.items[0].fields.categories, [], 'the feed\'s own fields stay');++});++++t('a stated date without a year lands on or after the item\'s publication', async () => {++ assert.strictEqual(extract.statedDate('October 20', '2026-10-14T00:00:00Z'), '2026-10-20');++ assert.strictEqual(extract.statedDate('January 5', '2026-12-20T00:00:00Z'), '2027-01-05');++ assert.strictEqual(extract.statedDate('Oct. 20, 2027', '2026-10-14T00:00:00Z'), '2027-10-20');++ assert.strictEqual(extract.statedDate('February 30', '2026-01-01T00:00:00Z'), null);++ assert.strictEqual(extract.statedDate('soon', '2026-01-01T00:00:00Z'), null);++});++++t('a broken or unknown extract is refused', async () => {++ const bad = [null, [], { kind: 'Coupon', require: ['code'], fields: { code: { from: 'summary', pattern: '(x)' } } },++ { kind: 'coupon', require: ['code'], fields: { code: { from: 'body', pattern: '(x)' } } },++ { kind: 'coupon', require: ['code'], fields: { code: { from: 'summary', pattern: 'no group' } } },++ { kind: 'coupon', require: ['code'], fields: { code: { from: 'summary', pattern: '(unclosed' } } },++ { kind: 'coupon', require: ['other'], fields: { code: { from: 'summary', pattern: '(x)' } } },++ { kind: 'coupon', require: ['code'], fields: { code: { from: 'summary', pattern: '(x)', as: 'number' } } },++ { kind: 'coupon', require: ['code'], fields: { code: { from: 'summary', pattern: '(x)' } }, more: 1 }];++ for (const b of bad) assert.ok(extract.check(b), JSON.stringify(b));++});++++t('through the registry and a real run: only coupon items are stored, with their fields', async () => {++ const svc = await boot();++ const body = rss(ITEMS.map((i) => ({ guid: i.identity, title: i.title, link: `https://www.dealnews.example/${i.identity}`, date: 'Wed, 14 Oct 2026 12:00:00 +0000', description: i.summary })));++ const web = await site({ '/robots.txt': () => ({ body: 'User-agent: *\nAllow: /\n' }), '/feed.xml': () => ({ headers: { 'Content-Type': 'application/rss+xml' }, body }) });++ const def = sourceDef({ key: 'coupons-x', category: 'coupons', endpoints: [{ url: `${web.origin}/feed.xml`, extract: X }] });++ await svc.registry.create(def, 'test');++ await assert.rejects(svc.registry.create(sourceDef({ key: 'coupons-y', endpoints: [{ url: `${web.origin}/feed.xml`, extract: { ...X, fields: { code: { from: 'summary', pattern: '(' } } } }] }), 'test'), /pattern/);++ await assert.rejects(svc.registry.create(sourceDef({ key: 'coupons-z', type: 'api', endpoints: [{ url: `${web.origin}/feed.xml`, extract: X }] }), 'test'));++ const out = await svc.ingest.run('coupons-x', { trigger: 'manual' });++ assert.deepStrictEqual(out.runs.map((r) => r.state), ['ok']);++ const rows = await svc.db.prepare("SELECT identity, kind, fields FROM items WHERE source_key = 'coupons-x' ORDER BY identity").all();++ assert.deepStrictEqual(rows.map((r) => [r.identity, r.kind]), [['d1', 'coupon'], ['d2', 'coupon'], ['d5', 'coupon']]);++ const f = typeof rows[0].fields === 'string' ? JSON.parse(rows[0].fields) : rows[0].fields;++ assert.deepStrictEqual([f.code, f.merchant, f.expires], ['UXBMXG9V', 'Amazon', '2026-10-20']);++ const [run] = out.runs;++ assert.strictEqual(run.items_skipped, 2);++ await web.close();++ await svc.stop();++});++++t.run();
@@ -126,6 +126,15 @@ number or boolean, or an array of up to 20 of them, matching any): a record that
@@ -126,6 +126,15 @@ number or boolean, or an array of up to 20 of them, matching any): a record that
condition is left out before `maxItems` is counted and reported as `meta.filtered`, e.g.
condition is left out before `maxItems` is counted and reported as `meta.filtered`, e.g.
`"where": { "productFamily": "Lightsail Instance", "attributes.operatingSystem": "Linux" }`.
`"where": { "productFamily": "Lightsail Instance", "attributes.operatingSystem": "Linux" }`.
+**Facts stated in prose (`extract`, rss/atom only).** A feed endpoint may carry
+`extract: { kind, require, fields }` ([server/adapters/extract.js](server/adapters/extract.js)). Each field is read from
+the item's own `title` or `summary` by a pattern with one capturing group. With `as: "date"`, "October 20" becomes
+2026-10-20: without a year, the first such day on or after the item's publication. An item that yields every `require`d
+field is stored as `kind` with those fields; any other item is skipped and counted in the run's `items_skipped` (the
+plain feed source keeps it). The registry refuses a pattern that does not compile or has no group, an unknown option,
+and `extract` on an api source. `dealnews-coupon-codes` (category `coupons`) is the first: the code, the shop and the
+stated end date of the DealNews deals that state a code, exactly as written.
+
### Items
### Items
Each item: `source_key`, `category`, `kind`, `identity`, `canonical_url` (tracking parameters and
Each item: `source_key`, `category`, `kind`, `identity`, `canonical_url` (tracking parameters and
@@ -193,6 +202,7 @@ person before enabling:
@@ -193,6 +202,7 @@ person before enabling:
|---|---|---|---|
|---|---|---|---|
| `nasa-news-releases` | news | rss | nasa.gov news-release feed |
| `nasa-news-releases` | news | rss | nasa.gov news-release feed |
| `nodejs-blog` | blog | rss | nodejs.org blog feed |
| `nodejs-blog` | blog | rss | nodejs.org blog feed |
+| `dealnews-coupon-codes` | coupons | rss | The DealNews feed with `extract`: only items that state a coupon code and the shop, as kind `coupon` (code, merchant, expires) |
| `steam-reviews-portal-2` | reviews | api | Steam `appreviews` for app 620 — signals only, review text not mapped |
| `steam-reviews-portal-2` | reviews | api | Steam `appreviews` for app 620 — signals only, review text not mapped |
| `steam-reviews-<game>` (18 more, 2026-10-14) | reviews | api | The same Steam endpoint, terms and mapping for Portal, Stardew Valley, Terraria, Hollow Knight, Hades, Slay the Spire, The Witcher 3, Baldur's Gate 3, Counter-Strike 2, Dota 2, Elden Ring, Celeste, Dead Cells, Valheim, Half-Life 2, Vampire Survivors, Outer Wilds and Lethal Company (app ids checked against Steam's appdetails) |
| `steam-reviews-<game>` (18 more, 2026-10-14) | reviews | api | The same Steam endpoint, terms and mapping for Portal, Stardew Valley, Terraria, Hollow Knight, Hades, Slay the Spire, The Witcher 3, Baldur's Gate 3, Counter-Strike 2, Dota 2, Elden Ring, Celeste, Dead Cells, Valheim, Half-Life 2, Vampire Survivors, Outer Wilds and Lethal Company (app ids checked against Steam's appdetails) |
| `dealnews-daily` | deals | rss | dealnews.com RSS (Crawl-delay 2) |
| `dealnews-daily` | deals | rss | dealnews.com RSS (Crawl-delay 2) |
@@ -134,6 +134,65 @@
@@ -134,6 +134,65 @@
"type": "string"
"type": "string"
},
},
"description": "Extra fields copied as the API gave them (scalars). A path is relative to the record; $key is the map key; $root.<path> reads the response from its root, where a {$key} segment is the record's map key; * steps into an object's single child."
"description": "Extra fields copied as the API gave them (scalars). A path is relative to the record; $key is the map key; $root.<path> reads the response from its root, where a {$key} segment is the record's map key; * steps into an object's single child."
+ },
+ "extract": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "kind",
+ "require",
+ "fields"
+ ],
+ "description": "rss and atom sources only (Sources#45): facts an item states in its own title or summary, read by the source's patterns. An item that yields every REQUIRE'd field is stored as KIND with those fields; any other item is skipped. Each field's PATTERN has one capturing group; AS date turns a stated day such as October 20 into YYYY-MM-DD (without a year, the first such day on or after the item's publication).",
+ "properties": {
+ "kind": {
+ "type": "string",
+ "pattern": "^[a-z][a-z0-9_]{0,40}$"
+ },
+ "require": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 12,
+ "items": {
+ "type": "string",
+ "pattern": "^[a-z][a-z0-9_]{0,40}$"
+ }
+ },
+ "fields": {
+ "type": "object",
+ "minProperties": 1,
+ "maxProperties": 12,
+ "propertyNames": {
+ "pattern": "^[a-z][a-z0-9_]{0,40}$"
+ },
+ "additionalProperties": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "from",
+ "pattern"
+ ],
+ "properties": {
+ "from": {
+ "enum": [
+ "title",
+ "summary"
+ ]
+ },
+ "pattern": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 300
+ },
+ "as": {
+ "enum": [
+ "date"
+ ]
+ }
+ }
+ }
+ }
+ }
}
}
}
}
}
}
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"fast-xml-parser": "^4.5.3",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.146.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.163.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -769,9 +769,9 @@
@@ -769,9 +769,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.146.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.146.0",
- "integrity": "sha512-A/JyuuPvQd0JytCOnjSfO6GQgHuNsg8UjAsWLKriSuiDpHQKHvZFsxZ+U/riFN7H5KDxgMB8Okv2nLRyLuNIAQ==",
+ "version": "0.163.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.163.0",
+ "integrity": "sha512-ASRT/sE3h9O+1sXXlLUKY5RfKmceqCSGKiM1iV4o+OZYBbORwwU+gMon4kEC2tar0/8Z/X/fIPNFufxBStK8bA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"fast-xml-parser": "^4.5.3",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.146.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.163.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1054,6 +1054,49 @@
@@ -1054,6 +1054,49 @@
"review_required": true,
"review_required": true,
"sensitivity": "none",
"sensitivity": "none",
"default_indexability": "noindex"
"default_indexability": "noindex"
+ },
+ {
+ "key": "dealnews-coupon-codes",
+ "name": "DealNews: coupon codes its deals state",
+ "type": "rss",
+ "category": "coupons",
+ "homepage_url": "https://www.dealnews.com/",
+ "endpoints": [
+ {
+ "url": "https://www.dealnews.com/?rss=1",
+ "extract": {
+ "kind": "coupon",
+ "require": [
+ "code",
+ "merchant"
+ ],
+ "fields": {
+ "code": {
+ "from": "summary",
+ "pattern": "(?:coupon|promo|discount) code \"([A-Z0-9][A-Z0-9-]{3,31})\""
+ },
+ "expires": {
+ "from": "summary",
+ "pattern": "(?:Deal|Sale|Offer|It) ends ([A-Z][a-z]{2,8}\\.? \\d{1,2}(?:, \\d{4})?)",
+ "as": "date"
+ },
+ "merchant": {
+ "from": "summary",
+ "pattern": "(?:Buy|Shop) Now at ([A-Z][A-Za-z0-9&.' -]{1,40}?)(?= Features| Shop| Buy|\\.|$)"
+ }
+ }
+ }
+ }
+ ],
+ "robots_note": "robots.txt checked 2026-09-22: Crawl-delay 2 (honoured); the feed URL is not disallowed.",
+ "terms_note": "The same feed and terms as dealnews-daily (Copyright DealNews, all rights reserved). Only items whose text states a coupon code and the shop are kept, as kind coupon with the code, the shop's name and the stated end date exactly as written, plus the headline and link; never a claim that a code works. Verify terms before enabling.",
+ "min_interval_ms": 60000,
+ "poll_interval_sec": 3600,
+ "max_items": 100,
+ "enabled": false,
+ "review_required": true,
+ "sensitivity": "none",
+ "default_indexability": "noindex"
}
}
]
]
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+/**
+ * `extract` on an rss/atom endpoint: fields read out of each item's own title or summary by a pattern, for a source
+ * whose items state structured facts in prose (DealNews: 'Apply coupon code "UXBMXG9V" … Deal ends October 20 … at
+ * Amazon'). An item that yields every `require`d field becomes `kind` with those fields; any other item is skipped
+ * (it is another source's item: the plain feed keeps it). Nothing is inferred: a field is exactly the text the
+ * pattern captured, or a date the item states.
+ *
+ * endpoint.extract = {
+ * kind: 'coupon',
+ * require: ['code'],
+ * fields: {
+ * code: { from: 'summary', pattern: '(?:coupon|promo) code "([A-Z0-9-]{4,32})"' },
+ * expires: { from: 'summary', pattern: 'ends ([A-Z][a-z]+ \\d{1,2}(?:, \\d{4})?)', as: 'date' },
+ * merchant: { from: 'summary', pattern: 'Buy Now at ([A-Z][\\w&.\' -]{1,40}?)(?: Features|\\.|$)' },
+ * },
+ * }
+ *
+ * `as: 'date'` turns "October 20" or "October 20, 2026" into 2026-10-20: without a year, the first such day on or
+ * after the item's own publication date (a sale that ends "October 20" published in December ends next year).
+ */
+const FROM = new Set(['title', 'summary']);
+const NAME = /^[a-z][a-z0-9_]{0,40}$/;
+const MONTHS = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'];
+
+/** Why `extract` is not usable, or null. */
+function check(x) {
+ if (!x || typeof x !== 'object' || Array.isArray(x)) return 'extract must be an object';
+ for (const k of Object.keys(x)) if (!['kind', 'require', 'fields'].includes(k)) return `extract.${k} is not known`;
+ if (typeof x.kind !== 'string' || !NAME.test(x.kind)) return 'extract.kind is a short lower-case name';
+ if (!x.fields || typeof x.fields !== 'object' || Array.isArray(x.fields)) return 'extract.fields is an object';
+ const names = Object.keys(x.fields);
+ if (!names.length || names.length > 12) return 'extract.fields has 1 to 12 fields';
+ for (const n of names) {
+ const f = x.fields[n];
+ if (!NAME.test(n)) return `extract.fields.${n}: a field name is a short lower-case name`;
+ if (!f || typeof f !== 'object') return `extract.fields.${n} is an object`;
+ for (const k of Object.keys(f)) if (!['from', 'pattern', 'as'].includes(k)) return `extract.fields.${n}.${k} is not known`;
+ if (!FROM.has(f.from)) return `extract.fields.${n}.from is title or summary`;
+ if (typeof f.pattern !== 'string' || !f.pattern || f.pattern.length > 300) return `extract.fields.${n}.pattern is a regular expression of at most 300 characters`;
+ try {
+ if (!/\((?!\?)/.test(f.pattern)) return `extract.fields.${n}.pattern needs one capturing group`;
+ new RegExp(f.pattern, 'i');
+ } catch (e) { return `extract.fields.${n}.pattern: ${e.message}`; }
+ if (f.as !== undefined && f.as !== 'date') return `extract.fields.${n}.as is date`;
+ }
+ if (!Array.isArray(x.require) || !x.require.length || !x.require.every((r) => names.includes(r))) return 'extract.require lists one or more of its fields';
+ return null;
+}
+
+/** "October 20" / "Oct 20, 2026" → YYYY-MM-DD, the first such day on or after `published` (ISO), or null. */
+function statedDate(text, published) {
+ const m = /^([A-Za-z]{3,9})\.? (\d{1,2})(?:, (\d{4}))?$/.exec(String(text).trim());
+ if (!m) return null;
+ const month = MONTHS.findIndex((x) => x.startsWith(m[1].toLowerCase().slice(0, 3)) && (m[1].length === 3 || x === m[1].toLowerCase()));
+ const day = Number(m[2]);
+ if (month < 0 || day < 1 || day > 31) return null;
+ const base = published ? new Date(published) : new Date();
+ let year = m[3] ? Number(m[3]) : base.getUTCFullYear();
+ const at = (y) => new Date(Date.UTC(y, month, day));
+ if (!m[3] && at(year).getTime() < Date.UTC(base.getUTCFullYear(), base.getUTCMonth(), base.getUTCDate())) year += 1;
+ const d = at(year);
+ if (d.getUTCMonth() !== month) return null; // February 30
+ return d.toISOString().slice(0, 10);
+}
+
+/** The items that yield every required field, as `kind` with those fields. → { items, skipped } */
+function apply(items, x) {
+ const rules = Object.entries(x.fields).map(([name, f]) => ({ name, from: f.from, re: new RegExp(f.pattern, 'i'), as: f.as }));
+ const out = [];
+ let skipped = 0;
+ for (const it of items) {
+ const fields = {};
+ for (const r of rules) {
+ const m = r.re.exec(String(it[r.from] || ''));
+ if (!m || m[1] == null) continue;
+ const v = m[1].trim().slice(0, 300);
+ const value = r.as === 'date' ? statedDate(v, it.published_at) : v;
+ if (value) fields[r.name] = value;
+ }
+ if (!x.require.every((n) => fields[n])) { skipped++; continue; }
+ out.push({ ...it, kind: x.kind, fields: { ...(it.fields || {}), ...fields } });
+ }
+ return { items: out, skipped };
+}
+
+module.exports = { check, apply, statedDate };
@@ -19,6 +19,7 @@
@@ -19,6 +19,7 @@
*/
*/
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const { ADAPTERS } = require('./adapters');
const { ADAPTERS } = require('./adapters');
+const extract = require('./adapters/extract');
const { sha256, decodeBody } = require('./adapters/util');
const { sha256, decodeBody } = require('./adapters/util');
const FAILURE_STATES = new Set(['http_error', 'timeout', 'robots_denied', 'parse_error', 'rate_limited']);
const FAILURE_STATES = new Set(['http_error', 'timeout', 'robots_denied', 'parse_error', 'rate_limited']);
@@ -178,6 +179,11 @@ function createIngest({ db, config, registry, items, robots, fetcher, spacer, em
@@ -178,6 +179,11 @@ function createIngest({ db, config, registry, items, robots, fetcher, spacer, em
try {
try {
const text = decodeBody(res.body, res.headers['content-type']);
const text = decodeBody(res.body, res.headers['content-type']);
parsed = adapter.parse(text, { url: res.finalUrl, endpoint, summaryMax: config.items.summaryMax, maxItems: source.max_items });
parsed = adapter.parse(text, { url: res.finalUrl, endpoint, summaryMax: config.items.summaryMax, maxItems: source.max_items });
+ // An endpoint that reads facts out of its items' prose keeps only the items that state them (adapters/extract.js).
+ if (endpoint.extract) {
+ const x = extract.apply(parsed.items, endpoint.extract);
+ parsed = { ...parsed, items: x.items, skipped: parsed.skipped + x.skipped };
+ }
} catch (err) {
} catch (err) {
Object.assign(run, { state: 'parse_error', error_code: err.code === 'parse_error' ? 'unreadable' : 'adapter_error', detail: String(err.message).slice(0, 500) });
Object.assign(run, { state: 'parse_error', error_code: err.code === 'parse_error' ? 'unreadable' : 'adapter_error', detail: String(err.message).slice(0, 500) });
if (err.code !== 'parse_error') log.error(`[ingest] ${source.key}: adapter crashed: ${err.stack || err}`);
if (err.code !== 'parse_error') log.error(`[ingest] ${source.key}: adapter crashed: ${err.stack || err}`);
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
* an adapter existing is not permission to ingest (roadmap anti-goal 26).
* an adapter existing is not permission to ingest (roadmap anti-goal 26).
*/
*/
const { checkApiMapping } = require('./adapters');
const { checkApiMapping } = require('./adapters');
+const extract = require('./adapters/extract');
const TYPES = ['rss', 'atom', 'sitemap', 'jsonld', 'api', 'manual'];
const TYPES = ['rss', 'atom', 'sitemap', 'jsonld', 'api', 'manual'];
const CATEGORIES = ['news', 'blog', 'reviews', 'deals', 'coupons', 'trade', 'hosting'];
const CATEGORIES = ['news', 'blog', 'reviews', 'deals', 'coupons', 'trade', 'hosting'];
@@ -77,7 +78,13 @@ function validateSource(input, { maxItemsCap = 500 } = {}) {
@@ -77,7 +78,13 @@ function validateSource(input, { maxItemsCap = 500 } = {}) {
// `where` is only copied when given, so records without conditions stay byte-identical.
// `where` is only copied when given, so records without conditions stay byte-identical.
if (e.where) out.where = e.where;
if (e.where) out.where = e.where;
} else {
} else {
- for (const k of Object.keys(e)) if (k !== 'url') throw new RegistryError(`endpoints[${i}].${k} is only for api sources`);
+ // A feed may read structured facts out of its items' prose (adapters/extract.js).
+ for (const k of Object.keys(e)) if (k !== 'url' && !(k === 'extract' && (input.type === 'rss' || input.type === 'atom'))) throw new RegistryError(`endpoints[${i}].${k} is only for api sources`);
+ if (e.extract !== undefined) {
+ const err = extract.check(e.extract);
+ if (err) throw new RegistryError(`endpoints[${i}]: ${err}`);
+ out.extract = e.extract;
+ }
}
}
return out;
return out;
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+'use strict';
+/**
+ * `extract` on a feed endpoint (server/adapters/extract.js): facts a feed's items state in prose — a coupon code, the
+ * shop, the stated end date — read by the source's own patterns. An item that states every required field becomes
+ * that kind with those fields; any other item is not this source's. Dates without a year land on or after the item's
+ * publication. The registry refuses a broken pattern, an unknown option, or extract on an api source; the seed
+ * (dealnews-coupon-codes) reads real DealNews wording.
+ */
+const assert = require('assert');
+const extract = require('../server/adapters/extract');
+const { boot, site, sourceDef, rss, suite } = require('./helpers');
+const seeds = require('../seeds/sources.json').sources;
+
+const t = suite('extract');
+const SEED = seeds.find((s) => s.key === 'dealnews-coupon-codes');
+const X = SEED.endpoints[0].extract;
+// Real DealNews item text (2026-10-14), one that names no code, and one with a code but no shop.
+const ITEMS = [
+ { identity: 'd1', kind: 'article', title: 'Bestek 300W DC to AC Car Power Inverter for $19 + free shipping w/ Prime', published_at: '2026-10-14T12:00:00.000Z',
+ summary: 'Handy for road trips, and at $19 it\'s $16 below the $35 list price. Apply coupon code "UXBMXG9V" for a savings of $16. Deal ends October 20. Buy Now at Amazon Features 300W continuous', fields: { categories: [] } },
+ { identity: 'd2', kind: 'article', title: 'Office 2024', published_at: '2026-10-14T12:00:00.000Z',
+ summary: 'DigitalKeysBox offers Microsoft Office Standard 2024 for just $11 when you apply promo code "DEALNEWSOCT11" at checkout. Buy Now at DigitalKeysBox', fields: {} },
+ { identity: 'd3', kind: 'article', title: 'A sale', published_at: '2026-10-14T12:00:00.000Z', summary: 'Up to 60% off everything. Buy Now at Harbor Freight', fields: {} },
+ { identity: 'd4', kind: 'article', title: 'A code with no shop', published_at: '2026-10-14T12:00:00.000Z', summary: 'Apply coupon code "NOSHOP12" to save.', fields: {} }, { identity: 'd5', kind: 'article', title: 'Shake Shack', published_at: '2026-10-14T12:00:00.000Z', summary: 'Get a free Single Cheeseburger with $10 purchase by applying promo code "SCARYGOOD". Shop Now at Shake Shack', fields: {} },
+];
+
+t('the DealNews seed keeps the items that state a code and a shop, with exactly what they state', async () => {
+ assert.strictEqual(extract.check(X), null);
+ const out = extract.apply(ITEMS, X);
+ assert.strictEqual(out.skipped, 2);
+ assert.deepStrictEqual(out.items.map((i) => [i.identity, i.kind, i.fields.code, i.fields.merchant, i.fields.expires || null]),
+ [['d1', 'coupon', 'UXBMXG9V', 'Amazon', '2026-10-20'], ['d2', 'coupon', 'DEALNEWSOCT11', 'DigitalKeysBox', null], ['d5', 'coupon', 'SCARYGOOD', 'Shake Shack', null]]);
+ assert.deepStrictEqual(out.items[0].fields.categories, [], 'the feed\'s own fields stay');
+});
+
+t('a stated date without a year lands on or after the item\'s publication', async () => {
+ assert.strictEqual(extract.statedDate('October 20', '2026-10-14T00:00:00Z'), '2026-10-20');
+ assert.strictEqual(extract.statedDate('January 5', '2026-12-20T00:00:00Z'), '2027-01-05');
+ assert.strictEqual(extract.statedDate('Oct. 20, 2027', '2026-10-14T00:00:00Z'), '2027-10-20');
+ assert.strictEqual(extract.statedDate('February 30', '2026-01-01T00:00:00Z'), null);
+ assert.strictEqual(extract.statedDate('soon', '2026-01-01T00:00:00Z'), null);
+});
+
+t('a broken or unknown extract is refused', async () => {
+ const bad = [null, [], { kind: 'Coupon', require: ['code'], fields: { code: { from: 'summary', pattern: '(x)' } } },
+ { kind: 'coupon', require: ['code'], fields: { code: { from: 'body', pattern: '(x)' } } },
+ { kind: 'coupon', require: ['code'], fields: { code: { from: 'summary', pattern: 'no group' } } },
+ { kind: 'coupon', require: ['code'], fields: { code: { from: 'summary', pattern: '(unclosed' } } },
+ { kind: 'coupon', require: ['other'], fields: { code: { from: 'summary', pattern: '(x)' } } },
+ { kind: 'coupon', require: ['code'], fields: { code: { from: 'summary', pattern: '(x)', as: 'number' } } },
+ { kind: 'coupon', require: ['code'], fields: { code: { from: 'summary', pattern: '(x)' } }, more: 1 }];
+ for (const b of bad) assert.ok(extract.check(b), JSON.stringify(b));
+});
+
+t('through the registry and a real run: only coupon items are stored, with their fields', async () => {
+ const svc = await boot();
+ const body = rss(ITEMS.map((i) => ({ guid: i.identity, title: i.title, link: `https://www.dealnews.example/${i.identity}`, date: 'Wed, 14 Oct 2026 12:00:00 +0000', description: i.summary })));
+ const web = await site({ '/robots.txt': () => ({ body: 'User-agent: *\nAllow: /\n' }), '/feed.xml': () => ({ headers: { 'Content-Type': 'application/rss+xml' }, body }) });
+ const def = sourceDef({ key: 'coupons-x', category: 'coupons', endpoints: [{ url: `${web.origin}/feed.xml`, extract: X }] });
+ await svc.registry.create(def, 'test');
+ await assert.rejects(svc.registry.create(sourceDef({ key: 'coupons-y', endpoints: [{ url: `${web.origin}/feed.xml`, extract: { ...X, fields: { code: { from: 'summary', pattern: '(' } } } }] }), 'test'), /pattern/);
+ await assert.rejects(svc.registry.create(sourceDef({ key: 'coupons-z', type: 'api', endpoints: [{ url: `${web.origin}/feed.xml`, extract: X }] }), 'test'));
+ const out = await svc.ingest.run('coupons-x', { trigger: 'manual' });
+ assert.deepStrictEqual(out.runs.map((r) => r.state), ['ok']);
+ const rows = await svc.db.prepare("SELECT identity, kind, fields FROM items WHERE source_key = 'coupons-x' ORDER BY identity").all();
+ assert.deepStrictEqual(rows.map((r) => [r.identity, r.kind]), [['d1', 'coupon'], ['d2', 'coupon'], ['d5', 'coupon']]);
+ const f = typeof rows[0].fields === 'string' ? JSON.parse(rows[0].fields) : rows[0].fields;
+ assert.deepStrictEqual([f.code, f.merchant, f.expires], ['UXBMXG9V', 'Amazon', '2026-10-20']);
+ const [run] = out.runs;
+ assert.strictEqual(run.items_skipped, 2);
+ await web.close();
+ await svc.stop();
+});
+
+t.run();