openvibers/openvibe.sources Commits
Steam's featured lists for OpenVibe.Games: the games category (contracts 0.205.0), three seeds on store.steampowered.com/api/featuredcategories (top sellers, new releases, specials; robots allows /api/, every 3 hours), and $index, an array record's position, so a ranked list keeps its order (#79)
Changes
@@@ -115,7 +115,9 @@ is `{ "pricing": { "com": {…}, "net": {…} } }`. `items_shape: "map"` reads tproperty becomes one record in sorted key order, and the property's key is available as thevirtual path `$key` (so `fields.identity: "$key"` makes the id the identity). A property whosevalue is not an object is skipped, and an `items_path` of the wrong shape is a parse error, never--an empty success.++an empty success. With the default `items_shape: "array"`, `$index` is a record's 0-based position in++the array (as a whole path, e.g. `"extra": { "position": "$index" }`), so a ranked list keeps its++order (Steam's top sellers).```json{ "url": "https://api.porkbun.com/api/json/v3/pricing/get", "format": "json",@@@ -9,7 +9,7 @@"properties": {"id": { "type": "string", "pattern": "^itm_[0-9A-HJKMNP-TV-Z]{26}$" },"source_key": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{1,63}$" },-- "category": { "enum": ["news", "blog", "reviews", "deals", "coupons", "trade", "hosting", "crowdfunding"] },++ "category": { "enum": ["news", "blog", "reviews", "deals", "coupons", "trade", "hosting", "crowdfunding", "games"] },"kind": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,39}$", "description": "article | url | sitemap | product | offer | review | review_signal | record | coupon …" },"identity": { "type": "string", "minLength": 1, "maxLength": 2048, "description": "The source's own id (guid, @id, API id) or the canonical URL." },"canonical_url": { "type": ["string", "null"], "format": "uri" },@@@ -49,7 +49,9 @@"deals","coupons","trade",-- "hosting", "crowdfunding"++ "hosting",++ "crowdfunding",++ "games"]},"homepage_url": {@@@ -130,7 +132,7 @@}]},-- "description": "Records kept only when every path equals its value (or one of an array of values), or, for { pattern }, is text the regular expression matches; others are left out before max_items counts. Paths follow the field rules: relative to the record, $key, an absolute $root.<path> into the response (a {$key} segment there is the record's map key), * for an object's single child, and key[field=value,...] selectors (Sources#39, Sources#49)."++ "description": "Records kept only when every path equals its value (or one of an array of values), or, for { pattern }, is text the regular expression matches; others are left out before max_items counts. Paths follow the field rules: relative to the record, $key, an absolute $root.<path> into the response (a {$key} segment there is the record's map key), * for an object's single child, and key[field=value,...] selectors (Sources#39, Sources#51)."},"item_kind": {"type": "string",@@@ -144,13 +146,13 @@"type": "integer","minimum": 1024,"maximum": 33554432,-- "description": "The body cap for this endpoint, in bytes (1 KiB to 32 MiB); the deployment's cap otherwise. OVHcloud's VPS catalog is about 12 MB (Sources#49)."++ "description": "The body cap for this endpoint, in bytes (1 KiB to 32 MiB); the deployment's cap otherwise. OVHcloud's VPS catalog is about 12 MB (Sources#51)."},"timeout_ms": {"type": "integer","minimum": 1000,"maximum": 120000,-- "description": "This endpoint's fetch deadline, in milliseconds (1 s to 120 s); the deployment's otherwise. NYC Open Data computes the HPD per-building violation tally in 12 to 60 seconds."++ "description": "This endpoint's fetch deadline, in milliseconds (1 s to 120 s); the deployment's otherwise. NYC Open Data computes the HPD per-building violation tally in 12 to 60 seconds (Sources#76)."},"fields": {"type": "object",@@@ -163,7 +165,7 @@"additionalProperties": {"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; key[field=value,...] (one to four conditions) steps into the first element of the array key whose fields equal the values, compared as text, where a value is literal text or $rec.<path> read from the record (Sources#49)."++ "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; key[field=value,...] (one to four conditions) steps into the first element of the array key whose fields equal the values, compared as text, where a value is literal text or $rec.<path> read from the record (Sources#51)."},"extract": {"type": "object",@@@ -1438,6 +1438,102 @@"review_required": true,"sensitivity": "none","default_indexability": "noindex"++ },++ {++ "key": "steam-top-sellers",++ "name": "Steam: top sellers (the store's featured list)",++ "type": "api",++ "category": "games",++ "homepage_url": "https://store.steampowered.com/",++ "endpoints": [++ {++ "url": "https://store.steampowered.com/api/featuredcategories?cc=us&l=english",++ "format": "json",++ "items_path": "top_sellers.items",++ "item_kind": "steam_top_seller",++ "fields": { "identity": "id", "title": "name" },++ "extra": {++ "appid": "id", "position": "$index", "final_price": "final_price", "original_price": "original_price", "discount_percent": "discount_percent",++ "discounted": "discounted", "currency": "currency", "header_image": "header_image",++ "windows": "windows_available", "mac": "mac_available", "linux": "linux_available", "discount_expiration": "discount_expiration"++ }++ }++ ],++ "robots_note": "robots.txt checked 2026-10-18 (https://store.steampowered.com/robots.txt): User-agent * disallows /share/, /news/externalpost/, account and email paths, /login and /join with tokens, /account/ackgift/, /email/ and /widget/; /api/ is not disallowed, so /api/featuredcategories is allowed. It answers 200 to the production host without a key. The fetcher re-checks robots.txt on every run.",++ "terms_note": "Steam's storefront endpoint is public and unauthenticated (no key, unlike api.steampowered.com's Web API, whose robots.txt disallows everything and which is never fetched). It is read once every 3 hours (the same response for all three lists). OpenVibe.Games shows each game's name, price, discount and Steam's header image as observed at that time, labelled 'From the Steam store, checked <time>', and links every game to its store page; it sells nothing and implies no endorsement by Valve.",++ "license_note": "Game names, prices and images are Valve's and the publishers' (Steam store data). Shown as dated observations with a link to each game's Steam store page; no licence to republish was found, so only these fields are kept and the images are loaded from Steam's own CDN.",++ "min_interval_ms": 60000,++ "poll_interval_sec": 10800,++ "stale_after_sec": 43200,++ "max_items": 50,++ "enabled": false,++ "review_required": true,++ "sensitivity": "none",++ "default_indexability": "noindex"++ },++ {++ "key": "steam-new-releases",++ "name": "Steam: new releases (the store's featured list)",++ "type": "api",++ "category": "games",++ "homepage_url": "https://store.steampowered.com/",++ "endpoints": [++ {++ "url": "https://store.steampowered.com/api/featuredcategories?cc=us&l=english",++ "format": "json",++ "items_path": "new_releases.items",++ "item_kind": "steam_new_release",++ "fields": { "identity": "id", "title": "name" },++ "extra": {++ "appid": "id", "position": "$index", "final_price": "final_price", "original_price": "original_price", "discount_percent": "discount_percent",++ "discounted": "discounted", "currency": "currency", "header_image": "header_image",++ "windows": "windows_available", "mac": "mac_available", "linux": "linux_available", "discount_expiration": "discount_expiration"++ }++ }++ ],++ "robots_note": "robots.txt checked 2026-10-18 (https://store.steampowered.com/robots.txt): User-agent * disallows /share/, /news/externalpost/, account and email paths, /login and /join with tokens, /account/ackgift/, /email/ and /widget/; /api/ is not disallowed, so /api/featuredcategories is allowed. It answers 200 to the production host without a key. The fetcher re-checks robots.txt on every run.",++ "terms_note": "Steam's storefront endpoint is public and unauthenticated (no key, unlike api.steampowered.com's Web API, whose robots.txt disallows everything and which is never fetched). It is read once every 3 hours (the same response for all three lists). OpenVibe.Games shows each game's name, price, discount and Steam's header image as observed at that time, labelled 'From the Steam store, checked <time>', and links every game to its store page; it sells nothing and implies no endorsement by Valve.",++ "license_note": "Game names, prices and images are Valve's and the publishers' (Steam store data). Shown as dated observations with a link to each game's Steam store page; no licence to republish was found, so only these fields are kept and the images are loaded from Steam's own CDN.",++ "min_interval_ms": 60000,++ "poll_interval_sec": 10800,++ "stale_after_sec": 43200,++ "max_items": 50,++ "enabled": false,++ "review_required": true,++ "sensitivity": "none",++ "default_indexability": "noindex"++ },++ {++ "key": "steam-specials",++ "name": "Steam: specials (the store's featured list)",++ "type": "api",++ "category": "games",++ "homepage_url": "https://store.steampowered.com/",++ "endpoints": [++ {++ "url": "https://store.steampowered.com/api/featuredcategories?cc=us&l=english",++ "format": "json",++ "items_path": "specials.items",++ "item_kind": "steam_special",++ "fields": { "identity": "id", "title": "name" },++ "extra": {++ "appid": "id", "position": "$index", "final_price": "final_price", "original_price": "original_price", "discount_percent": "discount_percent",++ "discounted": "discounted", "currency": "currency", "header_image": "header_image",++ "windows": "windows_available", "mac": "mac_available", "linux": "linux_available", "discount_expiration": "discount_expiration"++ }++ }++ ],++ "robots_note": "robots.txt checked 2026-10-18 (https://store.steampowered.com/robots.txt): User-agent * disallows /share/, /news/externalpost/, account and email paths, /login and /join with tokens, /account/ackgift/, /email/ and /widget/; /api/ is not disallowed, so /api/featuredcategories is allowed. It answers 200 to the production host without a key. The fetcher re-checks robots.txt on every run.",++ "terms_note": "Steam's storefront endpoint is public and unauthenticated (no key, unlike api.steampowered.com's Web API, whose robots.txt disallows everything and which is never fetched). It is read once every 3 hours (the same response for all three lists). OpenVibe.Games shows each game's name, price, discount and Steam's header image as observed at that time, labelled 'From the Steam store, checked <time>', and links every game to its store page; it sells nothing and implies no endorsement by Valve.",++ "license_note": "Game names, prices and images are Valve's and the publishers' (Steam store data). Shown as dated observations with a link to each game's Steam store page; no licence to republish was found, so only these fields are kept and the images are loaded from Steam's own CDN.",++ "min_interval_ms": 60000,++ "poll_interval_sec": 10800,++ "stale_after_sec": 43200,++ "max_items": 50,++ "enabled": false,++ "review_required": true,++ "sensitivity": "none",++ "default_indexability": "noindex"}]}@@@ -59,7 +59,8 @@* `"items_shape": "map"` reads such an object: each own property becomes one record, in sorted* key order, and the key is available as the virtual path `$key` (so `fields.identity: "$key"`* makes the id the identity) or as `{$key}` inside a `$root.` path. `"array"` (the default) is-- * today's behaviour. A property whose value is not an object is skipped; a non-object++ * today's behaviour, and there the record's 0-based position in the array is the virtual path `$index` (a ranked++ * list's order, e.g. Steam's top sellers). A property whose value is not an object is skipped; a non-object* `items_path` is a parse error, never an empty success.*/const { parseXml, toText, toIsoDate, epochSecondsToIso, canonicalUrl, toId, ParseError } = require('./util');@@@ -189,6 +190,8 @@ function keyPathViolation(p, shape) {}if (hasBrace) return '{$key} is only valid inside a $root. path';if (p === '$key') return shape === 'map' ? null : '$key requires items_shape "map"';++ if (p === '$index') return shape === 'map' ? '$index requires items_shape "array"' : null;++ if (segs.includes('$index')) return 'an $index path is exactly "$index"';if (hasBare) return 'a $key path is exactly "$key"';return null;}@@@ -255,7 +258,7 @@ function parse(text, { url, endpoint = {}, summaryMax = 1000, maxItems = 500 } =records = Object.keys(node).sort().map((key) => ({ key, value: node[key] }));} else {if (!Array.isArray(node)) throw new ParseError(`items_path "${itemsPath}" is not an array in the response`);-- records = node.map((value) => ({ key: undefined, value }));++ records = node.map((value, index) => ({ key: undefined, index, value }));}const f = endpoint.fields || {};const where = endpoint.where && typeof endpoint.where === 'object' && !Array.isArray(endpoint.where) ? endpoint.where : {};@@@ -264,11 +267,11 @@ function parse(text, { url, endpoint = {}, summaryMax = 1000, maxItems = 500 } =let skipped = 0;let filtered = 0;const seen = new Set();-- for (const { key, value: rec } of records) {++ for (const { key, index, value: rec } of records) {if (!rec || typeof rec !== 'object' || (shape === 'map' && Array.isArray(rec))) { skipped++; continue; }if (filterKeys.length && !matchesWhere(body, rec, key, where)) { filtered++; continue; }if (items.length >= maxItems) { skipped++; continue; }-- const get = (p) => getField(body, rec, key, p);++ const get = (p) => (p === '$index' ? index : getField(body, rec, key, p));const link = f.url ? canonicalUrl(scalar(get(f.url)), url) : null;const identity = (f.identity ? toId(scalar(get(f.identity))) : null) || link;if (!identity || seen.has(identity)) { skipped++; continue; }@@@ -13,7 +13,7 @@ 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', 'crowdfunding'];++const CATEGORIES = ['news', 'blog', 'reviews', 'deals', 'coupons', 'trade', 'hosting', 'crowdfunding', 'games'];const SENSITIVITY = ['none', 'financial', 'health', 'political', 'legal', 'adult'];const AUTH_MODES = ['none', 'header', 'bearer', 'query'];const KEY_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;nnew file mode 100644@@@ -0,0 +1,67 @@++'use strict';++/**++ * Steam's featured lists for OpenVibe.Games (category games, contracts 0.205.0; the owner's valve.openvibe.games ask):++ * store.steampowered.com/api/featuredcategories, which robots.txt allows (api.steampowered.com, which disallows++ * everything, is never fetched), read every 3 hours. One source per list (top sellers, new releases, specials), so a++ * game on two lists is two items rather than one identity overwritten by the other list. Each item keeps the app id,++ * the name, the prices in cents, the discount, the currency, the header image and the platforms.++ */++const assert = require('assert');++const api = require('../server/adapters/api');++const { validateSource, CATEGORIES } = require('../server/registry');++const { load } = require('../server/config');++const { suite } = require('./helpers');++++const t = suite('steam-sources');++const seeds = require('../seeds/sources.json').sources;++const lists = ['steam-top-sellers', 'steam-new-releases', 'steam-specials'].map((k) => seeds.find((s) => s.key === k));++++const game = (id, name, extra = {}) => ({++ id, type: 0, name, discounted: false, discount_percent: 0, original_price: 2999, final_price: 2999, currency: 'USD',++ large_capsule_image: `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${id}/capsule_616x353.jpg`,++ header_image: `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${id}/header.jpg`,++ windows_available: true, mac_available: false, linux_available: true, controller_support: 'full', ...extra,++});++const BODY = JSON.stringify({++ top_sellers: { id: 'cat_topsellers', name: 'Top Sellers', items: [game(730, 'Counter-Strike 2', { final_price: 0, original_price: 0 }), game(4435490, 'Call of Duty®: Modern Warfare® 4', { final_price: 6999, original_price: 6999 })] },++ new_releases: { id: 'cat_newreleases', name: 'New Releases', items: [game(3527290, 'A New Game')] },++ specials: { id: 'cat_specials', name: 'Specials', items: [game(413150, 'Stardew Valley', { discounted: true, discount_percent: 50, final_price: 749, original_price: 1499, discount_expiration: 1792800000 })] },++ coming_soon: { id: 'cat_comingsoon', name: 'Coming Soon', items: [game(1, 'Not read')] },++ status: 1,++});++++t('the seeds validate, start disabled, are games sources on the robots-allowed store API, every 3 hours', () => {++ assert.ok(CATEGORIES.includes('games'));++ for (const s of lists) {++ assert.ok(s, 'seeded');++ const rec = validateSource(s);++ assert.strictEqual(rec.enabled, false);++ assert.strictEqual(rec.category, 'games');++ assert.ok(rec.robots_note && rec.terms_note && rec.license_note, `${s.key}: robots, terms and licence notes`);++ assert.strictEqual(new URL(s.endpoints[0].url).host, 'store.steampowered.com', 'never api.steampowered.com');++ assert.strictEqual(s.poll_interval_sec, 10800);++ assert.ok(rec.max_items <= load({}).items.maxPerFetch);++ }++});++++t('each list is its own items: id, name, prices, discount, image and platforms', () => {++ const [top, fresh, specials] = lists.map((s) => api.parse(BODY, { url: s.endpoints[0].url, endpoint: s.endpoints[0], maxItems: s.max_items }).items);++ assert.deepStrictEqual(top.map((i) => [i.kind, i.identity, i.title]), [['steam_top_seller', '730', 'Counter-Strike 2'], ['steam_top_seller', '4435490', 'Call of Duty®: Modern Warfare® 4']]);++ assert.deepStrictEqual(fresh.map((i) => i.identity), ['3527290']);++ const [sale] = specials;++ assert.strictEqual(sale.kind, 'steam_special');++ assert.deepStrictEqual(++ [sale.fields.appid, sale.fields.final_price, sale.fields.original_price, sale.fields.discount_percent, sale.fields.currency, sale.fields.windows, sale.fields.linux, sale.fields.mac, sale.fields.discount_expiration],++ [413150, 749, 1499, 50, 'USD', true, true, false, 1792800000],++ );++ assert.match(sale.fields.header_image, /^https:\/\/shared\.akamai\.steamstatic\.com\/.+header\.jpg$/);++ assert.ok(![...top, ...fresh, ...specials].some((i) => i.title === 'Not read'), 'coming soon is not one of the lists');++ assert.deepStrictEqual(top.map((i) => i.fields.position), [0, 1], "a list keeps the store's order ($index)");++});++++t('$index is an array record\'s position, as a whole path, never on a map', () => {++ const ep = (extra, shape) => ({ url: 'https://example.org/x', items_path: 'list', ...(shape ? { items_shape: shape } : {}), fields: { identity: 'id' }, extra });++ assert.strictEqual(api.checkMapping(ep({ position: '$index' })), null);++ assert.match(api.checkMapping(ep({ position: '$index' }, 'map')), /\$index requires items_shape "array"/);++ assert.match(api.checkMapping(ep({ position: 'a.$index' })), /exactly "\$index"/);++});
@@ -115,7 +115,9 @@ is `{ "pricing": { "com": {…}, "net": {…} } }`. `items_shape: "map"` reads t
@@ -115,7 +115,9 @@ is `{ "pricing": { "com": {…}, "net": {…} } }`. `items_shape: "map"` reads t
property becomes one record in sorted key order, and the property's key is available as the
property becomes one record in sorted key order, and the property's key is available as the
virtual path `$key` (so `fields.identity: "$key"` makes the id the identity). A property whose
virtual path `$key` (so `fields.identity: "$key"` makes the id the identity). A property whose
value is not an object is skipped, and an `items_path` of the wrong shape is a parse error, never
value is not an object is skipped, and an `items_path` of the wrong shape is a parse error, never
-an empty success.
+an empty success. With the default `items_shape: "array"`, `$index` is a record's 0-based position in
+the array (as a whole path, e.g. `"extra": { "position": "$index" }`), so a ranked list keeps its
+order (Steam's top sellers).
```json
```json
{ "url": "https://api.porkbun.com/api/json/v3/pricing/get", "format": "json",
{ "url": "https://api.porkbun.com/api/json/v3/pricing/get", "format": "json",
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
"properties": {
"properties": {
"id": { "type": "string", "pattern": "^itm_[0-9A-HJKMNP-TV-Z]{26}$" },
"id": { "type": "string", "pattern": "^itm_[0-9A-HJKMNP-TV-Z]{26}$" },
"source_key": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{1,63}$" },
"source_key": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{1,63}$" },
- "category": { "enum": ["news", "blog", "reviews", "deals", "coupons", "trade", "hosting", "crowdfunding"] },
+ "category": { "enum": ["news", "blog", "reviews", "deals", "coupons", "trade", "hosting", "crowdfunding", "games"] },
"kind": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,39}$", "description": "article | url | sitemap | product | offer | review | review_signal | record | coupon …" },
"kind": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,39}$", "description": "article | url | sitemap | product | offer | review | review_signal | record | coupon …" },
"identity": { "type": "string", "minLength": 1, "maxLength": 2048, "description": "The source's own id (guid, @id, API id) or the canonical URL." },
"identity": { "type": "string", "minLength": 1, "maxLength": 2048, "description": "The source's own id (guid, @id, API id) or the canonical URL." },
"canonical_url": { "type": ["string", "null"], "format": "uri" },
"canonical_url": { "type": ["string", "null"], "format": "uri" },
@@ -49,7 +49,9 @@
@@ -49,7 +49,9 @@
"deals",
"deals",
"coupons",
"coupons",
"trade",
"trade",
- "hosting", "crowdfunding"
+ "hosting",
+ "crowdfunding",
+ "games"
]
]
},
},
"homepage_url": {
"homepage_url": {
@@ -130,7 +132,7 @@
@@ -130,7 +132,7 @@
}
}
]
]
},
},
- "description": "Records kept only when every path equals its value (or one of an array of values), or, for { pattern }, is text the regular expression matches; others are left out before max_items counts. Paths follow the field rules: relative to the record, $key, an absolute $root.<path> into the response (a {$key} segment there is the record's map key), * for an object's single child, and key[field=value,...] selectors (Sources#39, Sources#49)."
+ "description": "Records kept only when every path equals its value (or one of an array of values), or, for { pattern }, is text the regular expression matches; others are left out before max_items counts. Paths follow the field rules: relative to the record, $key, an absolute $root.<path> into the response (a {$key} segment there is the record's map key), * for an object's single child, and key[field=value,...] selectors (Sources#39, Sources#51)."
},
},
"item_kind": {
"item_kind": {
"type": "string",
"type": "string",
@@ -144,13 +146,13 @@
@@ -144,13 +146,13 @@
"type": "integer",
"type": "integer",
"minimum": 1024,
"minimum": 1024,
"maximum": 33554432,
"maximum": 33554432,
- "description": "The body cap for this endpoint, in bytes (1 KiB to 32 MiB); the deployment's cap otherwise. OVHcloud's VPS catalog is about 12 MB (Sources#49)."
+ "description": "The body cap for this endpoint, in bytes (1 KiB to 32 MiB); the deployment's cap otherwise. OVHcloud's VPS catalog is about 12 MB (Sources#51)."
},
},
"timeout_ms": {
"timeout_ms": {
"type": "integer",
"type": "integer",
"minimum": 1000,
"minimum": 1000,
"maximum": 120000,
"maximum": 120000,
- "description": "This endpoint's fetch deadline, in milliseconds (1 s to 120 s); the deployment's otherwise. NYC Open Data computes the HPD per-building violation tally in 12 to 60 seconds."
+ "description": "This endpoint's fetch deadline, in milliseconds (1 s to 120 s); the deployment's otherwise. NYC Open Data computes the HPD per-building violation tally in 12 to 60 seconds (Sources#76)."
},
},
"fields": {
"fields": {
"type": "object",
"type": "object",
@@ -163,7 +165,7 @@
@@ -163,7 +165,7 @@
"additionalProperties": {
"additionalProperties": {
"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; key[field=value,...] (one to four conditions) steps into the first element of the array key whose fields equal the values, compared as text, where a value is literal text or $rec.<path> read from the record (Sources#49)."
+ "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; key[field=value,...] (one to four conditions) steps into the first element of the array key whose fields equal the values, compared as text, where a value is literal text or $rec.<path> read from the record (Sources#51)."
},
},
"extract": {
"extract": {
"type": "object",
"type": "object",
@@ -1438,6 +1438,102 @@
@@ -1438,6 +1438,102 @@
"review_required": true,
"review_required": true,
"sensitivity": "none",
"sensitivity": "none",
"default_indexability": "noindex"
"default_indexability": "noindex"
+ },
+ {
+ "key": "steam-top-sellers",
+ "name": "Steam: top sellers (the store's featured list)",
+ "type": "api",
+ "category": "games",
+ "homepage_url": "https://store.steampowered.com/",
+ "endpoints": [
+ {
+ "url": "https://store.steampowered.com/api/featuredcategories?cc=us&l=english",
+ "format": "json",
+ "items_path": "top_sellers.items",
+ "item_kind": "steam_top_seller",
+ "fields": { "identity": "id", "title": "name" },
+ "extra": {
+ "appid": "id", "position": "$index", "final_price": "final_price", "original_price": "original_price", "discount_percent": "discount_percent",
+ "discounted": "discounted", "currency": "currency", "header_image": "header_image",
+ "windows": "windows_available", "mac": "mac_available", "linux": "linux_available", "discount_expiration": "discount_expiration"
+ }
+ }
+ ],
+ "robots_note": "robots.txt checked 2026-10-18 (https://store.steampowered.com/robots.txt): User-agent * disallows /share/, /news/externalpost/, account and email paths, /login and /join with tokens, /account/ackgift/, /email/ and /widget/; /api/ is not disallowed, so /api/featuredcategories is allowed. It answers 200 to the production host without a key. The fetcher re-checks robots.txt on every run.",
+ "terms_note": "Steam's storefront endpoint is public and unauthenticated (no key, unlike api.steampowered.com's Web API, whose robots.txt disallows everything and which is never fetched). It is read once every 3 hours (the same response for all three lists). OpenVibe.Games shows each game's name, price, discount and Steam's header image as observed at that time, labelled 'From the Steam store, checked <time>', and links every game to its store page; it sells nothing and implies no endorsement by Valve.",
+ "license_note": "Game names, prices and images are Valve's and the publishers' (Steam store data). Shown as dated observations with a link to each game's Steam store page; no licence to republish was found, so only these fields are kept and the images are loaded from Steam's own CDN.",
+ "min_interval_ms": 60000,
+ "poll_interval_sec": 10800,
+ "stale_after_sec": 43200,
+ "max_items": 50,
+ "enabled": false,
+ "review_required": true,
+ "sensitivity": "none",
+ "default_indexability": "noindex"
+ },
+ {
+ "key": "steam-new-releases",
+ "name": "Steam: new releases (the store's featured list)",
+ "type": "api",
+ "category": "games",
+ "homepage_url": "https://store.steampowered.com/",
+ "endpoints": [
+ {
+ "url": "https://store.steampowered.com/api/featuredcategories?cc=us&l=english",
+ "format": "json",
+ "items_path": "new_releases.items",
+ "item_kind": "steam_new_release",
+ "fields": { "identity": "id", "title": "name" },
+ "extra": {
+ "appid": "id", "position": "$index", "final_price": "final_price", "original_price": "original_price", "discount_percent": "discount_percent",
+ "discounted": "discounted", "currency": "currency", "header_image": "header_image",
+ "windows": "windows_available", "mac": "mac_available", "linux": "linux_available", "discount_expiration": "discount_expiration"
+ }
+ }
+ ],
+ "robots_note": "robots.txt checked 2026-10-18 (https://store.steampowered.com/robots.txt): User-agent * disallows /share/, /news/externalpost/, account and email paths, /login and /join with tokens, /account/ackgift/, /email/ and /widget/; /api/ is not disallowed, so /api/featuredcategories is allowed. It answers 200 to the production host without a key. The fetcher re-checks robots.txt on every run.",
+ "terms_note": "Steam's storefront endpoint is public and unauthenticated (no key, unlike api.steampowered.com's Web API, whose robots.txt disallows everything and which is never fetched). It is read once every 3 hours (the same response for all three lists). OpenVibe.Games shows each game's name, price, discount and Steam's header image as observed at that time, labelled 'From the Steam store, checked <time>', and links every game to its store page; it sells nothing and implies no endorsement by Valve.",
+ "license_note": "Game names, prices and images are Valve's and the publishers' (Steam store data). Shown as dated observations with a link to each game's Steam store page; no licence to republish was found, so only these fields are kept and the images are loaded from Steam's own CDN.",
+ "min_interval_ms": 60000,
+ "poll_interval_sec": 10800,
+ "stale_after_sec": 43200,
+ "max_items": 50,
+ "enabled": false,
+ "review_required": true,
+ "sensitivity": "none",
+ "default_indexability": "noindex"
+ },
+ {
+ "key": "steam-specials",
+ "name": "Steam: specials (the store's featured list)",
+ "type": "api",
+ "category": "games",
+ "homepage_url": "https://store.steampowered.com/",
+ "endpoints": [
+ {
+ "url": "https://store.steampowered.com/api/featuredcategories?cc=us&l=english",
+ "format": "json",
+ "items_path": "specials.items",
+ "item_kind": "steam_special",
+ "fields": { "identity": "id", "title": "name" },
+ "extra": {
+ "appid": "id", "position": "$index", "final_price": "final_price", "original_price": "original_price", "discount_percent": "discount_percent",
+ "discounted": "discounted", "currency": "currency", "header_image": "header_image",
+ "windows": "windows_available", "mac": "mac_available", "linux": "linux_available", "discount_expiration": "discount_expiration"
+ }
+ }
+ ],
+ "robots_note": "robots.txt checked 2026-10-18 (https://store.steampowered.com/robots.txt): User-agent * disallows /share/, /news/externalpost/, account and email paths, /login and /join with tokens, /account/ackgift/, /email/ and /widget/; /api/ is not disallowed, so /api/featuredcategories is allowed. It answers 200 to the production host without a key. The fetcher re-checks robots.txt on every run.",
+ "terms_note": "Steam's storefront endpoint is public and unauthenticated (no key, unlike api.steampowered.com's Web API, whose robots.txt disallows everything and which is never fetched). It is read once every 3 hours (the same response for all three lists). OpenVibe.Games shows each game's name, price, discount and Steam's header image as observed at that time, labelled 'From the Steam store, checked <time>', and links every game to its store page; it sells nothing and implies no endorsement by Valve.",
+ "license_note": "Game names, prices and images are Valve's and the publishers' (Steam store data). Shown as dated observations with a link to each game's Steam store page; no licence to republish was found, so only these fields are kept and the images are loaded from Steam's own CDN.",
+ "min_interval_ms": 60000,
+ "poll_interval_sec": 10800,
+ "stale_after_sec": 43200,
+ "max_items": 50,
+ "enabled": false,
+ "review_required": true,
+ "sensitivity": "none",
+ "default_indexability": "noindex"
}
}
]
]
}
}
@@ -59,7 +59,8 @@
@@ -59,7 +59,8 @@
* `"items_shape": "map"` reads such an object: each own property becomes one record, in sorted
* `"items_shape": "map"` reads such an object: each own property becomes one record, in sorted
* key order, and the key is available as the virtual path `$key` (so `fields.identity: "$key"`
* key order, and the key is available as the virtual path `$key` (so `fields.identity: "$key"`
* makes the id the identity) or as `{$key}` inside a `$root.` path. `"array"` (the default) is
* makes the id the identity) or as `{$key}` inside a `$root.` path. `"array"` (the default) is
- * today's behaviour. A property whose value is not an object is skipped; a non-object
+ * today's behaviour, and there the record's 0-based position in the array is the virtual path `$index` (a ranked
+ * list's order, e.g. Steam's top sellers). A property whose value is not an object is skipped; a non-object
* `items_path` is a parse error, never an empty success.
* `items_path` is a parse error, never an empty success.
*/
*/
const { parseXml, toText, toIsoDate, epochSecondsToIso, canonicalUrl, toId, ParseError } = require('./util');
const { parseXml, toText, toIsoDate, epochSecondsToIso, canonicalUrl, toId, ParseError } = require('./util');
@@ -189,6 +190,8 @@ function keyPathViolation(p, shape) {
@@ -189,6 +190,8 @@ function keyPathViolation(p, shape) {
}
}
if (hasBrace) return '{$key} is only valid inside a $root. path';
if (hasBrace) return '{$key} is only valid inside a $root. path';
if (p === '$key') return shape === 'map' ? null : '$key requires items_shape "map"';
if (p === '$key') return shape === 'map' ? null : '$key requires items_shape "map"';
+ if (p === '$index') return shape === 'map' ? '$index requires items_shape "array"' : null;
+ if (segs.includes('$index')) return 'an $index path is exactly "$index"';
if (hasBare) return 'a $key path is exactly "$key"';
if (hasBare) return 'a $key path is exactly "$key"';
return null;
return null;
}
}
@@ -255,7 +258,7 @@ function parse(text, { url, endpoint = {}, summaryMax = 1000, maxItems = 500 } =
@@ -255,7 +258,7 @@ function parse(text, { url, endpoint = {}, summaryMax = 1000, maxItems = 500 } =
records = Object.keys(node).sort().map((key) => ({ key, value: node[key] }));
records = Object.keys(node).sort().map((key) => ({ key, value: node[key] }));
} else {
} else {
if (!Array.isArray(node)) throw new ParseError(`items_path "${itemsPath}" is not an array in the response`);
if (!Array.isArray(node)) throw new ParseError(`items_path "${itemsPath}" is not an array in the response`);
- records = node.map((value) => ({ key: undefined, value }));
+ records = node.map((value, index) => ({ key: undefined, index, value }));
}
}
const f = endpoint.fields || {};
const f = endpoint.fields || {};
const where = endpoint.where && typeof endpoint.where === 'object' && !Array.isArray(endpoint.where) ? endpoint.where : {};
const where = endpoint.where && typeof endpoint.where === 'object' && !Array.isArray(endpoint.where) ? endpoint.where : {};
@@ -264,11 +267,11 @@ function parse(text, { url, endpoint = {}, summaryMax = 1000, maxItems = 500 } =
@@ -264,11 +267,11 @@ function parse(text, { url, endpoint = {}, summaryMax = 1000, maxItems = 500 } =
let skipped = 0;
let skipped = 0;
let filtered = 0;
let filtered = 0;
const seen = new Set();
const seen = new Set();
- for (const { key, value: rec } of records) {
+ for (const { key, index, value: rec } of records) {
if (!rec || typeof rec !== 'object' || (shape === 'map' && Array.isArray(rec))) { skipped++; continue; }
if (!rec || typeof rec !== 'object' || (shape === 'map' && Array.isArray(rec))) { skipped++; continue; }
if (filterKeys.length && !matchesWhere(body, rec, key, where)) { filtered++; continue; }
if (filterKeys.length && !matchesWhere(body, rec, key, where)) { filtered++; continue; }
if (items.length >= maxItems) { skipped++; continue; }
if (items.length >= maxItems) { skipped++; continue; }
- const get = (p) => getField(body, rec, key, p);
+ const get = (p) => (p === '$index' ? index : getField(body, rec, key, p));
const link = f.url ? canonicalUrl(scalar(get(f.url)), url) : null;
const link = f.url ? canonicalUrl(scalar(get(f.url)), url) : null;
const identity = (f.identity ? toId(scalar(get(f.identity))) : null) || link;
const identity = (f.identity ? toId(scalar(get(f.identity))) : null) || link;
if (!identity || seen.has(identity)) { skipped++; continue; }
if (!identity || seen.has(identity)) { skipped++; continue; }
@@ -13,7 +13,7 @@ const { checkApiMapping } = require('./adapters');
@@ -13,7 +13,7 @@ const { checkApiMapping } = require('./adapters');
const extract = require('./adapters/extract');
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', 'crowdfunding'];
+const CATEGORIES = ['news', 'blog', 'reviews', 'deals', 'coupons', 'trade', 'hosting', 'crowdfunding', 'games'];
const SENSITIVITY = ['none', 'financial', 'health', 'political', 'legal', 'adult'];
const SENSITIVITY = ['none', 'financial', 'health', 'political', 'legal', 'adult'];
const AUTH_MODES = ['none', 'header', 'bearer', 'query'];
const AUTH_MODES = ['none', 'header', 'bearer', 'query'];
const KEY_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;
const KEY_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+'use strict';
+/**
+ * Steam's featured lists for OpenVibe.Games (category games, contracts 0.205.0; the owner's valve.openvibe.games ask):
+ * store.steampowered.com/api/featuredcategories, which robots.txt allows (api.steampowered.com, which disallows
+ * everything, is never fetched), read every 3 hours. One source per list (top sellers, new releases, specials), so a
+ * game on two lists is two items rather than one identity overwritten by the other list. Each item keeps the app id,
+ * the name, the prices in cents, the discount, the currency, the header image and the platforms.
+ */
+const assert = require('assert');
+const api = require('../server/adapters/api');
+const { validateSource, CATEGORIES } = require('../server/registry');
+const { load } = require('../server/config');
+const { suite } = require('./helpers');
+
+const t = suite('steam-sources');
+const seeds = require('../seeds/sources.json').sources;
+const lists = ['steam-top-sellers', 'steam-new-releases', 'steam-specials'].map((k) => seeds.find((s) => s.key === k));
+
+const game = (id, name, extra = {}) => ({
+ id, type: 0, name, discounted: false, discount_percent: 0, original_price: 2999, final_price: 2999, currency: 'USD',
+ large_capsule_image: `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${id}/capsule_616x353.jpg`,
+ header_image: `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${id}/header.jpg`,
+ windows_available: true, mac_available: false, linux_available: true, controller_support: 'full', ...extra,
+});
+const BODY = JSON.stringify({
+ top_sellers: { id: 'cat_topsellers', name: 'Top Sellers', items: [game(730, 'Counter-Strike 2', { final_price: 0, original_price: 0 }), game(4435490, 'Call of Duty®: Modern Warfare® 4', { final_price: 6999, original_price: 6999 })] },
+ new_releases: { id: 'cat_newreleases', name: 'New Releases', items: [game(3527290, 'A New Game')] },
+ specials: { id: 'cat_specials', name: 'Specials', items: [game(413150, 'Stardew Valley', { discounted: true, discount_percent: 50, final_price: 749, original_price: 1499, discount_expiration: 1792800000 })] },
+ coming_soon: { id: 'cat_comingsoon', name: 'Coming Soon', items: [game(1, 'Not read')] },
+ status: 1,
+});
+
+t('the seeds validate, start disabled, are games sources on the robots-allowed store API, every 3 hours', () => {
+ assert.ok(CATEGORIES.includes('games'));
+ for (const s of lists) {
+ assert.ok(s, 'seeded');
+ const rec = validateSource(s);
+ assert.strictEqual(rec.enabled, false);
+ assert.strictEqual(rec.category, 'games');
+ assert.ok(rec.robots_note && rec.terms_note && rec.license_note, `${s.key}: robots, terms and licence notes`);
+ assert.strictEqual(new URL(s.endpoints[0].url).host, 'store.steampowered.com', 'never api.steampowered.com');
+ assert.strictEqual(s.poll_interval_sec, 10800);
+ assert.ok(rec.max_items <= load({}).items.maxPerFetch);
+ }
+});
+
+t('each list is its own items: id, name, prices, discount, image and platforms', () => {
+ const [top, fresh, specials] = lists.map((s) => api.parse(BODY, { url: s.endpoints[0].url, endpoint: s.endpoints[0], maxItems: s.max_items }).items);
+ assert.deepStrictEqual(top.map((i) => [i.kind, i.identity, i.title]), [['steam_top_seller', '730', 'Counter-Strike 2'], ['steam_top_seller', '4435490', 'Call of Duty®: Modern Warfare® 4']]);
+ assert.deepStrictEqual(fresh.map((i) => i.identity), ['3527290']);
+ const [sale] = specials;
+ assert.strictEqual(sale.kind, 'steam_special');
+ assert.deepStrictEqual(
+ [sale.fields.appid, sale.fields.final_price, sale.fields.original_price, sale.fields.discount_percent, sale.fields.currency, sale.fields.windows, sale.fields.linux, sale.fields.mac, sale.fields.discount_expiration],
+ [413150, 749, 1499, 50, 'USD', true, true, false, 1792800000],
+ );
+ assert.match(sale.fields.header_image, /^https:\/\/shared\.akamai\.steamstatic\.com\/.+header\.jpg$/);
+ assert.ok(![...top, ...fresh, ...specials].some((i) => i.title === 'Not read'), 'coming soon is not one of the lists');
+ assert.deepStrictEqual(top.map((i) => i.fields.position), [0, 1], "a list keeps the store's order ($index)");
+});
+
+t('$index is an array record\'s position, as a whole path, never on a map', () => {
+ const ep = (extra, shape) => ({ url: 'https://example.org/x', items_path: 'list', ...(shape ? { items_shape: shape } : {}), fields: { identity: 'id' }, extra });
+ assert.strictEqual(api.checkMapping(ep({ position: '$index' })), null);
+ assert.match(api.checkMapping(ep({ position: '$index' }, 'map')), /\$index requires items_shape "array"/);
+ assert.match(api.checkMapping(ep({ position: 'a.$index' })), /exactly "\$index"/);
+});