openvibers/openvibe.coupons Commits

Coupons: a source that names the shop (DealNews) files its code under that shop (#47)

OP OpenVibers committed 30 days ago · d5a3953a6e · 4 files, +36 −4

Plan T9: OpenVibe.Coupons holds no codes. Sources#45 adds `dealnews-coupon-codes`: the codes DealNews deals state, with the shop's name and the stated end date. Their link is DealNews' page, so the host can't find the shop.

**Change (importer)**
- When the link's host is no merchant's, `fields.merchant` (the shop's name as the source wrote it) is matched against active merchants' names, case-insensitively.
- Only one exact match counts. Otherwise the item is held `no_merchant`, and the hold says which name the source gave.

**What stays as it was**
- The code is `unknown` until people report it.
- The DealNews page is evidence but never merchant evidence (`merchant_page: false`), so it never raises confidence.
- Review still follows `COUPONS_SOURCES_AUTO_PUBLISH`.

**Tests:** `sources` (a named shop gets the code; an unknown name is held with that name). README updated. Suite: 22/22.

Changes

ddiff --git a/README.md b/README.md
iindex dcef893..0b36cfd 100644
---- a/README.md
++++ b/README.md
@@@ -63,7 +63,7 @@ Nothing is seeded, and no code, merchant or report is invented for a demo.
- **Notifications.** They belong to the Network's notification store. Watches are recorded, but
nothing is sent yet.
- **Search.** OpenVibe.Search indexes what Coupons sends through Events.
--- **Ingestion.** It belongs to OpenVibe.Sources. Coupons only reads the `coupons` category.
++- **Ingestion.** It belongs to OpenVibe.Sources. Coupons only reads the `coupons` category. An item names its shop by its link's host or, when the link is another site's page (DealNews: `dealnews-coupon-codes`), by `fields.merchant`, the shop's name as the source wrote it, which must match exactly one active merchant's name (any case); otherwise it is held `no_merchant` with that name. Such a page is never merchant evidence, so it never raises confidence.
- **The browser helper.** It lives in OpenVibe.Extensions (ADR-023).
## Statuses and confidence
ddiff --git a/server/clients/sources.js b/server/clients/sources.js
iindex cf7cd0f..8305aae 100644
---- a/server/clients/sources.js
++++ b/server/clients/sources.js
@@@ -13,7 +13,9 @@
*
* For each item, in change order:
* removed its evidence is withdrawn; a code left with no evidence is disabled
-- * kind 'coupon' + fields.code on a host a merchant covers
++ * kind 'coupon' + fields.code on a host a merchant covers, or else fields.merchant naming exactly one
++ * active merchant (a source such as DealNews, whose page is the evidence but
++ * names the shop: never merchant evidence, so it never raises confidence)
* a code (status unknown — an item is evidence that a code was published,
* never that it works), or new evidence on the same code; its expiry is
* fields.expires when the item states one, otherwise unknown
@@@ -90,8 +92,13 @@ function createSourcesImporter({ store, config, merchants, coupons, fetchImpl =
return await held(err.code === 'coupon.already_expired' ? 'expired' : 'invalid', err.message);
}
const at = hosts.hostOfUrl(item.canonical_url);
-- const found = at ? await merchants.resolve(at.host, { path: at.path, includeStatuses: ['active', 'pending'] }) : null;
-- if (!found) return await held('no_merchant', at ? at.host : 'no canonical URL');
++ let found = at ? await merchants.resolve(at.host, { path: at.path, includeStatuses: ['active', 'pending'] }) : null;
++ const named = typeof item.fields.merchant === 'string' ? item.fields.merchant.trim().slice(0, 120) : '';
++ if (!found && named) {
++ const m = await merchants.byName(named);
++ if (m) found = { merchant: m };
++ }
++ if (!found) return await held('no_merchant', `${at ? at.host : 'no canonical URL'}${named ? `; the source names "${named}"` : ''}`);
const r = await coupons.importFromSource(found.merchant, item, parsed, { autoPublish: config.sources.autoPublish });
await q.resolve.run(now, now, item.id);
return r.created ? 'imported' : 'updated';
ddiff --git a/server/domain/merchants.js b/server/domain/merchants.js
iindex 73ba838..44657a5 100644
---- a/server/domain/merchants.js
++++ b/server/domain/merchants.js
@@@ -38,6 +38,7 @@ function createMerchants({ store }) {
rulesFor: db.prepare(`SELECT d.*, m.status AS merchant_status FROM coupon_merchant_domains d JOIN coupon_merchants m ON m.id = d.merchant_id
WHERE d.registrable_domain = ?`),
listActive: db.prepare(`SELECT * FROM coupon_merchants WHERE status = 'active' ORDER BY lower(name) LIMIT ? OFFSET ?`),
++ byName: db.prepare(`SELECT * FROM coupon_merchants WHERE status = 'active' AND lower(name) = lower(?) LIMIT 2`),
listAll: db.prepare('SELECT * FROM coupon_merchants ORDER BY id LIMIT ? OFFSET ?'),
countActive: db.prepare("SELECT COUNT(*) AS n FROM coupon_merchants WHERE status = 'active'"),
search: db.prepare(`SELECT m.* FROM coupon_merchants m
@@@ -147,6 +148,13 @@ function createMerchants({ store }) {
return await q.search.all({ q: `%${t.replace(/[\\%_]/g, (c) => `\\${c}`)}%` });
},
pending: async () => await q.pending.all(),
++ /** The one active merchant with exactly this name (any case), or null: never a guess between two. */
++ async byName(name) {
++ const n = String(name || '').trim();
++ if (!n || n.length > 120) return null;
++ const rows = await q.byName.all(n);
++ return rows.length === 1 ? rows[0] : null;
++ },
slugify,
};
}
ddiff --git a/test/sources.test.js b/test/sources.test.js
iindex c3c62a1..520a09d 100644
---- a/test/sources.test.js
++++ b/test/sources.test.js
@@@ -92,6 +92,23 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(c.status_reason, 'source_removed');
});
++ await check('a source that names the shop (DealNews): the code goes to the one active merchant of that name, never as merchant evidence', async () => {
++ const amazon = await t.merchant({ name: 'Amazon', host: 'amazon.com' });
++ t.sources.put(item('itm_01K0000000000000000000000N', { source_key: 'dealnews-coupon-codes', canonical_url: 'https://www.dealnews.com/x/1.html', fields: { code: 'UXBMXG9V', merchant: 'amazon', expires: '2026-12-20' } }));
++ t.sources.put(item('itm_01K0000000000000000000000P', { source_key: 'dealnews-coupon-codes', canonical_url: 'https://www.dealnews.com/x/2.html', fields: { code: 'ZZZZ1234', merchant: 'Nowhere Shop' } }));
++ const r = await t.ctx.importer.run();
++ assert.strictEqual(r.outcomes.imported, 1, JSON.stringify(r.outcomes));
++ assert.strictEqual(r.outcomes['held:no_merchant'], 1, JSON.stringify(r.outcomes));
++ const c = await t.ctx.store.db.prepare("SELECT * FROM coupons WHERE code = 'UXBMXG9V'").get();
++ assert.strictEqual(c.merchant_id, amazon.id);
++ assert.strictEqual(c.status, 'unknown');
++ const v = await t.ctx.coupons.view(c);
++ assert.strictEqual(v.evidence[0].merchant_page, false, 'a DealNews page is not the shop\'s own');
++ assert.strictEqual(v.evidence[0].url, 'https://www.dealnews.com/x/1.html');
++ const hold = (await t.ctx.importer.holds()).find((h) => h.item_id === 'itm_01K0000000000000000000000P');
++ assert.match(hold.detail, /the source names "Nowhere Shop"/);
++ });
++
await check('a failed fetch changes nothing and is recorded', async () => {
const before = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupons').get()).n;
const cursor = (await t.ctx.importer.state()).cursor;