openvibers/openvibe.sources Commits
USDA ERS fruit and vegetable prices: CSV api format, composite identity, disabled seed (#205)
Changes
@@@ -105,7 +105,7 @@ succeeded. Health status: `healthy | stale | failing | never_fetched | disabled| `rss`, `atom` (`feed@3`) | RSS 2.0, RSS 1.0/RDF, Atom 1.0 | guid/id identity, else link; text-only summary capped at 1000 chars; a deal feed's own offer elements (DealNews' `dealnews:price` with its currency, `dealnews:retailer`, `dealnews:expires`) become `fields.price`, `currency`, `seller` and `valid_until` exactly as stated (the item stays an article; nothing is read out of the headline); its `dealnews:category` fills `fields.categories` when the item has no `<category>`, and `dealnews:staffPick` is `fields.staff_pick` (true/false, absent when not stated) || `sitemap` (`sitemap@1`) | `<urlset>` (+ Google News extension), `<sitemapindex>` | index children are listed, not followed || `jsonld` (`jsonld@2`) | schema.org Product, Offer/AggregateOffer, Review, NewsArticle, BlogPosting (+ Article) | prices, ratings and availability exactly as the page states; missing = null; embedded review text not kept; a page without JSON-LD is a `parse_error`; `list_price` from a ListPrice/StrikethroughPrice/MSRP/SRP `priceSpecification`, as stated |--| `api` (`api@1`) | JSON or XML official APIs via a registry mapping | `items_path`, `items_shape` (`array` default, `map` for records keyed by id), `fields`, `extra`, `where`/`filter`; epoch fields only where mapped as such; `resolver: "hn-whoishiring"` walks the Hacker News thread's JSON documents instead |++| `api` (`api@1`) | JSON, XML, TSV or CSV official files via a registry mapping | `items_path`, `items_shape` (`array` default, `map` for records keyed by id), `fields`, `extra`, `where`/`filter`; epoch fields only where mapped as such; `resolver: "hn-whoishiring"` walks the Hacker News thread's JSON documents instead || `bulk` (`bulk@1`) | one dataset file, streamed (CSV, JSONL or TSV) | `max_bytes` is the size cap, `max_rows` the per-run row limit; the stream stops at the limit, so a multi-GB file is never held in memory. `resolver: "off-delta"` reads a text index and only the gzipped JSONL delta parts it lists, keeping the last finished part and the current part's row offset in `source_state` (Open Food Facts) || `crawl` (`jsonld@2`, `page@1`) | a whole site from its seeds: its sitemaps and, if asked, its links (see below) | only pages that publish JSON-LD are kept, unless `require_structured: false` keeps a `page` item from the page's own search metadata || `manual` (`manual@1`) | items entered through the API with an evidence URL | no fetcher |@@@ -122,6 +122,13 @@ an empty success. With the default `items_shape: "array"`, `$index` is a record'the array (as a whole path, e.g. `"extra": { "position": "$index" }`), so a ranked list keeps itsorder (Steam's top sellers).++A CSV or TSV body likewise names its columns on the first line: each further line is a record of++trimmed text, and `items_path` must be "". CSV follows RFC 4180 quoting (a quoted field may++hold commas, doubled quotes and line breaks); a row whose value count differs from the header is++skipped, and a body that ends inside a quoted field is a parse error. When no single column is unique,++`identity_fields` names two to eight paths whose values, joined with " | ", are the record's identity++(the USDA ERS table prices Apples in four forms); `fields.identity` cannot be given with it.++```json{ "url": "https://api.porkbun.com/api/json/v3/pricing/get", "format": "json","items_path": "pricing", "items_shape": "map", "item_kind": "tld",@@@ -591,7 +598,7 @@ in [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites); thPart of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.289.1++- openvibe-contracts: v0.290.0- openvibe-sdk: v0.82.0- openvibe-shared: v4.109.2<!-- versions:end -->@@@ -7,7 +7,7 @@"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, fast-xml-parser","port": 4720,"domain": "sources.openvibe.network (vhost in deploy/nginx: an internal-service page, the merchant opt-in pages, health and JSON 404s; the JSON API is loopback-only; README \"Public host\")",-- "contracts": "openvibe-contracts v0.289.1",++ "contracts": "openvibe-contracts v0.290.0","sdk": "openvibe-sdk v0.82.0","shared": "openvibe-shared v4.109.2","capabilities": [@@@ -51,5 +51,5 @@"staff-only index documents for Search","per-actor limits (openvibe-sdk/limits)"],-- "updated": "2026-11-05"++ "updated": "2026-11-06"}@@@ -89,7 +89,16 @@"csv","jsonl"],-- "description": "json (default), xml, tsv (a tab-separated file whose first line names the columns; BLS series files, Sources 2026-10-24), or, for a bulk source, csv, jsonl or tsv: one dataset file read as a stream, row by row (Sources, 2026-10-31)."++ "description": "json (default), xml, tsv (a tab-separated file whose first line names the columns; BLS series files, Sources 2026-10-24), csv (a comma-separated file with RFC 4180 quoting; USDA ERS fruit and vegetable prices, Sources 2026-11-05), or, for a bulk source, csv, jsonl or tsv: one dataset file read as a stream, row by row (Sources, 2026-10-31)."++ },++ "identity_fields": {++ "type": "array",++ "minItems": 2,++ "maxItems": 8,++ "items": {++ "type": "string"++ },++ "description": "A record's identity from several columns, joined in order with \" | \", for a table where no single column is unique (USDA ERS prices a product in several forms; Sources 2026-11-05). Requires items_shape array and cannot be given with fields.identity."},"duplicates": {"enum": [@@@ -12,7 +12,7 @@"dotenv": "^16.4.7","express": "^4.21.2","fast-xml-parser": "^4.5.3",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.290.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2","pg": "^8.23.0"@@@ -1044,9 +1044,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.289.1",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",-- "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==",++ "version": "0.290.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.290.0",++ "integrity": "sha512-WX9p49Wyr5dPRQ9iQEJvVEPIQwkJYsJTLryU5DJJI/GrxzfMZFN1nfuM5W/KmL3DGoRTAOg/KAWjs0OkunJyeg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -30,7 +30,7 @@"dotenv": "^16.4.7","express": "^4.21.2","fast-xml-parser": "^4.5.3",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.290.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2","pg": "^8.23.0"@@@ -1972,6 +1972,53 @@"sensitivity": "none","default_indexability": "noindex"},++ {++ "key": "usda-ers-fruit-vegetable-prices",++ "name": "USDA ERS average retail prices for fruits and vegetables",++ "type": "api",++ "category": "food",++ "homepage_url": "https://www.ers.usda.gov/data-products/fruit-and-vegetable-prices/",++ "endpoints": [++ {++ "url": "https://www.ers.usda.gov/media/6210/all-fruits-average-prices-csv-format.csv",++ "format": "csv",++ "items_path": "",++ "item_kind": "price",++ "identity_fields": ["Fruit", "Form"],++ "fields": { "title": "Fruit" },++ "extra": {++ "name": "Fruit",++ "form": "Form",++ "price": "AverageRetailPrice",++ "unit": "AverageRetailPriceUnitOfMeasure"++ }++ },++ {++ "url": "https://www.ers.usda.gov/media/6240/all-vegetables-average-prices-csv-format.csv",++ "format": "csv",++ "items_path": "",++ "item_kind": "price",++ "identity_fields": ["Vegetable", "Form"],++ "fields": { "title": "Vegetable" },++ "extra": {++ "name": "Vegetable",++ "form": "Form",++ "price": "AverageRetailPrice",++ "unit": "AverageRetailPriceUnitOfMeasure"++ }++ }++ ],++ "robots_note": "robots.txt checked 2026-11-05 from the production host: User-agent * Allow /, with only /core/, /profiles/, /README.md and /web.config disallowed; the two /media/ CSV paths are allowed. The fetcher re-checks it on every run. Two CSV files, about 5 KB and 7 KB, one request each a run.",++ "terms_note": "Verified 2026-11-05 from the production host: USDA's Economic Research Service is a US federal agency, and the page says its fruit and vegetable prices are ERS's own estimates from Circana scanner data ('ERS calculated average prices at retail stores using 2013, 2016, 2020, 2022, and 2023 retail scanner data from Circana'). The documentation asks for the citation 'U.S. Department of Agriculture, Economic Research Service. (2025). Fruit and Vegetable Prices.' and the page says 'The findings in this data product or stated on this page should not be attributed to Circana.' The prices are annual national averages, updated 'each year, subject to data availability' (files last updated 2025-12-09), and the page says twice that the data 'should not be used for making inferences about price changes over time'. Kept: every row of the two published all-fruits and all-vegetables tables (product, form, average retail price per pound or pint, unit), one item per product and form. Food shows it as an annual national average with its 2023 data year, cites ERS and repeats the no-trend rule; nothing is compared over time. Re-verify before enabling.",++ "license_note": "US federal government work (Economic Research Service, U.S. Department of Agriculture), public domain in the US; ERS is cited as its documentation asks, and no finding is attributed to Circana.",++ "min_interval_ms": 60000,++ "poll_interval_sec": 604800,++ "max_items": 250,++ "enabled": false,++ "review_required": true,++ "sensitivity": "none",++ "default_indexability": "noindex"++ },{"key": "sec-press","name": "US Securities and Exchange Commission",@@@ -79,6 +79,15 @@* value). A line with a different number of values than the header is skipped; a file without a header is a parse* error.*++ * `"format": "csv"` reads a comma-separated file the same way, with RFC 4180 quoting (a quoted field may hold++ * commas, doubled quotes and line breaks); the header line names the columns. The USDA Economic Research Service++ * publishes its fruit and vegetable prices this way (ers.usda.gov). A row with a different number of values than the++ * header, or a body that ends inside a quoted field, is a parse error or a skipped row, never invented text.++ *++ * An endpoint may name its identity as several columns instead of one: `"identity_fields": ["Fruit", "Form"]`++ * joins their values in order with " | " (a row missing any of them is skipped). This is what a table needs when++ * no single column is unique — the USDA table has Apples in four forms. `fields.identity` cannot be given with it.++ ** 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.@@@ -322,15 +331,26 @@ function checkConditions(where, label, shape) {/** Validate an endpoint mapping (registry side). `bulk` is the streaming dataset download's own schema. */function checkMapping(ep, { bulk = false } = {}) {-- const formats = bulk ? ['csv', 'jsonl', 'tsv'] : ['json', 'xml', 'tsv'];-- if (!formats.includes(ep.format || (bulk ? 'csv' : 'json'))) return bulk ? 'format must be csv, jsonl or tsv' : 'format must be json, xml or tsv';++ const formats = bulk ? ['csv', 'jsonl', 'tsv'] : ['json', 'xml', 'tsv', 'csv'];++ if (!formats.includes(ep.format || (bulk ? 'csv' : 'json'))) return bulk ? 'format must be csv, jsonl or tsv' : 'format must be json, xml, tsv or csv';if (['tsv', 'csv', 'jsonl'].includes(ep.format) && ep.items_path) return `a ${ep.format} body is the array itself: items_path is ""`;if (!bulk && ep.duplicates !== undefined && !['first', 'last'].includes(ep.duplicates)) return 'duplicates must be first or last';const shape = ep.items_shape || 'array';if (!bulk && !['array', 'map'].includes(shape)) return 'items_shape must be array or map';if (!bulk && ep.items_path !== undefined && ep.items_path !== '' && !pathValid(ep.items_path)) return 'items_path is malformed';const fields = ep.fields || {};-- if (!fields.identity && !fields.url) return 'fields.identity or fields.url is required';++ const identityFields = ep.identity_fields;++ if (identityFields !== undefined) {++ if (!Array.isArray(identityFields) || identityFields.length < 2 || identityFields.length > 8) return 'identity_fields must be an array of 2 to 8 paths';++ if (shape !== 'array') return 'identity_fields requires items_shape "array"';++ if (fields.identity) return 'identity_fields and fields.identity cannot both be given';++ for (const p of identityFields) {++ if (typeof p !== 'string' || !pathValid(p)) return 'identity_fields paths must be paths';++ const kerr = keyPathViolation(p, shape);++ if (kerr) return `identity_fields: ${kerr}`;++ }++ }++ if (!fields.identity && !fields.url && !identityFields) return 'fields.identity, fields.url or identity_fields is required';for (const [k, p] of Object.entries(fields)) {if (!['identity', 'url', 'title', 'summary', 'author', 'published_at', 'published_at_epoch', 'updated_at', 'updated_at_epoch'].includes(k)) return `unknown field ${k}`;if (typeof p !== 'string' || !pathValid(p)) return `fields.${k} path is malformed`;@@@ -387,12 +407,56 @@ function parseTsv(text) {return { records, bad };}++/** A comma-separated body as records keyed by its header line's names (RFC 4180 quoting); malformed rows are counted. */++function parseCsv(text) {++ const src = String(text).replace(/^\uFEFF/, '');++ const rows = [];++ let row = [];++ let field = '';++ let quoted = false;++ for (let i = 0; i < src.length; i++) {++ const ch = src[i];++ if (quoted) {++ if (ch === '"') {++ if (src[i + 1] === '"') { field += '"'; i++; }++ else quoted = false;++ } else field += ch;++ continue;++ }++ if (ch === '"' && field === '') { quoted = true; continue; }++ if (ch === ',') { row.push(field); field = ''; continue; }++ if (ch === '\r') continue;++ if (ch === '\n') { row.push(field); rows.push(row); row = []; field = ''; continue; }++ field += ch;++ }++ if (quoted) throw new ParseError('csv body ends inside a quoted field');++ if (field !== '' || row.length) { row.push(field); rows.push(row); }++ const lines = rows.filter((r) => !(r.length === 1 && String(r[0]).trim() === ''));++ if (!lines.length) throw new ParseError('csv body has no header line');++ const names = lines[0].map((s) => String(s).trim());++ if (names.some((n) => !n) || new Set(names).size !== names.length) throw new ParseError('csv header names must be present and distinct');++ const records = [];++ let bad = 0;++ for (const values of lines.slice(1)) {++ if (values.length !== names.length) { bad++; continue; }++ const rec = {};++ names.forEach((n, i) => { rec[n] = String(values[i]).trim(); });++ records.push(rec);++ }++ return { records, bad };++}++/** One record → the item every api-shaped adapter stores, or null when it states no identity. */function mapRecord(body, rec, key, index, endpoint, url, summaryMax = 1000) {const f = endpoint.fields || {};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;++ let identity = null;++ if (Array.isArray(endpoint.identity_fields)) {++ const parts = endpoint.identity_fields.map((p) => scalar(get(p)));++ if (parts.every((v) => v != null && v !== '')) identity = toId(parts.join(' | '));++ } else if (f.identity) identity = toId(scalar(get(f.identity)));++ identity = identity || link;if (!identity) return null;const extra = {};for (const [k, p] of Object.entries(endpoint.extra || {})) extra[k] = scalar(get(p));@@@ -416,6 +480,7 @@ function parse(text, { url, endpoint = {}, summaryMax = 1000, maxItems = 500 } =let malformed = 0;if (format === 'xml') body = parseXml(text);else if (format === 'tsv') ({ records: body, bad: malformed } = parseTsv(text));++ else if (format === 'csv') ({ records: body, bad: malformed } = parseCsv(text));else {try { body = JSON.parse(text); } catch { throw new ParseError('body is not valid JSON'); }}@@@ -135,6 +135,7 @@ function validateSource(input, { maxItemsCap = 12000 } = {}) {if (e.filter) out.filter = e.filter;// and `extra_lists` (tags, job types) the same way.if (e.extra_lists) out.extra_lists = e.extra_lists;++ if (e.identity_fields !== undefined) out.identity_fields = e.identity_fields;if (e.duplicates !== undefined) out.duplicates = e.duplicates;if (e.max_bytes !== undefined) out.max_bytes = e.max_bytes;if (e.timeout_ms !== undefined) out.timeout_ms = e.timeout_ms;nnew file mode 100644@@@ -0,0 +1,74 @@++'use strict';++/**++ * API adapter: `format: "csv"` reads a comma-separated file by its header, and `identity_fields` makes the identity++ * from several columns when no single one is unique (USDA ERS prices each product in several forms).++ */++const assert = require('assert');++const api = require('../server/adapters/api');++const { suite } = require('./helpers');++++const t = suite('api-csv');++++// The USDA ERS all-fruits file as published: UTF-8 BOM, quoted names with commas, padding, and a column per product.++const USDA = [++ '\uFEFFFruit,Form,AverageRetailPrice,AverageRetailPriceUnitOfMeasure,AveragePricePerCupEquivalent',++ 'Apples,Fresh,1.86093095,per pound,0.501435057',++ '"Apples, applesauce",Canned,1.208099427,per pound,0.652533815',++ 'Bananas,Fresh,0.609594742,per pound,0.31498274',++ '"Grapes (raisins)",Dried,4.328343983,per pound,',++ 'Oranges,Fresh,1.439704016,per pound,0.863515806',++ 'a broken row with too few values',++ '',++ 'Strawberries,Fresh,3.078734363,per pound,1.046999868',++].join('\r\n');++++const usdaEndpoint = {++ format: 'csv', items_path: '', item_kind: 'price',++ identity_fields: ['Fruit', 'Form'],++ fields: { title: 'Fruit' },++ extra: { name: 'Fruit', form: 'Form', price: 'AverageRetailPrice', unit: 'AverageRetailPriceUnitOfMeasure' },++};++++t('a USDA table: one item per product and form, joined as "name | form"; quoted commas kept, broken rows skipped', () => {++ assert.strictEqual(api.checkMapping(usdaEndpoint), null);++ const r = api.parse(USDA, { url: 'https://www.ers.usda.gov/media/x.csv', endpoint: usdaEndpoint });++ assert.strictEqual(r.meta.format, 'csv');++ assert.deepStrictEqual(r.items.map((i) => i.identity), [++ 'Apples | Fresh', 'Apples, applesauce | Canned', 'Bananas | Fresh', 'Grapes (raisins) | Dried', 'Oranges | Fresh', 'Strawberries | Fresh',++ ]);++ assert.deepStrictEqual([r.items[0].title, r.items[0].fields.name, r.items[0].fields.form, r.items[0].fields.price, r.items[0].fields.unit], [++ 'Apples', 'Apples', 'Fresh', '1.86093095', 'per pound',++ ]);++ assert.strictEqual(r.items[1].fields.name, 'Apples, applesauce', 'the quoted comma is part of the value');++ assert.strictEqual(r.items[3].fields.price, '4.328343983', 'a trailing empty column is kept as the empty string');++ assert.strictEqual(r.skipped, 1, 'the broken row');++ assert.strictEqual(r.meta.filtered, 0);++});++++t('a row missing an identity column is skipped; the first duplicate identity stays', () => {++ const body = 'Fruit,Form,AverageRetailPrice\nApples,Fresh,1.86\nApples,Fresh,9.99\n,Fresh,1.00\nApples,,1.00\n';++ const r = api.parse(body, { url: 'https://x.example/x.csv', endpoint: usdaEndpoint });++ assert.deepStrictEqual(r.items.map((i) => [i.identity, i.fields.price]), [['Apples | Fresh', '1.86']]);++ assert.strictEqual(r.skipped, 3, 'the duplicate and the two rows without a full identity');++ assert.strictEqual(api.checkMapping({ ...usdaEndpoint, duplicates: 'last' }), null);++});++++t('mapping rules: csv is an api format, an identity must be named, and identity_fields has bounds', () => {++ assert.strictEqual(api.checkMapping({ ...usdaEndpoint, format: 'tsv' }), null, 'tsv is unchanged');++ assert.match(api.checkMapping({ format: 'yaml', fields: { identity: 'x' } }), /format must be json, xml, tsv or csv/);++ assert.match(api.checkMapping({ ...usdaEndpoint, items_path: 'rows' }), /items_path is ""/);++ assert.match(api.checkMapping({ format: 'csv', identity_fields: ['A'] }), /2 to 8 paths/);++ assert.match(api.checkMapping({ format: 'csv', identity_fields: ['A', 'B'], fields: { identity: 'A' } }), /cannot both be given/);++ assert.match(api.checkMapping({ format: 'csv', fields: {} }), /fields\.identity, fields\.url or identity_fields is required/);++ assert.match(api.checkMapping({ ...usdaEndpoint, items_shape: 'map' }), /identity_fields requires items_shape "array"/);++ assert.match(api.checkMapping({ ...usdaEndpoint, identity_fields: ['Fruit', '$key'] }), /identity_fields: \$key requires items_shape "map"/);++ assert.throws(() => api.parse('a,b\n"unterminated,1', { url: 'https://x.example/x.csv', endpoint: usdaEndpoint }), /ends inside a quoted field/);++});++++t('a quoted field may hold a line break and doubled quotes', () => {++ const body = 'Name,Form,Price\n"Apples, ""gala""",Fresh,"1.50"\n"Pears\n(anjou)",Fresh,1.20\n';++ const r = api.parse(body, { url: 'https://x.example/x.csv', endpoint: { format: 'csv', identity_fields: ['Name', 'Form'], fields: { title: 'Name' }, extra: { price: 'Price' } } });++ assert.deepStrictEqual(r.items.map((i) => [i.identity, i.fields.price]), [['Apples, "gala" | Fresh', '1.50'], ['Pears\n(anjou) | Fresh', '1.20']]);++});++++t.run();@@@ -74,7 +74,7 @@ t('filter: the BLS CPI rent seed keeps one series\' rows, one item per month, by});t('mapping and body errors', () => {-- assert.match(api.checkMapping({ ...blsEndpoint, format: 'csv' }), /format must be json, xml or tsv/);++ assert.match(api.checkMapping({ ...blsEndpoint, format: 'yaml' }), /format must be json, xml, tsv or csv/);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/);
@@ -105,7 +105,7 @@ succeeded. Health status: `healthy | stale | failing | never_fetched | disabled
@@ -105,7 +105,7 @@ succeeded. Health status: `healthy | stale | failing | never_fetched | disabled
| `rss`, `atom` (`feed@3`) | RSS 2.0, RSS 1.0/RDF, Atom 1.0 | guid/id identity, else link; text-only summary capped at 1000 chars; a deal feed's own offer elements (DealNews' `dealnews:price` with its currency, `dealnews:retailer`, `dealnews:expires`) become `fields.price`, `currency`, `seller` and `valid_until` exactly as stated (the item stays an article; nothing is read out of the headline); its `dealnews:category` fills `fields.categories` when the item has no `<category>`, and `dealnews:staffPick` is `fields.staff_pick` (true/false, absent when not stated) |
| `rss`, `atom` (`feed@3`) | RSS 2.0, RSS 1.0/RDF, Atom 1.0 | guid/id identity, else link; text-only summary capped at 1000 chars; a deal feed's own offer elements (DealNews' `dealnews:price` with its currency, `dealnews:retailer`, `dealnews:expires`) become `fields.price`, `currency`, `seller` and `valid_until` exactly as stated (the item stays an article; nothing is read out of the headline); its `dealnews:category` fills `fields.categories` when the item has no `<category>`, and `dealnews:staffPick` is `fields.staff_pick` (true/false, absent when not stated) |
| `sitemap` (`sitemap@1`) | `<urlset>` (+ Google News extension), `<sitemapindex>` | index children are listed, not followed |
| `sitemap` (`sitemap@1`) | `<urlset>` (+ Google News extension), `<sitemapindex>` | index children are listed, not followed |
| `jsonld` (`jsonld@2`) | schema.org Product, Offer/AggregateOffer, Review, NewsArticle, BlogPosting (+ Article) | prices, ratings and availability exactly as the page states; missing = null; embedded review text not kept; a page without JSON-LD is a `parse_error`; `list_price` from a ListPrice/StrikethroughPrice/MSRP/SRP `priceSpecification`, as stated |
| `jsonld` (`jsonld@2`) | schema.org Product, Offer/AggregateOffer, Review, NewsArticle, BlogPosting (+ Article) | prices, ratings and availability exactly as the page states; missing = null; embedded review text not kept; a page without JSON-LD is a `parse_error`; `list_price` from a ListPrice/StrikethroughPrice/MSRP/SRP `priceSpecification`, as stated |
-| `api` (`api@1`) | JSON or XML official APIs via a registry mapping | `items_path`, `items_shape` (`array` default, `map` for records keyed by id), `fields`, `extra`, `where`/`filter`; epoch fields only where mapped as such; `resolver: "hn-whoishiring"` walks the Hacker News thread's JSON documents instead |
+| `api` (`api@1`) | JSON, XML, TSV or CSV official files via a registry mapping | `items_path`, `items_shape` (`array` default, `map` for records keyed by id), `fields`, `extra`, `where`/`filter`; epoch fields only where mapped as such; `resolver: "hn-whoishiring"` walks the Hacker News thread's JSON documents instead |
| `bulk` (`bulk@1`) | one dataset file, streamed (CSV, JSONL or TSV) | `max_bytes` is the size cap, `max_rows` the per-run row limit; the stream stops at the limit, so a multi-GB file is never held in memory. `resolver: "off-delta"` reads a text index and only the gzipped JSONL delta parts it lists, keeping the last finished part and the current part's row offset in `source_state` (Open Food Facts) |
| `bulk` (`bulk@1`) | one dataset file, streamed (CSV, JSONL or TSV) | `max_bytes` is the size cap, `max_rows` the per-run row limit; the stream stops at the limit, so a multi-GB file is never held in memory. `resolver: "off-delta"` reads a text index and only the gzipped JSONL delta parts it lists, keeping the last finished part and the current part's row offset in `source_state` (Open Food Facts) |
| `crawl` (`jsonld@2`, `page@1`) | a whole site from its seeds: its sitemaps and, if asked, its links (see below) | only pages that publish JSON-LD are kept, unless `require_structured: false` keeps a `page` item from the page's own search metadata |
| `crawl` (`jsonld@2`, `page@1`) | a whole site from its seeds: its sitemaps and, if asked, its links (see below) | only pages that publish JSON-LD are kept, unless `require_structured: false` keeps a `page` item from the page's own search metadata |
| `manual` (`manual@1`) | items entered through the API with an evidence URL | no fetcher |
| `manual` (`manual@1`) | items entered through the API with an evidence URL | no fetcher |
@@ -122,6 +122,13 @@ an empty success. With the default `items_shape: "array"`, `$index` is a record'
@@ -122,6 +122,13 @@ an empty success. With the default `items_shape: "array"`, `$index` is a record'
the array (as a whole path, e.g. `"extra": { "position": "$index" }`), so a ranked list keeps its
the array (as a whole path, e.g. `"extra": { "position": "$index" }`), so a ranked list keeps its
order (Steam's top sellers).
order (Steam's top sellers).
+A CSV or TSV body likewise names its columns on the first line: each further line is a record of
+trimmed text, and `items_path` must be "". CSV follows RFC 4180 quoting (a quoted field may
+hold commas, doubled quotes and line breaks); a row whose value count differs from the header is
+skipped, and a body that ends inside a quoted field is a parse error. When no single column is unique,
+`identity_fields` names two to eight paths whose values, joined with " | ", are the record's identity
+(the USDA ERS table prices Apples in four forms); `fields.identity` cannot be given with it.
+
```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",
"items_path": "pricing", "items_shape": "map", "item_kind": "tld",
"items_path": "pricing", "items_shape": "map", "item_kind": "tld",
@@ -591,7 +598,7 @@ in [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites); th
@@ -591,7 +598,7 @@ in [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites); th
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.289.1
+- openvibe-contracts: v0.290.0
- openvibe-sdk: v0.82.0
- openvibe-sdk: v0.82.0
- openvibe-shared: v4.109.2
- openvibe-shared: v4.109.2
<!-- versions:end -->
<!-- versions:end -->
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, fast-xml-parser",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, fast-xml-parser",
"port": 4720,
"port": 4720,
"domain": "sources.openvibe.network (vhost in deploy/nginx: an internal-service page, the merchant opt-in pages, health and JSON 404s; the JSON API is loopback-only; README \"Public host\")",
"domain": "sources.openvibe.network (vhost in deploy/nginx: an internal-service page, the merchant opt-in pages, health and JSON 404s; the JSON API is loopback-only; README \"Public host\")",
- "contracts": "openvibe-contracts v0.289.1",
+ "contracts": "openvibe-contracts v0.290.0",
"sdk": "openvibe-sdk v0.82.0",
"sdk": "openvibe-sdk v0.82.0",
"shared": "openvibe-shared v4.109.2",
"shared": "openvibe-shared v4.109.2",
"capabilities": [
"capabilities": [
@@ -51,5 +51,5 @@
@@ -51,5 +51,5 @@
"staff-only index documents for Search",
"staff-only index documents for Search",
"per-actor limits (openvibe-sdk/limits)"
"per-actor limits (openvibe-sdk/limits)"
],
],
- "updated": "2026-11-05"
+ "updated": "2026-11-06"
}
}
@@ -89,7 +89,16 @@
@@ -89,7 +89,16 @@
"csv",
"csv",
"jsonl"
"jsonl"
],
],
- "description": "json (default), xml, tsv (a tab-separated file whose first line names the columns; BLS series files, Sources 2026-10-24), or, for a bulk source, csv, jsonl or tsv: one dataset file read as a stream, row by row (Sources, 2026-10-31)."
+ "description": "json (default), xml, tsv (a tab-separated file whose first line names the columns; BLS series files, Sources 2026-10-24), csv (a comma-separated file with RFC 4180 quoting; USDA ERS fruit and vegetable prices, Sources 2026-11-05), or, for a bulk source, csv, jsonl or tsv: one dataset file read as a stream, row by row (Sources, 2026-10-31)."
+ },
+ "identity_fields": {
+ "type": "array",
+ "minItems": 2,
+ "maxItems": 8,
+ "items": {
+ "type": "string"
+ },
+ "description": "A record's identity from several columns, joined in order with \" | \", for a table where no single column is unique (USDA ERS prices a product in several forms; Sources 2026-11-05). Requires items_shape array and cannot be given with fields.identity."
},
},
"duplicates": {
"duplicates": {
"enum": [
"enum": [
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"fast-xml-parser": "^4.5.3",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.290.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1044,9 +1044,9 @@
@@ -1044,9 +1044,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.289.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
- "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==",
+ "version": "0.290.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.290.0",
+ "integrity": "sha512-WX9p49Wyr5dPRQ9iQEJvVEPIQwkJYsJTLryU5DJJI/GrxzfMZFN1nfuM5W/KmL3DGoRTAOg/KAWjs0OkunJyeg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -30,7 +30,7 @@
@@ -30,7 +30,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"fast-xml-parser": "^4.5.3",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.290.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1972,6 +1972,53 @@
@@ -1972,6 +1972,53 @@
"sensitivity": "none",
"sensitivity": "none",
"default_indexability": "noindex"
"default_indexability": "noindex"
},
},
+ {
+ "key": "usda-ers-fruit-vegetable-prices",
+ "name": "USDA ERS average retail prices for fruits and vegetables",
+ "type": "api",
+ "category": "food",
+ "homepage_url": "https://www.ers.usda.gov/data-products/fruit-and-vegetable-prices/",
+ "endpoints": [
+ {
+ "url": "https://www.ers.usda.gov/media/6210/all-fruits-average-prices-csv-format.csv",
+ "format": "csv",
+ "items_path": "",
+ "item_kind": "price",
+ "identity_fields": ["Fruit", "Form"],
+ "fields": { "title": "Fruit" },
+ "extra": {
+ "name": "Fruit",
+ "form": "Form",
+ "price": "AverageRetailPrice",
+ "unit": "AverageRetailPriceUnitOfMeasure"
+ }
+ },
+ {
+ "url": "https://www.ers.usda.gov/media/6240/all-vegetables-average-prices-csv-format.csv",
+ "format": "csv",
+ "items_path": "",
+ "item_kind": "price",
+ "identity_fields": ["Vegetable", "Form"],
+ "fields": { "title": "Vegetable" },
+ "extra": {
+ "name": "Vegetable",
+ "form": "Form",
+ "price": "AverageRetailPrice",
+ "unit": "AverageRetailPriceUnitOfMeasure"
+ }
+ }
+ ],
+ "robots_note": "robots.txt checked 2026-11-05 from the production host: User-agent * Allow /, with only /core/, /profiles/, /README.md and /web.config disallowed; the two /media/ CSV paths are allowed. The fetcher re-checks it on every run. Two CSV files, about 5 KB and 7 KB, one request each a run.",
+ "terms_note": "Verified 2026-11-05 from the production host: USDA's Economic Research Service is a US federal agency, and the page says its fruit and vegetable prices are ERS's own estimates from Circana scanner data ('ERS calculated average prices at retail stores using 2013, 2016, 2020, 2022, and 2023 retail scanner data from Circana'). The documentation asks for the citation 'U.S. Department of Agriculture, Economic Research Service. (2025). Fruit and Vegetable Prices.' and the page says 'The findings in this data product or stated on this page should not be attributed to Circana.' The prices are annual national averages, updated 'each year, subject to data availability' (files last updated 2025-12-09), and the page says twice that the data 'should not be used for making inferences about price changes over time'. Kept: every row of the two published all-fruits and all-vegetables tables (product, form, average retail price per pound or pint, unit), one item per product and form. Food shows it as an annual national average with its 2023 data year, cites ERS and repeats the no-trend rule; nothing is compared over time. Re-verify before enabling.",
+ "license_note": "US federal government work (Economic Research Service, U.S. Department of Agriculture), public domain in the US; ERS is cited as its documentation asks, and no finding is attributed to Circana.",
+ "min_interval_ms": 60000,
+ "poll_interval_sec": 604800,
+ "max_items": 250,
+ "enabled": false,
+ "review_required": true,
+ "sensitivity": "none",
+ "default_indexability": "noindex"
+ },
{
{
"key": "sec-press",
"key": "sec-press",
"name": "US Securities and Exchange Commission",
"name": "US Securities and Exchange Commission",
@@ -79,6 +79,15 @@
@@ -79,6 +79,15 @@
* value). A line with a different number of values than the header is skipped; a file without a header is a parse
* value). A line with a different number of values than the header is skipped; a file without a header is a parse
* error.
* error.
*
*
+ * `"format": "csv"` reads a comma-separated file the same way, with RFC 4180 quoting (a quoted field may hold
+ * commas, doubled quotes and line breaks); the header line names the columns. The USDA Economic Research Service
+ * publishes its fruit and vegetable prices this way (ers.usda.gov). A row with a different number of values than the
+ * header, or a body that ends inside a quoted field, is a parse error or a skipped row, never invented text.
+ *
+ * An endpoint may name its identity as several columns instead of one: `"identity_fields": ["Fruit", "Form"]`
+ * joins their values in order with " | " (a row missing any of them is skipped). This is what a table needs when
+ * no single column is unique — the USDA table has Apples in four forms. `fields.identity` cannot be given with it.
+ *
* Two records with the same identity keep the first (`"duplicates": "first"`, the default). `"duplicates": "last"`
* 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
* keeps the last instead, in the first one's place: a file that lists each series oldest to newest then yields its
* newest value.
* newest value.
@@ -322,15 +331,26 @@ function checkConditions(where, label, shape) {
@@ -322,15 +331,26 @@ function checkConditions(where, label, shape) {
/** Validate an endpoint mapping (registry side). `bulk` is the streaming dataset download's own schema. */
/** Validate an endpoint mapping (registry side). `bulk` is the streaming dataset download's own schema. */
function checkMapping(ep, { bulk = false } = {}) {
function checkMapping(ep, { bulk = false } = {}) {
- const formats = bulk ? ['csv', 'jsonl', 'tsv'] : ['json', 'xml', 'tsv'];
- if (!formats.includes(ep.format || (bulk ? 'csv' : 'json'))) return bulk ? 'format must be csv, jsonl or tsv' : 'format must be json, xml or tsv';
+ const formats = bulk ? ['csv', 'jsonl', 'tsv'] : ['json', 'xml', 'tsv', 'csv'];
+ if (!formats.includes(ep.format || (bulk ? 'csv' : 'json'))) return bulk ? 'format must be csv, jsonl or tsv' : 'format must be json, xml, tsv or csv';
if (['tsv', 'csv', 'jsonl'].includes(ep.format) && ep.items_path) return `a ${ep.format} body is the array itself: items_path is ""`;
if (['tsv', 'csv', 'jsonl'].includes(ep.format) && ep.items_path) return `a ${ep.format} body is the array itself: items_path is ""`;
if (!bulk && ep.duplicates !== undefined && !['first', 'last'].includes(ep.duplicates)) return 'duplicates must be first or last';
if (!bulk && ep.duplicates !== undefined && !['first', 'last'].includes(ep.duplicates)) return 'duplicates must be first or last';
const shape = ep.items_shape || 'array';
const shape = ep.items_shape || 'array';
if (!bulk && !['array', 'map'].includes(shape)) return 'items_shape must be array or map';
if (!bulk && !['array', 'map'].includes(shape)) return 'items_shape must be array or map';
if (!bulk && ep.items_path !== undefined && ep.items_path !== '' && !pathValid(ep.items_path)) return 'items_path is malformed';
if (!bulk && ep.items_path !== undefined && ep.items_path !== '' && !pathValid(ep.items_path)) return 'items_path is malformed';
const fields = ep.fields || {};
const fields = ep.fields || {};
- if (!fields.identity && !fields.url) return 'fields.identity or fields.url is required';
+ const identityFields = ep.identity_fields;
+ if (identityFields !== undefined) {
+ if (!Array.isArray(identityFields) || identityFields.length < 2 || identityFields.length > 8) return 'identity_fields must be an array of 2 to 8 paths';
+ if (shape !== 'array') return 'identity_fields requires items_shape "array"';
+ if (fields.identity) return 'identity_fields and fields.identity cannot both be given';
+ for (const p of identityFields) {
+ if (typeof p !== 'string' || !pathValid(p)) return 'identity_fields paths must be paths';
+ const kerr = keyPathViolation(p, shape);
+ if (kerr) return `identity_fields: ${kerr}`;
+ }
+ }
+ if (!fields.identity && !fields.url && !identityFields) return 'fields.identity, fields.url or identity_fields is required';
for (const [k, p] of Object.entries(fields)) {
for (const [k, p] of Object.entries(fields)) {
if (!['identity', 'url', 'title', 'summary', 'author', 'published_at', 'published_at_epoch', 'updated_at', 'updated_at_epoch'].includes(k)) return `unknown field ${k}`;
if (!['identity', 'url', 'title', 'summary', 'author', 'published_at', 'published_at_epoch', 'updated_at', 'updated_at_epoch'].includes(k)) return `unknown field ${k}`;
if (typeof p !== 'string' || !pathValid(p)) return `fields.${k} path is malformed`;
if (typeof p !== 'string' || !pathValid(p)) return `fields.${k} path is malformed`;
@@ -387,12 +407,56 @@ function parseTsv(text) {
@@ -387,12 +407,56 @@ function parseTsv(text) {
return { records, bad };
return { records, bad };
}
}
+/** A comma-separated body as records keyed by its header line's names (RFC 4180 quoting); malformed rows are counted. */
+function parseCsv(text) {
+ const src = String(text).replace(/^\uFEFF/, '');
+ const rows = [];
+ let row = [];
+ let field = '';
+ let quoted = false;
+ for (let i = 0; i < src.length; i++) {
+ const ch = src[i];
+ if (quoted) {
+ if (ch === '"') {
+ if (src[i + 1] === '"') { field += '"'; i++; }
+ else quoted = false;
+ } else field += ch;
+ continue;
+ }
+ if (ch === '"' && field === '') { quoted = true; continue; }
+ if (ch === ',') { row.push(field); field = ''; continue; }
+ if (ch === '\r') continue;
+ if (ch === '\n') { row.push(field); rows.push(row); row = []; field = ''; continue; }
+ field += ch;
+ }
+ if (quoted) throw new ParseError('csv body ends inside a quoted field');
+ if (field !== '' || row.length) { row.push(field); rows.push(row); }
+ const lines = rows.filter((r) => !(r.length === 1 && String(r[0]).trim() === ''));
+ if (!lines.length) throw new ParseError('csv body has no header line');
+ const names = lines[0].map((s) => String(s).trim());
+ if (names.some((n) => !n) || new Set(names).size !== names.length) throw new ParseError('csv header names must be present and distinct');
+ const records = [];
+ let bad = 0;
+ for (const values of lines.slice(1)) {
+ if (values.length !== names.length) { bad++; continue; }
+ const rec = {};
+ names.forEach((n, i) => { rec[n] = String(values[i]).trim(); });
+ records.push(rec);
+ }
+ return { records, bad };
+}
+
/** One record → the item every api-shaped adapter stores, or null when it states no identity. */
/** One record → the item every api-shaped adapter stores, or null when it states no identity. */
function mapRecord(body, rec, key, index, endpoint, url, summaryMax = 1000) {
function mapRecord(body, rec, key, index, endpoint, url, summaryMax = 1000) {
const f = endpoint.fields || {};
const f = endpoint.fields || {};
const get = (p) => (p === '$index' ? index : 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;
+ let identity = null;
+ if (Array.isArray(endpoint.identity_fields)) {
+ const parts = endpoint.identity_fields.map((p) => scalar(get(p)));
+ if (parts.every((v) => v != null && v !== '')) identity = toId(parts.join(' | '));
+ } else if (f.identity) identity = toId(scalar(get(f.identity)));
+ identity = identity || link;
if (!identity) return null;
if (!identity) return null;
const extra = {};
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 || {})) extra[k] = scalar(get(p));
@@ -416,6 +480,7 @@ function parse(text, { url, endpoint = {}, summaryMax = 1000, maxItems = 500 } =
@@ -416,6 +480,7 @@ function parse(text, { url, endpoint = {}, summaryMax = 1000, maxItems = 500 } =
let malformed = 0;
let malformed = 0;
if (format === 'xml') body = parseXml(text);
if (format === 'xml') body = parseXml(text);
else if (format === 'tsv') ({ records: body, bad: malformed } = parseTsv(text));
else if (format === 'tsv') ({ records: body, bad: malformed } = parseTsv(text));
+ else if (format === 'csv') ({ records: body, bad: malformed } = parseCsv(text));
else {
else {
try { body = JSON.parse(text); } catch { throw new ParseError('body is not valid JSON'); }
try { body = JSON.parse(text); } catch { throw new ParseError('body is not valid JSON'); }
}
}
@@ -135,6 +135,7 @@ function validateSource(input, { maxItemsCap = 12000 } = {}) {
@@ -135,6 +135,7 @@ function validateSource(input, { maxItemsCap = 12000 } = {}) {
if (e.filter) out.filter = e.filter;
if (e.filter) out.filter = e.filter;
// and `extra_lists` (tags, job types) the same way.
// and `extra_lists` (tags, job types) the same way.
if (e.extra_lists) out.extra_lists = e.extra_lists;
if (e.extra_lists) out.extra_lists = e.extra_lists;
+ if (e.identity_fields !== undefined) out.identity_fields = e.identity_fields;
if (e.duplicates !== undefined) out.duplicates = e.duplicates;
if (e.duplicates !== undefined) out.duplicates = e.duplicates;
if (e.max_bytes !== undefined) out.max_bytes = e.max_bytes;
if (e.max_bytes !== undefined) out.max_bytes = e.max_bytes;
if (e.timeout_ms !== undefined) out.timeout_ms = e.timeout_ms;
if (e.timeout_ms !== undefined) out.timeout_ms = e.timeout_ms;
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
+'use strict';
+/**
+ * API adapter: `format: "csv"` reads a comma-separated file by its header, and `identity_fields` makes the identity
+ * from several columns when no single one is unique (USDA ERS prices each product in several forms).
+ */
+const assert = require('assert');
+const api = require('../server/adapters/api');
+const { suite } = require('./helpers');
+
+const t = suite('api-csv');
+
+// The USDA ERS all-fruits file as published: UTF-8 BOM, quoted names with commas, padding, and a column per product.
+const USDA = [
+ '\uFEFFFruit,Form,AverageRetailPrice,AverageRetailPriceUnitOfMeasure,AveragePricePerCupEquivalent',
+ 'Apples,Fresh,1.86093095,per pound,0.501435057',
+ '"Apples, applesauce",Canned,1.208099427,per pound,0.652533815',
+ 'Bananas,Fresh,0.609594742,per pound,0.31498274',
+ '"Grapes (raisins)",Dried,4.328343983,per pound,',
+ 'Oranges,Fresh,1.439704016,per pound,0.863515806',
+ 'a broken row with too few values',
+ '',
+ 'Strawberries,Fresh,3.078734363,per pound,1.046999868',
+].join('\r\n');
+
+const usdaEndpoint = {
+ format: 'csv', items_path: '', item_kind: 'price',
+ identity_fields: ['Fruit', 'Form'],
+ fields: { title: 'Fruit' },
+ extra: { name: 'Fruit', form: 'Form', price: 'AverageRetailPrice', unit: 'AverageRetailPriceUnitOfMeasure' },
+};
+
+t('a USDA table: one item per product and form, joined as "name | form"; quoted commas kept, broken rows skipped', () => {
+ assert.strictEqual(api.checkMapping(usdaEndpoint), null);
+ const r = api.parse(USDA, { url: 'https://www.ers.usda.gov/media/x.csv', endpoint: usdaEndpoint });
+ assert.strictEqual(r.meta.format, 'csv');
+ assert.deepStrictEqual(r.items.map((i) => i.identity), [
+ 'Apples | Fresh', 'Apples, applesauce | Canned', 'Bananas | Fresh', 'Grapes (raisins) | Dried', 'Oranges | Fresh', 'Strawberries | Fresh',
+ ]);
+ assert.deepStrictEqual([r.items[0].title, r.items[0].fields.name, r.items[0].fields.form, r.items[0].fields.price, r.items[0].fields.unit], [
+ 'Apples', 'Apples', 'Fresh', '1.86093095', 'per pound',
+ ]);
+ assert.strictEqual(r.items[1].fields.name, 'Apples, applesauce', 'the quoted comma is part of the value');
+ assert.strictEqual(r.items[3].fields.price, '4.328343983', 'a trailing empty column is kept as the empty string');
+ assert.strictEqual(r.skipped, 1, 'the broken row');
+ assert.strictEqual(r.meta.filtered, 0);
+});
+
+t('a row missing an identity column is skipped; the first duplicate identity stays', () => {
+ const body = 'Fruit,Form,AverageRetailPrice\nApples,Fresh,1.86\nApples,Fresh,9.99\n,Fresh,1.00\nApples,,1.00\n';
+ const r = api.parse(body, { url: 'https://x.example/x.csv', endpoint: usdaEndpoint });
+ assert.deepStrictEqual(r.items.map((i) => [i.identity, i.fields.price]), [['Apples | Fresh', '1.86']]);
+ assert.strictEqual(r.skipped, 3, 'the duplicate and the two rows without a full identity');
+ assert.strictEqual(api.checkMapping({ ...usdaEndpoint, duplicates: 'last' }), null);
+});
+
+t('mapping rules: csv is an api format, an identity must be named, and identity_fields has bounds', () => {
+ assert.strictEqual(api.checkMapping({ ...usdaEndpoint, format: 'tsv' }), null, 'tsv is unchanged');
+ assert.match(api.checkMapping({ format: 'yaml', fields: { identity: 'x' } }), /format must be json, xml, tsv or csv/);
+ assert.match(api.checkMapping({ ...usdaEndpoint, items_path: 'rows' }), /items_path is ""/);
+ assert.match(api.checkMapping({ format: 'csv', identity_fields: ['A'] }), /2 to 8 paths/);
+ assert.match(api.checkMapping({ format: 'csv', identity_fields: ['A', 'B'], fields: { identity: 'A' } }), /cannot both be given/);
+ assert.match(api.checkMapping({ format: 'csv', fields: {} }), /fields\.identity, fields\.url or identity_fields is required/);
+ assert.match(api.checkMapping({ ...usdaEndpoint, items_shape: 'map' }), /identity_fields requires items_shape "array"/);
+ assert.match(api.checkMapping({ ...usdaEndpoint, identity_fields: ['Fruit', '$key'] }), /identity_fields: \$key requires items_shape "map"/);
+ assert.throws(() => api.parse('a,b\n"unterminated,1', { url: 'https://x.example/x.csv', endpoint: usdaEndpoint }), /ends inside a quoted field/);
+});
+
+t('a quoted field may hold a line break and doubled quotes', () => {
+ const body = 'Name,Form,Price\n"Apples, ""gala""",Fresh,"1.50"\n"Pears\n(anjou)",Fresh,1.20\n';
+ const r = api.parse(body, { url: 'https://x.example/x.csv', endpoint: { format: 'csv', identity_fields: ['Name', 'Form'], fields: { title: 'Name' }, extra: { price: 'Price' } } });
+ assert.deepStrictEqual(r.items.map((i) => [i.identity, i.fields.price]), [['Apples, "gala" | Fresh', '1.50'], ['Pears\n(anjou) | Fresh', '1.20']]);
+});
+
+t.run();
@@ -74,7 +74,7 @@ t('filter: the BLS CPI rent seed keeps one series\' rows, one item per month, by
@@ -74,7 +74,7 @@ t('filter: the BLS CPI rent seed keeps one series\' rows, one item per month, by
});
});
t('mapping and body errors', () => {
t('mapping and body errors', () => {
- assert.match(api.checkMapping({ ...blsEndpoint, format: 'csv' }), /format must be json, xml or tsv/);
+ assert.match(api.checkMapping({ ...blsEndpoint, format: 'yaml' }), /format must be json, xml, tsv or csv/);
assert.match(api.checkMapping({ ...blsEndpoint, items_path: 'rows' }), /items_path is ""/);
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.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('', { url: 'https://x.example/', endpoint: blsEndpoint }), /no header line/);