openvibers/openvibe.sources Commits

An api endpoint may read a tab-separated body (format tsv) and keep the newest of a series' rows (duplicates last); BLS average food prices seeded (disabled) (#97)

OP OpenVibers committed 20 days ago · bf98f3ede7 · 4 files, +163 −9

download.bls.gov publishes the Average Price series as one tab-separated file, each series oldest to newest
(api.bls.gov's robots.txt disallows every path). The seed keeps the 13 US city average series that match
OpenVibe.Food's food list, one item per series with its newest month. Needs openvibe-contracts 0.212.0
(sources.source@1 format tsv, duplicates).

Changes

ddiff --git a/seeds/sources.json b/seeds/sources.json
iindex 756631b..27949aa 100644
---- a/seeds/sources.json
++++ b/seeds/sources.json
@@@ -1822,6 +1822,60 @@
"review_required": true,
"sensitivity": "none",
"default_indexability": "noindex"
++ },
++ {
++ "key": "bls-average-prices",
++ "name": "BLS average retail food prices (US city average)",
++ "type": "api",
++ "category": "food",
++ "homepage_url": "https://www.bls.gov/cpi/factsheets/average-prices.htm",
++ "endpoints": [
++ {
++ "url": "https://download.bls.gov/pub/time.series/ap/ap.data.0.Current",
++ "format": "tsv",
++ "items_path": "",
++ "item_kind": "price",
++ "duplicates": "last",
++ "max_bytes": 16777216,
++ "timeout_ms": 60000,
++ "where": {
++ "series_id": [
++ "APU0000708111",
++ "APU0000FF1101",
++ "APU0000703112",
++ "APU0000702111",
++ "APU0000701312",
++ "APU0000701322",
++ "APU0000714233",
++ "APU0000711211",
++ "APU0000711311",
++ "APU0000711415",
++ "APU0000712112",
++ "APU0000709112",
++ "APU0000710212"
++ ]
++ },
++ "fields": {
++ "identity": "series_id",
++ "title": "series_id"
++ },
++ "extra": {
++ "value": "value",
++ "year": "year",
++ "period": "period"
++ }
++ }
++ ],
++ "robots_note": "download.bls.gov serves no robots.txt (404, checked 2026-10-23); the fetcher re-checks it on every run. api.bls.gov disallows every path in its robots.txt, so the API is not used: this is BLS's published flat file of the Average Price series (ap.data.0.Current, about 9 MB, every series oldest to newest). One request a day.",
++ "terms_note": "BLS data are US federal works in the public domain; BLS asks that it be cited as the source and notes that it cannot vouch for data or analyses after they leave BLS.gov (bls.gov/bls/linksite.htm). Food shows each price as 'US city average, BLS' with its month and that note. Kept: the 13 US city average (area 0000) series that match items on OpenVibe.Food's food list (series_id: APU0000708111 Eggs, grade A, large, per dozen, APU0000FF1101 Chicken breast, boneless, per lb, APU0000703112 Ground beef, 100% beef, per lb, APU0000702111 Bread, white, pan, per lb, APU0000701312 Rice, white, long grain, uncooked, per lb, APU0000701322 Spaghetti and macaroni, per lb, APU0000714233 Beans, dried, any type, per lb, APU0000711211 Bananas, per lb, APU0000711311 Oranges, Navel, per lb, APU0000711415 Strawberries, dry pint, per 12 oz, APU0000712112 Potatoes, white, per lb, APU0000709112 Milk, fresh, whole, fortified, per gallon, APU0000710212 Cheddar cheese, natural, per lb), each series' newest month. Monthly data: a daily poll is plenty. Re-verify before enabling.",
++ "license_note": "US federal government work (public domain in the US), BLS cited as the source.",
++ "min_interval_ms": 60000,
++ "poll_interval_sec": 86400,
++ "max_items": 50,
++ "enabled": false,
++ "review_required": true,
++ "sensitivity": "none",
++ "default_indexability": "noindex"
}
]
}
ddiff --git a/server/adapters/api.js b/server/adapters/api.js
iindex 03240dc..e13361d 100644
---- a/server/adapters/api.js
++++ b/server/adapters/api.js
@@@ -66,6 +66,16 @@
* 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.
++ *
++ * `"format": "tsv"` reads a tab-separated file whose first line names the columns: each further line is one record,
++ * its values trimmed text keyed by those names, and the body is that array (so `items_path` is ""). The Bureau of
++ * Labor Statistics publishes its series this way (download.bls.gov/pub/time.series/…: series_id, year, period,
++ * value). A line with a different number of values than the header is skipped; a file without a header is a parse
++ * error.
++ *
++ * Two records with the same identity keep the first (`"duplicates": "first"`, the default). `"duplicates": "last"`
++ * keeps the last instead, in the first one's place: a file that lists each series oldest to newest then yields its
++ * newest value.
*/
const { parseXml, toText, toIsoDate, epochSecondsToIso, canonicalUrl, toId, ParseError } = require('./util');
@@@ -214,7 +224,9 @@ function keyPathViolation(p, shape) {
/** Validate an endpoint mapping (registry side). Returns an error string or null. */
function checkMapping(ep) {
-- if (!['json', 'xml'].includes(ep.format || 'json')) return 'format must be json or xml';
++ if (!['json', 'xml', 'tsv'].includes(ep.format || 'json')) return 'format must be json, xml or tsv';
++ if ((ep.format || 'json') === 'tsv' && ep.items_path) return 'a tsv body is the array itself: items_path is ""';
++ if (ep.duplicates !== undefined && !['first', 'last'].includes(ep.duplicates)) return 'duplicates must be first or last';
const shape = ep.items_shape || 'array';
if (!['array', 'map'].includes(shape)) return 'items_shape must be array or map';
if (ep.items_path !== undefined && ep.items_path !== '' && !PATH_RE.test(ep.items_path)) return 'items_path is malformed';
@@@ -267,10 +279,30 @@ function checkMapping(ep) {
return null;
}
++/** A tab-separated body as records keyed by its header line's names (values trimmed); malformed lines are counted. */
++function parseTsv(text) {
++ const lines = String(text).split(/\r?\n/).filter((l) => l.trim() !== '');
++ if (!lines.length) throw new ParseError('tsv body has no header line');
++ const names = lines[0].split('\t').map((s) => s.trim());
++ if (names.some((n) => !n) || new Set(names).size !== names.length) throw new ParseError('tsv header names must be present and distinct');
++ const records = [];
++ let bad = 0;
++ for (const line of lines.slice(1)) {
++ const values = line.split('\t');
++ if (values.length !== names.length) { bad++; continue; }
++ const rec = {};
++ names.forEach((n, i) => { rec[n] = values[i].trim(); });
++ records.push(rec);
++ }
++ return { records, bad };
++}
++
function parse(text, { url, endpoint = {}, summaryMax = 1000, maxItems = 500 } = {}) {
const format = endpoint.format || 'json';
let body;
++ let malformed = 0;
if (format === 'xml') body = parseXml(text);
++ else if (format === 'tsv') ({ records: body, bad: malformed } = parseTsv(text));
else {
try { body = JSON.parse(text); } catch { throw new ParseError('body is not valid JSON'); }
}
@@@ -289,22 +321,24 @@ function parse(text, { url, endpoint = {}, summaryMax = 1000, maxItems = 500 } =
const where = endpoint.where && typeof endpoint.where === 'object' && !Array.isArray(endpoint.where) ? endpoint.where : {};
const filterKeys = Object.keys(where);
const items = [];
-- let skipped = 0;
++ let skipped = malformed;
let filtered = 0;
-- const seen = new Set();
++ const seen = new Map(); // identity → its index in items
++ const keepLast = endpoint.duplicates === 'last';
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) => (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; }
-- seen.add(identity);
++ const again = identity && seen.has(identity);
++ if (!identity || (again && !keepLast)) { skipped++; continue; }
++ if (!again && items.length >= maxItems) { skipped++; continue; }
++ if (again) skipped++; // the earlier record it replaces
const extra = {};
for (const [k, p] of Object.entries(endpoint.extra || {})) extra[k] = scalar(get(p));
for (const [k, p] of Object.entries(endpoint.extra_lists || {})) extra[k] = scalars(get(p));
-- items.push({
++ const item = {
identity: String(identity),
kind: endpoint.item_kind || 'record',
canonical_url: link,
@@@ -314,7 +348,9 @@ function parse(text, { url, endpoint = {}, summaryMax = 1000, maxItems = 500 } =
published_at: f.published_at ? toIsoDate(scalar(get(f.published_at))) : f.published_at_epoch ? epochSecondsToIso(scalar(get(f.published_at_epoch))) : null,
updated_at: f.updated_at ? toIsoDate(scalar(get(f.updated_at))) : f.updated_at_epoch ? epochSecondsToIso(scalar(get(f.updated_at_epoch))) : null,
fields: extra,
-- });
++ };
++ if (again) items[seen.get(identity)] = item;
++ else { seen.set(identity, items.length); items.push(item); }
}
return { items, skipped, meta: { format, filtered } };
}
ddiff --git a/server/adapters/index.js b/server/adapters/index.js
iindex f73765f..f1fc042 100644
---- a/server/adapters/index.js
++++ b/server/adapters/index.js
@@@ -13,7 +13,7 @@ const ADAPTERS = {
atom: { parse: feed.parse, version: feed.PARSER_VERSION, accept: 'application/atom+xml, application/rss+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.1' },
sitemap: { parse: sitemap.parse, version: sitemap.PARSER_VERSION, accept: 'application/xml, text/xml;q=0.9, */*;q=0.1' },
jsonld: { parse: jsonld.parse, version: jsonld.PARSER_VERSION, accept: 'text/html, application/xhtml+xml;q=0.9, */*;q=0.1' },
-- api: { parse: api.parse, version: api.PARSER_VERSION, accept: 'application/json, application/xml;q=0.9, */*;q=0.1' },
++ api: { parse: api.parse, version: api.PARSER_VERSION, accept: 'application/json, application/xml;q=0.9, text/tab-separated-values;q=0.9, text/plain;q=0.8, */*;q=0.1' },
};
const MANUAL_VERSION = 'manual@1';
ddiff --git a/test/api-tsv.test.js b/test/api-tsv.test.js
nnew file mode 100644
iindex 0000000..ed09d24
---- /dev/null
++++ b/test/api-tsv.test.js
@@@ -0,0 +1,64 @@
++'use strict';
++/** API adapter: `format: "tsv"` reads a tab-separated file by its header, and `duplicates: "last"` keeps a series' newest row. */
++const assert = require('assert');
++const api = require('../server/adapters/api');
++const { suite } = require('./helpers');
++
++const t = suite('api-tsv');
++
++// download.bls.gov/pub/time.series/ap/ap.data.0.Current as BLS writes it: padded names and values, CRLF, an empty
++// last column, every series oldest to newest.
++const BLS = [
++ 'series_id \tyear\tperiod\t value\tfootnote_codes',
++ 'APU0000708111 \t2026\tM07\t 2.301\t',
++ 'APU0000708111 \t2026\tM08\t 2.272\t',
++ 'APU0000708111 \t2026\tM09\t 2.272\t',
++ 'APU0000709112 \t2026\tM08\t 4.016\t',
++ 'APU0000709112 \t2026\tM09\t 4.051\t',
++ 'APU0000711211 \t2026\tM09\t 0.633\t',
++ 'APU0000799999 \t2026\tM09\t 9.999\t',
++ 'a broken line without tabs',
++ '',
++].join('\r\n');
++
++const blsEndpoint = {
++ format: 'tsv', items_path: '', item_kind: 'price', duplicates: 'last',
++ where: { series_id: ['APU0000708111', 'APU0000709112', 'APU0000711211'] },
++ fields: { identity: 'series_id', title: 'series_id' },
++ extra: { value: 'value', year: 'year', period: 'period' },
++};
++
++t('a BLS series file: one item per wanted series with its newest value; others filtered, broken lines skipped', () => {
++ assert.strictEqual(api.checkMapping(blsEndpoint), null);
++ const r = api.parse(BLS, { url: 'https://download.bls.gov/pub/time.series/ap/ap.data.0.Current', endpoint: blsEndpoint });
++ assert.strictEqual(r.meta.format, 'tsv');
++ assert.deepStrictEqual(r.items.map((i) => [i.identity, i.fields.value, i.fields.period]), [
++ ['APU0000708111', '2.272', 'M09'],
++ ['APU0000709112', '4.051', 'M09'],
++ ['APU0000711211', '0.633', 'M09'],
++ ], 'the newest row of each, in first-seen order; values trimmed');
++ assert.strictEqual(r.items[0].kind, 'price');
++ assert.strictEqual(r.items[0].fields.year, '2026');
++ assert.strictEqual(r.meta.filtered, 1, 'the series nobody asked for');
++ assert.strictEqual(r.skipped, 4, 'the broken line and the three older rows replaced');
++});
++
++t('duplicates default to the first; maxItems counts distinct identities', () => {
++ const first = api.parse(BLS, { url: 'https://download.bls.gov/x', endpoint: { ...blsEndpoint, duplicates: undefined } });
++ assert.strictEqual(first.items[0].fields.value, '2.301', 'the first row stays');
++ const capped = api.parse(BLS, { url: 'https://download.bls.gov/x', endpoint: blsEndpoint, maxItems: 2 });
++ assert.deepStrictEqual(capped.items.map((i) => [i.identity, i.fields.period]), [['APU0000708111', 'M09'], ['APU0000709112', 'M09']], 'a repeat of a kept series still replaces it at the cap');
++});
++
++t('mapping and body errors', () => {
++ assert.match(api.checkMapping({ ...blsEndpoint, format: 'csv' }), /format must be json, xml or tsv/);
++ assert.match(api.checkMapping({ ...blsEndpoint, items_path: 'rows' }), /items_path is ""/);
++ assert.match(api.checkMapping({ ...blsEndpoint, duplicates: 'newest' }), /duplicates must be first or last/);
++ assert.throws(() => api.parse('', { url: 'https://x.example/', endpoint: blsEndpoint }), /no header line/);
++ assert.throws(() => api.parse('a\t\tb\n1\t2\t3', { url: 'https://x.example/', endpoint: blsEndpoint }), /header names must be present and distinct/);
++ // JSON endpoints are unchanged: a duplicate identity still keeps the first.
++ const json = api.parse(JSON.stringify([{ id: 'a', v: 1 }, { id: 'a', v: 2 }]), { url: 'https://x.example/', endpoint: { fields: { identity: 'id' }, extra: { v: 'v' } } });
++ assert.deepStrictEqual([json.items.length, json.items[0].fields.v, json.skipped], [1, 1, 1]);
++});
++
++t.run();