openvibers/openvibe.sources Commits

Sources: extract facts a feed's items state (coupon codes from DealNews for Coupons) (#45)

OP OpenVibers committed 30 days ago · 19aa00d7c4 · 9 files, +293 −6

* Sources: extract on feed endpoints; dealnews-coupon-codes

* Sources: contracts 0.163 (sources.source@1 extract); the proposal copy synced

Changes

ddiff --git a/README.md b/README.md
iindex 23f22cc..6577ecb 100644
---- a/README.md
++++ b/README.md
@@@ -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.
`"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
Each 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) |
ddiff --git a/docs/contracts-proposal/contracts/sources/source.v1.json b/docs/contracts-proposal/contracts/sources/source.v1.json
iindex a79c42f..18f011d 100644
---- a/docs/contracts-proposal/contracts/sources/source.v1.json
++++ b/docs/contracts-proposal/contracts/sources/source.v1.json
@@@ -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"
++ ]
++ }
++ }
++ }
++ }
++ }
}
}
}
ddiff --git a/package-lock.json b/package-lock.json
iindex 6dc39cc..24a1db9 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 31c77d1..fe30239 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/seeds/sources.json b/seeds/sources.json
iindex 77b7ae4..3f877ca 100644
---- a/seeds/sources.json
++++ b/seeds/sources.json
@@@ -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"
}
]
}
ddiff --git a/server/adapters/extract.js b/server/adapters/extract.js
nnew file mode 100644
iindex 0000000..ff24d3d
---- /dev/null
++++ b/server/adapters/extract.js
@@@ -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 };
ddiff --git a/server/ingest.js b/server/ingest.js
iindex 0d8447e..c2e6b12 100644
---- a/server/ingest.js
++++ b/server/ingest.js
@@@ -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, em
try {
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}`);
ddiff --git a/server/registry.js b/server/registry.js
iindex 4c91224..8938f19 100644
---- a/server/registry.js
++++ b/server/registry.js
@@@ -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;
});
ddiff --git a/test/extract.test.js b/test/extract.test.js
nnew file mode 100644
iindex 0000000..007b5ae
---- /dev/null
++++ b/test/extract.test.js
@@@ -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();