openvibers/openvibe.coupons Commits
Coupons: a source that names the shop (DealNews) files its code under that shop (#47)
Changes
@@@ -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, butnothing 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@@@ -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';@@@ -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_idWHERE 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,};}@@@ -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;
@@ -63,7 +63,7 @@ Nothing is seeded, and no code, merchant or report is invented for a demo.
@@ -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
- **Notifications.** They belong to the Network's notification store. Watches are recorded, but
nothing is sent yet.
nothing is sent yet.
- **Search.** OpenVibe.Search indexes what Coupons sends through Events.
- **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).
- **The browser helper.** It lives in OpenVibe.Extensions (ADR-023).
## Statuses and confidence
## Statuses and confidence
@@ -13,7 +13,9 @@
@@ -13,7 +13,9 @@
*
*
* For each item, in change order:
* For each item, in change order:
* removed its evidence is withdrawn; a code left with no evidence is disabled
* 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,
* 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
* never that it works), or new evidence on the same code; its expiry is
* fields.expires when the item states one, otherwise unknown
* fields.expires when the item states one, otherwise unknown
@@ -90,8 +92,13 @@ function createSourcesImporter({ store, config, merchants, coupons, fetchImpl =
@@ -90,8 +92,13 @@ function createSourcesImporter({ store, config, merchants, coupons, fetchImpl =
return await held(err.code === 'coupon.already_expired' ? 'expired' : 'invalid', err.message);
return await held(err.code === 'coupon.already_expired' ? 'expired' : 'invalid', err.message);
}
}
const at = hosts.hostOfUrl(item.canonical_url);
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 });
const r = await coupons.importFromSource(found.merchant, item, parsed, { autoPublish: config.sources.autoPublish });
await q.resolve.run(now, now, item.id);
await q.resolve.run(now, now, item.id);
return r.created ? 'imported' : 'updated';
return r.created ? 'imported' : 'updated';
@@ -38,6 +38,7 @@ function createMerchants({ store }) {
@@ -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
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 = ?`),
WHERE d.registrable_domain = ?`),
listActive: db.prepare(`SELECT * FROM coupon_merchants WHERE status = 'active' ORDER BY lower(name) LIMIT ? OFFSET ?`),
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 ?'),
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'"),
countActive: db.prepare("SELECT COUNT(*) AS n FROM coupon_merchants WHERE status = 'active'"),
search: db.prepare(`SELECT m.* FROM coupon_merchants m
search: db.prepare(`SELECT m.* FROM coupon_merchants m
@@ -147,6 +148,13 @@ function createMerchants({ store }) {
@@ -147,6 +148,13 @@ function createMerchants({ store }) {
return await q.search.all({ q: `%${t.replace(/[\\%_]/g, (c) => `\\${c}`)}%` });
return await q.search.all({ q: `%${t.replace(/[\\%_]/g, (c) => `\\${c}`)}%` });
},
},
pending: async () => await q.pending.all(),
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,
slugify,
};
};
}
}
@@ -92,6 +92,23 @@ const { boot, check, done } = require('./helpers/boot');
@@ -92,6 +92,23 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(c.status_reason, 'source_removed');
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 () => {
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 before = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupons').get()).n;
const cursor = (await t.ctx.importer.state()).cursor;
const cursor = (await t.ctx.importer.state()).cursor;