openvibers/openvibe.deals Commits
Each offer says what it costs, where it is sold and what it looks like (#330)
Changes
nnew file mode 100644@@@ -0,0 +1,10 @@++-- phase: expand++-- The picture of a product (the owner, 11-13: "update the listings for deals"): the one feed lists a++-- deal with its price, the price it was, the shop that sells it and the product's picture++-- (feed.item@1 1.1.0: group.items[].price / was / store, and the card's own media.thumb).++--++-- The image is the one the source stated for the listing (OpenVibe.Sources keeps the picture it++-- found in the item's fields). It is stored as stated, never fetched, and only when it is https.++--++-- Expand only: an older release never reads this column, and nothing here changes an existing row.++ALTER TABLE deal_offers ADD COLUMN image_url text;@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.425.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.241.0",@@@ -1229,9 +1229,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.419.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",-- "integrity": "sha512-VxrTdeHchT8gvl2wWC9ASaId92en7ZKfJ8F/0hKzCAD0f2Y00rPrqgBWF9BNPoLGARoyQvsMbhBEhSkhxGNnjA==",++ "version": "0.425.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.425.0",++ "integrity": "sha512-5TGvkK5Crrnlg7g33YH3pkStkM3lJ0mHLe+BCo6KR5tQNl/cjOq7YF5QqSDL0pEVU1+zWV+pBJY3aR4OTZkYkQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -26,7 +26,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.425.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.241.0",@@@ -19,7 +19,7 @@* drops the price, a missing availability is omitted (never an assumed InStock).*/const seo = require('openvibe-publishing/seo');--const { iso } = require('./values');++const { iso, pictureUrl } = require('./values');const { attributionOf, hidesFromExtension } = require('./source-policy');const { priceTrend, listPriceFields } = require('./price-trend');@@@ -164,6 +164,26 @@ function createPublication({ config, store, reads, catalog }) {};}++ /**++ * What the one feed needs about an offer (feed.item@1 1.1.0): what it costs now, what it cost++ * before, the shop that sells it and its picture. A price without a currency, or a currency that is++ * not an ISO code, is no price at all; a listing without a picture has none.++ */++ function feedFacts(v) {++ const latest = v.latest;++ const amount = latest ? Number(latest.price_num != null ? latest.price_num : latest.price) : NaN;++ const currency = String((latest && latest.currency) || '').trim().toUpperCase();++ const was = latest && latest.list_price_num != null ? Number(latest.list_price_num) : NaN;++ const store = (v.store && (v.store.name || v.store.domain)) || v.root.seller || null;++ const thumb = pictureUrl(v.root.image_url);++ return {++ price: Number.isFinite(amount) && amount >= 0 && /^[A-Z]{3}$/.test(currency) ? { amount, currency } : null,++ was: Number.isFinite(was) && was >= 0 ? was : null,++ store: store ? String(store).trim().slice(0, 60) || null : null,++ media: thumb ? { thumb } : null,++ };++ }++/** The JSON representation (API and /d/:slug.json): the same facts as the page. */function offerDto(v) {const r = v.root;@@@ -179,6 +199,9 @@ function createPublication({ config, store, reads, catalog }) {store: v.store ? { id: v.store.id, domain: v.store.domain, name: v.store.name, url: abs(storePath(v.store)) } : null,// The shop a source says sells it, as stated (the store is where the link goes).seller: r.seller || null,++ // What the one feed lists the offer with: the price now, the price before, the shop and the++ // product's picture (feed.item@1 1.1.0).++ feed: feedFacts(v),product: v.product ? { id: v.product.id, slug: v.product.slug, name: v.product.name, url: abs(productPath(v.product)) } : null,// "latest", never "current": whether it still holds is what freshness says.latest_observation: v.latest ? observationDto(v.latest) : null,@@@ -47,7 +47,7 @@*/const { createChangeCursor, pullChanges } = require('openvibe-publishing/ingest');const {-- normalizeUrl, verbatimUrl, urlKey, parseAmount, SCHEMA_AVAILABILITY, SCHEMA_CONDITION, iso,++ normalizeUrl, verbatimUrl, urlKey, pictureUrl, parseAmount, SCHEMA_AVAILABILITY, SCHEMA_CONDITION, iso,} = require('./values');const { keepsLinksVerbatim } = require('./source-policy');const { isDeal } = require('./price-trend');@@@ -87,6 +87,18 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourcconst enabled = () => Boolean(sources.enabled && config.sources.enabled);const cursorNow = async () => await cursor.get(CURSOR);++ /**++ * The picture the source stated for the listing, kept on the offer (the one feed lists a deal with++ * its picture). It is stored as stated and never fetched; a listing without one keeps none.++ */++ async function applyImage(offerId, image) {++ if (!image) return false;++ const row = await db.prepare('SELECT image_url FROM deal_offers WHERE id = ?').get(offerId);++ if (!row || row.image_url === image) return false;++ await db.prepare('UPDATE deal_offers SET image_url = ?, updated_at = ? WHERE id = ?').run(image, store.now(), offerId);++ return true;++ }++function amount(v) { try { return parseAmount(v, 'price'); } catch { return null; } }function currencyOf(v) { return typeof v === 'string' && /^[A-Z]{3}$/.test(v) ? v : null; }@@@ -126,7 +138,8 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourcif (item.kind === 'offer') {const url = linkOf(f.url || item.canonical_url);if (!url || !item.title) return [];-- return [{ part: '', url, title: item.title, description: item.summary, obs: obsFields(f), expires_at: stated(f.valid_until), ...sellerOf(f.seller), ...labels }];++ // The picture the source stated for the listing, when it stated one (the feed lists it).++ return [{ part: '', url, title: item.title, description: item.summary, obs: obsFields(f), expires_at: stated(f.valid_until), image: pictureUrl(f.image), ...sellerOf(f.seller), ...labels }];}if (item.kind === 'product') {if (!item.title) return [];@@@ -139,7 +152,8 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourcout.push({// A verbatim source's title is used as stated, with no added words.part: urlKey(url), url, title: !verbatim && o.seller ? `${item.title} — ${o.seller}` : item.title, description: item.summary,-- obs: obsFields(o), expires_at: stated(o.valid_until), ...sellerOf(o.seller), ...labels,++ obs: obsFields(o), expires_at: stated(o.valid_until), image: pictureUrl(f.image || o.image),++ ...sellerOf(o.seller), ...labels,product: { name: item.title, brand: f.brand, gtin: f.gtin, mpn: f.mpn, sku: f.sku },});}@@@ -206,6 +220,7 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourcconst obs = await offers.recordObservation(existing.offer_id, existing.id, c.obs, { origin, observedAt: retrievedAt, sourceRevision: item.revision });const offer = await reads.get(existing.offer_id);const changed = ['observation'];++ if (await applyImage(existing.offer_id, c.image)) changed.push('image');if (origin === 'import' && c.seller && offer.origin === 'import' && c.seller !== offer.seller) {await db.prepare('UPDATE deal_offers SET seller = ?, updated_at = ? WHERE id = ?').run(c.seller, store.now(), offer.id);changed.push('seller');@@@ -233,6 +248,7 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourcconst obs = await offers.recordObservation(dup.id, s.id, c.obs, { origin: 'import', observedAt: retrievedAt, sourceRevision: item.revision });if (productId && !dup.product_id) await db.prepare('UPDATE deal_offers SET product_id = ?, updated_at = ? WHERE id = ?').run(productId, store.now(), dup.id);if (c.seller && !dup.seller) await db.prepare('UPDATE deal_offers SET seller = ?, updated_at = ? WHERE id = ?').run(c.seller, store.now(), dup.id);++ await applyImage(dup.id, c.image);if (!dup.category || dup.staff_pick == null) await applyLabels(dup, { category: dup.category ? null : c.category, staff_pick: dup.staff_pick == null ? c.staff_pick : null });await indexing.emitOffer('deals.offer.updated', await reads.get(dup.id), { actor: 'svc:deals', extra: { changed: ['sources'], observation_id: obs.id } });await indexing.reindex(await reads.get(dup.id));@@@ -243,6 +259,7 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourcurl: c.url, title: String(c.title).slice(0, 200), description: c.description, product_id: productId, category: c.category,store_name: c.store_name, seller: c.seller, expires_at: c.expires_at != null && c.expires_at > store.now() ? c.expires_at : null,}, src, c.obs, { observedAt: retrievedAt, sourceRevision: item.revision });++ await applyImage(made.id, c.image);if (c.staff_pick != null) await applyLabels(made, { staff_pick: c.staff_pick });outcomes.push('created');}@@@ -86,6 +86,20 @@ function verbatimUrl(input) {return s.length <= 2048 ? s : null;}++/**++ * The picture of a listing, as the source stated it: an https address kept as it is (a picture link++ * is not the listing's identity, so nothing is stripped from it), and null for anything else.++ */++function pictureUrl(input) {++ if (input == null || input === '') return null;++ const s = String(input).trim();++ if (!s || s.length > 2000) return null;++ let u;++ try { u = new URL(s); } catch { return null; }++ if (u.protocol !== 'https:' || u.username || u.password) return null;++ return u.href;++}++/** The identity used to find duplicates: normalised URL without scheme and without "www.". */function urlKey(normalized) {if (!normalized) return null;@@@ -94,7 +108,7 @@ function urlKey(normalized) {module.exports = {ApiError, newId, CONDITIONS, AVAILABILITY, AVAILABILITY_LABEL, SCHEMA_AVAILABILITY, SCHEMA_CONDITION,-- normalizeUrl, urlKey, verbatimUrl,++ normalizeUrl, urlKey, verbatimUrl, pictureUrl,domainOf: normalize.domainOf, slugify: normalize.slugify,parseAmount: asApiError(normalize.parseAmount), parseCurrency: asApiError(normalize.parseCurrency),parseEnum: asApiError(normalize.parseEnum), parseInstant: asApiError(normalize.parseInstant),nnew file mode 100644@@@ -0,0 +1,77 @@++'use strict';++/**++ * What the one feed gets from an offer (feed.item@1 1.1.0): the price now, the price before, the shop++ * that sells it and the product's picture. The owner, 11-13: "update the listings for deals".++ *++ * OpenVibe.Network's collector reads these offers (GET /api/v1/offers) and folds a run of them into++ * one card whose items list what each deal costs.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { sourceItem } = require('./helpers/mocks');++++(async () => {++ const t = await boot();++ const T0 = t.clock.now();++++ await check('an offer carries what it costs, the shop that sells it and its picture', async () => {++ t.sources.put(sourceItem({++ id: 'itm_01K5FEED00000000000000A1', title: 'Espresso machine', url: 'https://coffee.example/espresso',++ retrievedAt: T0,++ fields: {++ price: '249.00', currency: 'EUR', list_price: '299.00', seller: 'Coffee Shop',++ image: 'https://coffee.example/img/espresso.jpg',++ },++ }));++ const summary = await t.ctx.importer.pull();++ assert.strictEqual(summary.outcomes.created, 1, JSON.stringify(summary));++ const offers = (await t.get('/api/v1/offers?sort=new')).json().offers;++ const offer = offers.find((o) => o.title === 'Espresso machine');++ assert.ok(offer, 'the offer is in the list the feed reads');++ assert.deepStrictEqual(offer.feed.price, { amount: 249, currency: 'EUR' }, 'the price, with its currency in upper case');++ assert.strictEqual(offer.feed.was, 299, 'the price it was');++ assert.strictEqual(offer.feed.store, 'Coffee Shop', 'the shop that sells it');++ assert.deepStrictEqual(offer.feed.media, { thumb: 'https://coffee.example/img/espresso.jpg' }, 'the picture of the product');++ });++++ await check('the picture is kept as the source stated it, and only when it is https', async () => {++ t.sources.put(sourceItem({++ id: 'itm_01K5FEED00000000000000A2', title: 'Tea kettle', url: 'https://tea.example/kettle',++ retrievedAt: T0, fields: { price: '39.99', currency: 'USD', image: 'http://tea.example/img/kettle.jpg' },++ }));++ await t.ctx.importer.pull();++ const offers = (await t.get('/api/v1/offers?sort=new')).json().offers;++ const offer = offers.find((o) => o.title === 'Tea kettle');++ assert.ok(offer, 'the offer is in the list');++ assert.strictEqual(offer.feed.media, null, 'a picture that is not https is not a picture');++ assert.deepStrictEqual(offer.feed.price, { amount: 39.99, currency: 'USD' }, 'its price is still there');++ assert.strictEqual(offer.feed.was, null, 'no list price stated, so no old price');++ });++++ await check('an offer with no price states no price', async () => {++ t.sources.put(sourceItem({++ id: 'itm_01K5FEED00000000000000A3', kind: 'article', title: 'Weekend sale on garden tools',++ url: 'https://news.example/garden', retrievedAt: T0,++ }));++ await t.ctx.importer.pull();++ const offers = (await t.get('/api/v1/offers?sort=new')).json().offers;++ const offer = offers.find((o) => o.title === 'Weekend sale on garden tools');++ assert.ok(offer, 'the offer is in the list');++ assert.strictEqual(offer.feed.price, null, 'no price is never a price of zero');++ assert.strictEqual(offer.feed.was, null);++ assert.strictEqual(offer.feed.media, null);++ });++++ await check('a deal a person submitted carries its price too', async () => {++ const alice = t.network.addUser('alice');++ const offer = await t.submit(alice, { url: 'https://shop.example/lamp', title: 'Reading lamp', price: '24.50', currency: 'GBP' });++ assert.ok(offer, 'the offer was submitted');++ const json = await t.offerJson(offer.slug);++ assert.deepStrictEqual(json.feed.price, { amount: 24.5, currency: 'GBP' }, 'the price the person stated');++ assert.strictEqual(json.feed.was, null, 'nothing was said about an old price');++ assert.strictEqual(json.feed.media, null, 'and there is no picture');++ });++++ await t.close();++ done();++})().catch((err) => { console.error(err); process.exitCode = 1; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+-- phase: expand
+-- The picture of a product (the owner, 11-13: "update the listings for deals"): the one feed lists a
+-- deal with its price, the price it was, the shop that sells it and the product's picture
+-- (feed.item@1 1.1.0: group.items[].price / was / store, and the card's own media.thumb).
+--
+-- The image is the one the source stated for the listing (OpenVibe.Sources keeps the picture it
+-- found in the item's fields). It is stored as stated, never fetched, and only when it is https.
+--
+-- Expand only: an older release never reads this column, and nothing here changes an existing row.
+ALTER TABLE deal_offers ADD COLUMN image_url text;
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.425.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.241.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.241.0",
@@ -1229,9 +1229,9 @@
@@ -1229,9 +1229,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.419.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
- "integrity": "sha512-VxrTdeHchT8gvl2wWC9ASaId92en7ZKfJ8F/0hKzCAD0f2Y00rPrqgBWF9BNPoLGARoyQvsMbhBEhSkhxGNnjA==",
+ "version": "0.425.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.425.0",
+ "integrity": "sha512-5TGvkK5Crrnlg7g33YH3pkStkM3lJ0mHLe+BCo6KR5tQNl/cjOq7YF5QqSDL0pEVU1+zWV+pBJY3aR4OTZkYkQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.425.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.241.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.241.0",
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
* drops the price, a missing availability is omitted (never an assumed InStock).
* drops the price, a missing availability is omitted (never an assumed InStock).
*/
*/
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
-const { iso } = require('./values');
+const { iso, pictureUrl } = require('./values');
const { attributionOf, hidesFromExtension } = require('./source-policy');
const { attributionOf, hidesFromExtension } = require('./source-policy');
const { priceTrend, listPriceFields } = require('./price-trend');
const { priceTrend, listPriceFields } = require('./price-trend');
@@ -164,6 +164,26 @@ function createPublication({ config, store, reads, catalog }) {
@@ -164,6 +164,26 @@ function createPublication({ config, store, reads, catalog }) {
};
};
}
}
+ /**
+ * What the one feed needs about an offer (feed.item@1 1.1.0): what it costs now, what it cost
+ * before, the shop that sells it and its picture. A price without a currency, or a currency that is
+ * not an ISO code, is no price at all; a listing without a picture has none.
+ */
+ function feedFacts(v) {
+ const latest = v.latest;
+ const amount = latest ? Number(latest.price_num != null ? latest.price_num : latest.price) : NaN;
+ const currency = String((latest && latest.currency) || '').trim().toUpperCase();
+ const was = latest && latest.list_price_num != null ? Number(latest.list_price_num) : NaN;
+ const store = (v.store && (v.store.name || v.store.domain)) || v.root.seller || null;
+ const thumb = pictureUrl(v.root.image_url);
+ return {
+ price: Number.isFinite(amount) && amount >= 0 && /^[A-Z]{3}$/.test(currency) ? { amount, currency } : null,
+ was: Number.isFinite(was) && was >= 0 ? was : null,
+ store: store ? String(store).trim().slice(0, 60) || null : null,
+ media: thumb ? { thumb } : null,
+ };
+ }
+
/** The JSON representation (API and /d/:slug.json): the same facts as the page. */
/** The JSON representation (API and /d/:slug.json): the same facts as the page. */
function offerDto(v) {
function offerDto(v) {
const r = v.root;
const r = v.root;
@@ -179,6 +199,9 @@ function createPublication({ config, store, reads, catalog }) {
@@ -179,6 +199,9 @@ function createPublication({ config, store, reads, catalog }) {
store: v.store ? { id: v.store.id, domain: v.store.domain, name: v.store.name, url: abs(storePath(v.store)) } : null,
store: v.store ? { id: v.store.id, domain: v.store.domain, name: v.store.name, url: abs(storePath(v.store)) } : null,
// The shop a source says sells it, as stated (the store is where the link goes).
// The shop a source says sells it, as stated (the store is where the link goes).
seller: r.seller || null,
seller: r.seller || null,
+ // What the one feed lists the offer with: the price now, the price before, the shop and the
+ // product's picture (feed.item@1 1.1.0).
+ feed: feedFacts(v),
product: v.product ? { id: v.product.id, slug: v.product.slug, name: v.product.name, url: abs(productPath(v.product)) } : null,
product: v.product ? { id: v.product.id, slug: v.product.slug, name: v.product.name, url: abs(productPath(v.product)) } : null,
// "latest", never "current": whether it still holds is what freshness says.
// "latest", never "current": whether it still holds is what freshness says.
latest_observation: v.latest ? observationDto(v.latest) : null,
latest_observation: v.latest ? observationDto(v.latest) : null,
@@ -47,7 +47,7 @@
@@ -47,7 +47,7 @@
*/
*/
const { createChangeCursor, pullChanges } = require('openvibe-publishing/ingest');
const { createChangeCursor, pullChanges } = require('openvibe-publishing/ingest');
const {
const {
- normalizeUrl, verbatimUrl, urlKey, parseAmount, SCHEMA_AVAILABILITY, SCHEMA_CONDITION, iso,
+ normalizeUrl, verbatimUrl, urlKey, pictureUrl, parseAmount, SCHEMA_AVAILABILITY, SCHEMA_CONDITION, iso,
} = require('./values');
} = require('./values');
const { keepsLinksVerbatim } = require('./source-policy');
const { keepsLinksVerbatim } = require('./source-policy');
const { isDeal } = require('./price-trend');
const { isDeal } = require('./price-trend');
@@ -87,6 +87,18 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
@@ -87,6 +87,18 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
const enabled = () => Boolean(sources.enabled && config.sources.enabled);
const enabled = () => Boolean(sources.enabled && config.sources.enabled);
const cursorNow = async () => await cursor.get(CURSOR);
const cursorNow = async () => await cursor.get(CURSOR);
+ /**
+ * The picture the source stated for the listing, kept on the offer (the one feed lists a deal with
+ * its picture). It is stored as stated and never fetched; a listing without one keeps none.
+ */
+ async function applyImage(offerId, image) {
+ if (!image) return false;
+ const row = await db.prepare('SELECT image_url FROM deal_offers WHERE id = ?').get(offerId);
+ if (!row || row.image_url === image) return false;
+ await db.prepare('UPDATE deal_offers SET image_url = ?, updated_at = ? WHERE id = ?').run(image, store.now(), offerId);
+ return true;
+ }
+
function amount(v) { try { return parseAmount(v, 'price'); } catch { return null; } }
function amount(v) { try { return parseAmount(v, 'price'); } catch { return null; } }
function currencyOf(v) { return typeof v === 'string' && /^[A-Z]{3}$/.test(v) ? v : null; }
function currencyOf(v) { return typeof v === 'string' && /^[A-Z]{3}$/.test(v) ? v : null; }
@@ -126,7 +138,8 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
@@ -126,7 +138,8 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
if (item.kind === 'offer') {
if (item.kind === 'offer') {
const url = linkOf(f.url || item.canonical_url);
const url = linkOf(f.url || item.canonical_url);
if (!url || !item.title) return [];
if (!url || !item.title) return [];
- return [{ part: '', url, title: item.title, description: item.summary, obs: obsFields(f), expires_at: stated(f.valid_until), ...sellerOf(f.seller), ...labels }];
+ // The picture the source stated for the listing, when it stated one (the feed lists it).
+ return [{ part: '', url, title: item.title, description: item.summary, obs: obsFields(f), expires_at: stated(f.valid_until), image: pictureUrl(f.image), ...sellerOf(f.seller), ...labels }];
}
}
if (item.kind === 'product') {
if (item.kind === 'product') {
if (!item.title) return [];
if (!item.title) return [];
@@ -139,7 +152,8 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
@@ -139,7 +152,8 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
out.push({
out.push({
// A verbatim source's title is used as stated, with no added words.
// A verbatim source's title is used as stated, with no added words.
part: urlKey(url), url, title: !verbatim && o.seller ? `${item.title} — ${o.seller}` : item.title, description: item.summary,
part: urlKey(url), url, title: !verbatim && o.seller ? `${item.title} — ${o.seller}` : item.title, description: item.summary,
- obs: obsFields(o), expires_at: stated(o.valid_until), ...sellerOf(o.seller), ...labels,
+ obs: obsFields(o), expires_at: stated(o.valid_until), image: pictureUrl(f.image || o.image),
+ ...sellerOf(o.seller), ...labels,
product: { name: item.title, brand: f.brand, gtin: f.gtin, mpn: f.mpn, sku: f.sku },
product: { name: item.title, brand: f.brand, gtin: f.gtin, mpn: f.mpn, sku: f.sku },
});
});
}
}
@@ -206,6 +220,7 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
@@ -206,6 +220,7 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
const obs = await offers.recordObservation(existing.offer_id, existing.id, c.obs, { origin, observedAt: retrievedAt, sourceRevision: item.revision });
const obs = await offers.recordObservation(existing.offer_id, existing.id, c.obs, { origin, observedAt: retrievedAt, sourceRevision: item.revision });
const offer = await reads.get(existing.offer_id);
const offer = await reads.get(existing.offer_id);
const changed = ['observation'];
const changed = ['observation'];
+ if (await applyImage(existing.offer_id, c.image)) changed.push('image');
if (origin === 'import' && c.seller && offer.origin === 'import' && c.seller !== offer.seller) {
if (origin === 'import' && c.seller && offer.origin === 'import' && c.seller !== offer.seller) {
await db.prepare('UPDATE deal_offers SET seller = ?, updated_at = ? WHERE id = ?').run(c.seller, store.now(), offer.id);
await db.prepare('UPDATE deal_offers SET seller = ?, updated_at = ? WHERE id = ?').run(c.seller, store.now(), offer.id);
changed.push('seller');
changed.push('seller');
@@ -233,6 +248,7 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
@@ -233,6 +248,7 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
const obs = await offers.recordObservation(dup.id, s.id, c.obs, { origin: 'import', observedAt: retrievedAt, sourceRevision: item.revision });
const obs = await offers.recordObservation(dup.id, s.id, c.obs, { origin: 'import', observedAt: retrievedAt, sourceRevision: item.revision });
if (productId && !dup.product_id) await db.prepare('UPDATE deal_offers SET product_id = ?, updated_at = ? WHERE id = ?').run(productId, store.now(), dup.id);
if (productId && !dup.product_id) await db.prepare('UPDATE deal_offers SET product_id = ?, updated_at = ? WHERE id = ?').run(productId, store.now(), dup.id);
if (c.seller && !dup.seller) await db.prepare('UPDATE deal_offers SET seller = ?, updated_at = ? WHERE id = ?').run(c.seller, store.now(), dup.id);
if (c.seller && !dup.seller) await db.prepare('UPDATE deal_offers SET seller = ?, updated_at = ? WHERE id = ?').run(c.seller, store.now(), dup.id);
+ await applyImage(dup.id, c.image);
if (!dup.category || dup.staff_pick == null) await applyLabels(dup, { category: dup.category ? null : c.category, staff_pick: dup.staff_pick == null ? c.staff_pick : null });
if (!dup.category || dup.staff_pick == null) await applyLabels(dup, { category: dup.category ? null : c.category, staff_pick: dup.staff_pick == null ? c.staff_pick : null });
await indexing.emitOffer('deals.offer.updated', await reads.get(dup.id), { actor: 'svc:deals', extra: { changed: ['sources'], observation_id: obs.id } });
await indexing.emitOffer('deals.offer.updated', await reads.get(dup.id), { actor: 'svc:deals', extra: { changed: ['sources'], observation_id: obs.id } });
await indexing.reindex(await reads.get(dup.id));
await indexing.reindex(await reads.get(dup.id));
@@ -243,6 +259,7 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
@@ -243,6 +259,7 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
url: c.url, title: String(c.title).slice(0, 200), description: c.description, product_id: productId, category: c.category,
url: c.url, title: String(c.title).slice(0, 200), description: c.description, product_id: productId, category: c.category,
store_name: c.store_name, seller: c.seller, expires_at: c.expires_at != null && c.expires_at > store.now() ? c.expires_at : null,
store_name: c.store_name, seller: c.seller, expires_at: c.expires_at != null && c.expires_at > store.now() ? c.expires_at : null,
}, src, c.obs, { observedAt: retrievedAt, sourceRevision: item.revision });
}, src, c.obs, { observedAt: retrievedAt, sourceRevision: item.revision });
+ await applyImage(made.id, c.image);
if (c.staff_pick != null) await applyLabels(made, { staff_pick: c.staff_pick });
if (c.staff_pick != null) await applyLabels(made, { staff_pick: c.staff_pick });
outcomes.push('created');
outcomes.push('created');
}
}
@@ -86,6 +86,20 @@ function verbatimUrl(input) {
@@ -86,6 +86,20 @@ function verbatimUrl(input) {
return s.length <= 2048 ? s : null;
return s.length <= 2048 ? s : null;
}
}
+/**
+ * The picture of a listing, as the source stated it: an https address kept as it is (a picture link
+ * is not the listing's identity, so nothing is stripped from it), and null for anything else.
+ */
+function pictureUrl(input) {
+ if (input == null || input === '') return null;
+ const s = String(input).trim();
+ if (!s || s.length > 2000) return null;
+ let u;
+ try { u = new URL(s); } catch { return null; }
+ if (u.protocol !== 'https:' || u.username || u.password) return null;
+ return u.href;
+}
+
/** The identity used to find duplicates: normalised URL without scheme and without "www.". */
/** The identity used to find duplicates: normalised URL without scheme and without "www.". */
function urlKey(normalized) {
function urlKey(normalized) {
if (!normalized) return null;
if (!normalized) return null;
@@ -94,7 +108,7 @@ function urlKey(normalized) {
@@ -94,7 +108,7 @@ function urlKey(normalized) {
module.exports = {
module.exports = {
ApiError, newId, CONDITIONS, AVAILABILITY, AVAILABILITY_LABEL, SCHEMA_AVAILABILITY, SCHEMA_CONDITION,
ApiError, newId, CONDITIONS, AVAILABILITY, AVAILABILITY_LABEL, SCHEMA_AVAILABILITY, SCHEMA_CONDITION,
- normalizeUrl, urlKey, verbatimUrl,
+ normalizeUrl, urlKey, verbatimUrl, pictureUrl,
domainOf: normalize.domainOf, slugify: normalize.slugify,
domainOf: normalize.domainOf, slugify: normalize.slugify,
parseAmount: asApiError(normalize.parseAmount), parseCurrency: asApiError(normalize.parseCurrency),
parseAmount: asApiError(normalize.parseAmount), parseCurrency: asApiError(normalize.parseCurrency),
parseEnum: asApiError(normalize.parseEnum), parseInstant: asApiError(normalize.parseInstant),
parseEnum: asApiError(normalize.parseEnum), parseInstant: asApiError(normalize.parseInstant),
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+'use strict';
+/**
+ * What the one feed gets from an offer (feed.item@1 1.1.0): the price now, the price before, the shop
+ * that sells it and the product's picture. The owner, 11-13: "update the listings for deals".
+ *
+ * OpenVibe.Network's collector reads these offers (GET /api/v1/offers) and folds a run of them into
+ * one card whose items list what each deal costs.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { sourceItem } = require('./helpers/mocks');
+
+(async () => {
+ const t = await boot();
+ const T0 = t.clock.now();
+
+ await check('an offer carries what it costs, the shop that sells it and its picture', async () => {
+ t.sources.put(sourceItem({
+ id: 'itm_01K5FEED00000000000000A1', title: 'Espresso machine', url: 'https://coffee.example/espresso',
+ retrievedAt: T0,
+ fields: {
+ price: '249.00', currency: 'EUR', list_price: '299.00', seller: 'Coffee Shop',
+ image: 'https://coffee.example/img/espresso.jpg',
+ },
+ }));
+ const summary = await t.ctx.importer.pull();
+ assert.strictEqual(summary.outcomes.created, 1, JSON.stringify(summary));
+ const offers = (await t.get('/api/v1/offers?sort=new')).json().offers;
+ const offer = offers.find((o) => o.title === 'Espresso machine');
+ assert.ok(offer, 'the offer is in the list the feed reads');
+ assert.deepStrictEqual(offer.feed.price, { amount: 249, currency: 'EUR' }, 'the price, with its currency in upper case');
+ assert.strictEqual(offer.feed.was, 299, 'the price it was');
+ assert.strictEqual(offer.feed.store, 'Coffee Shop', 'the shop that sells it');
+ assert.deepStrictEqual(offer.feed.media, { thumb: 'https://coffee.example/img/espresso.jpg' }, 'the picture of the product');
+ });
+
+ await check('the picture is kept as the source stated it, and only when it is https', async () => {
+ t.sources.put(sourceItem({
+ id: 'itm_01K5FEED00000000000000A2', title: 'Tea kettle', url: 'https://tea.example/kettle',
+ retrievedAt: T0, fields: { price: '39.99', currency: 'USD', image: 'http://tea.example/img/kettle.jpg' },
+ }));
+ await t.ctx.importer.pull();
+ const offers = (await t.get('/api/v1/offers?sort=new')).json().offers;
+ const offer = offers.find((o) => o.title === 'Tea kettle');
+ assert.ok(offer, 'the offer is in the list');
+ assert.strictEqual(offer.feed.media, null, 'a picture that is not https is not a picture');
+ assert.deepStrictEqual(offer.feed.price, { amount: 39.99, currency: 'USD' }, 'its price is still there');
+ assert.strictEqual(offer.feed.was, null, 'no list price stated, so no old price');
+ });
+
+ await check('an offer with no price states no price', async () => {
+ t.sources.put(sourceItem({
+ id: 'itm_01K5FEED00000000000000A3', kind: 'article', title: 'Weekend sale on garden tools',
+ url: 'https://news.example/garden', retrievedAt: T0,
+ }));
+ await t.ctx.importer.pull();
+ const offers = (await t.get('/api/v1/offers?sort=new')).json().offers;
+ const offer = offers.find((o) => o.title === 'Weekend sale on garden tools');
+ assert.ok(offer, 'the offer is in the list');
+ assert.strictEqual(offer.feed.price, null, 'no price is never a price of zero');
+ assert.strictEqual(offer.feed.was, null);
+ assert.strictEqual(offer.feed.media, null);
+ });
+
+ await check('a deal a person submitted carries its price too', async () => {
+ const alice = t.network.addUser('alice');
+ const offer = await t.submit(alice, { url: 'https://shop.example/lamp', title: 'Reading lamp', price: '24.50', currency: 'GBP' });
+ assert.ok(offer, 'the offer was submitted');
+ const json = await t.offerJson(offer.slug);
+ assert.deepStrictEqual(json.feed.price, { amount: 24.5, currency: 'GBP' }, 'the price the person stated');
+ assert.strictEqual(json.feed.was, null, 'nothing was said about an old price');
+ assert.strictEqual(json.feed.media, null, 'and there is no picture');
+ });
+
+ await t.close();
+ done();
+})().catch((err) => { console.error(err); process.exitCode = 1; });