openvibers/openvibe.sources Commits
Sources: category crowdfunding, request_content_type for api GETs, opencollective-accounts seed (contracts 0.191.0) (#65)
Changes
@@@ -53,7 +53,7 @@ start and flushes on stop.## The registryA source records: `key`, `name`, `type` (`rss|atom|sitemap|jsonld|api|manual`), `category`--(`news|blog|reviews|deals|coupons|trade|hosting`), `endpoints` (for `api`: a field mapping, no code per++(`news|blog|reviews|deals|coupons|trade|hosting|crowdfunding`), `endpoints` (for `api`: a field mapping, no code perprovider), `auth` (`none|header|bearer|query` with the **name** of an environment variable —names must start with `SOURCES_CRED_`, so an entry can never point at this service's ownsecrets), `robots_note`, `terms_note`, `license_note`, the rate limit (`min_interval_ms`),@@@ -150,6 +150,11 @@ A value is literal text or `$rec.<path>`, read from the record itself. No match`max_bytes` (an integer from 1 KiB to 32 MiB) sets one endpoint's body cap instead of `SOURCES_MAX_BYTES`. OVHcloud'scatalog is about 12 MB.++`request_content_type` (`application/json` only) is sent as the Content-Type of the endpoint's GET, which has no body.++Open Collective's public GraphQL API answers a GET only with it (its CSRF guard): `opencollective-accounts` (category++`crowdfunding`) reads the 200 most active and the 100 newest collectives, projects and funds every six hours, each a++`campaign` item with the amounts and counts as Open Collective states them, for OpenVibe.Deals' crowdfunding discovery.++**Facts stated in prose (`extract`, rss/atom only).** A feed endpoint may carry`extract: { kind, require, fields }` ([server/adapters/extract.js](server/adapters/extract.js)). Each field is read fromthe item's own `title` or `summary` by a pattern with one capturing group. With `as: "date"`, "October 20" becomes@@@ -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"] },++ "category": { "enum": ["news", "blog", "reviews", "deals", "coupons", "trade", "hosting", "crowdfunding"] },"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,7 @@"deals","coupons","trade",-- "hosting"++ "hosting", "crowdfunding"]},"homepage_url": {@@@ -136,6 +136,10 @@"type": "string","pattern": "^[a-z][a-z0-9_]{1,39}$"},++ "request_content_type": {++ "enum": ["application/json"],++ "description": "Sent as the request's Content-Type on this endpoint's GET (no body). Open Collective's GraphQL API answers a GET only with it, as its CSRF guard (Sources#65)."++ },"max_bytes": {"type": "integer","minimum": 1024,@@@ -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.188.2",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.191.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1","pg": "^8.23.0"@@@ -769,9 +769,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.188.2",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2",-- "integrity": "sha512-E64gQI7roKudmmJFnenyh1BjViJyI0j0MoVT4o35/jUD4rpzMkElwEh/fGbsPpnstdDFIcJR6gEcScbL0ZQhbg==",++ "version": "0.191.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.191.0",++ "integrity": "sha512-RA5K/K04KQce36VwTuBVVYJPeVk7FAuyHr5LU50WxyBesf/dfLiE0knnMQkDHWPLKFQ/HluWx0LUbqB+NJUo7A==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -27,7 +27,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.188.2",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.191.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1","pg": "^8.23.0"@@@ -1237,6 +1237,76 @@"review_required": true,"sensitivity": "health","default_indexability": "noindex"++ },++ {++ "key": "opencollective-accounts",++ "name": "Open Collective: collectives, projects and funds",++ "type": "api",++ "category": "crowdfunding",++ "homepage_url": "https://opencollective.com/discover",++ "endpoints": [++ {++ "url": "https://api.opencollective.com/graphql/v2?query=%7B+accounts%28type%3A+%5BCOLLECTIVE%2C+PROJECT%2C+FUND%5D%2C+limit%3A+200%2C+orderBy%3A+%7Bfield%3A+ACTIVITY%2C+direction%3A+DESC%7D%29+%7B+nodes+%7B+slug+name+type+description+currency+createdAt+imageUrl+website+tags+stats+%7B+totalAmountReceived+%7B+valueInCents+currency+%7D+yearlyBudget+%7B+valueInCents+currency+%7D+contributorsCount+%7D+%7D+%7D+%7D",++ "format": "json",++ "request_content_type": "application/json",++ "items_path": "data.accounts.nodes",++ "item_kind": "campaign",++ "fields": {++ "identity": "slug",++ "title": "name",++ "summary": "description",++ "published_at": "createdAt"++ },++ "extra": {++ "slug": "slug",++ "account_type": "type",++ "currency": "currency",++ "raised_cents": "stats.totalAmountReceived.valueInCents",++ "raised_currency": "stats.totalAmountReceived.currency",++ "yearly_budget_cents": "stats.yearlyBudget.valueInCents",++ "backers": "stats.contributorsCount",++ "image_url": "imageUrl",++ "website": "website",++ "tag": "tags.0"++ }++ },++ {++ "url": "https://api.opencollective.com/graphql/v2?query=%7B+accounts%28type%3A+%5BCOLLECTIVE%2C+PROJECT%2C+FUND%5D%2C+limit%3A+100%2C+orderBy%3A+%7Bfield%3A+CREATED_AT%2C+direction%3A+DESC%7D%29+%7B+nodes+%7B+slug+name+type+description+currency+createdAt+imageUrl+website+tags+stats+%7B+totalAmountReceived+%7B+valueInCents+currency+%7D+yearlyBudget+%7B+valueInCents+currency+%7D+contributorsCount+%7D+%7D+%7D+%7D",++ "format": "json",++ "request_content_type": "application/json",++ "items_path": "data.accounts.nodes",++ "item_kind": "campaign",++ "fields": {++ "identity": "slug",++ "title": "name",++ "summary": "description",++ "published_at": "createdAt"++ },++ "extra": {++ "slug": "slug",++ "account_type": "type",++ "currency": "currency",++ "raised_cents": "stats.totalAmountReceived.valueInCents",++ "raised_currency": "stats.totalAmountReceived.currency",++ "yearly_budget_cents": "stats.yearlyBudget.valueInCents",++ "backers": "stats.contributorsCount",++ "image_url": "imageUrl",++ "website": "website",++ "tag": "tags.0"++ }++ }++ ],++ "robots_note": "robots.txt checked 2026-10-15: https://api.opencollective.com/robots.txt answers 404, so the API host publishes no robots rules (a 404 is treated as no restrictions, never guessed as a disallow). The fetcher re-checks robots.txt on every run.",++ "terms_note": "Open Collective's public GraphQL API v2 (documented at https://graphql-docs-v2.opencollective.com) answers anonymous reads of public accounts; a GET must carry Content-Type: application/json (its CSRF guard). Two requests every six hours: the 200 most active collectives, projects and funds and the 100 newest. Each becomes a campaign item: name, description, type, currency, the total received and yearly budget in cents, the number of contributors, logo URL, website and first tag, as Open Collective states them; the profile is https://opencollective.com/<slug>. Show amounts as observations with their fetch date, link each to its Open Collective page, and imply no endorsement.",++ "license_note": "Public account data published by Open Collective; the descriptions are the collectives' own words (short summaries only, linked back).",++ "min_interval_ms": 60000,++ "poll_interval_sec": 21600,++ "stale_after_sec": 86400,++ "max_items": 300,++ "enabled": false,++ "review_required": true,++ "sensitivity": "financial",++ "default_indexability": "noindex"}]}@@@ -48,6 +48,9 @@* `max_bytes` (an integer from 1 KiB to 32 MiB) raises or lowers the body cap for one endpoint* (SOURCES_MAX_BYTES otherwise); OVHcloud's VPS catalog is about 12 MB.*++ * `request_content_type` ("application/json" only) is sent as the GET's Content-Type (the request has no body): Open++ * Collective's public GraphQL API answers a GET only with it, as its CSRF guard.++ ** Some price lists key their records by id instead of listing them: Porkbun returns* `{ "pricing": { "com": {…}, "net": {…} } }` and AWS Lightsail keys its price list by SKU.* `"items_shape": "map"` reads such an object: each own property becomes one record, in sorted@@@ -228,6 +231,7 @@ function checkMapping(ep) {}if (ep.item_kind !== undefined && !/^[a-z][a-z0-9_]{1,39}$/.test(ep.item_kind)) return 'item_kind is malformed';if (ep.max_bytes !== undefined && (!Number.isInteger(ep.max_bytes) || ep.max_bytes < 1024 || ep.max_bytes > 32 * 1024 * 1024)) return 'max_bytes is an integer from 1024 to 33554432';++ if (ep.request_content_type !== undefined && ep.request_content_type !== 'application/json') return 'request_content_type may only be application/json';return null;}@@@ -105,6 +105,8 @@ function createIngest({ db, config, registry, items, robots, fetcher, spacer, em// credential by variable name, read now, never storedconst headers = { Accept: adapter.accept };++ // An api endpoint may name the Content-Type its GET carries (adapters/api.js `request_content_type`).++ if (endpoint.request_content_type) headers['Content-Type'] = endpoint.request_content_type;const sensitive = [];let requestUrl = target;if (source.auth.mode !== 'none') {@@@ -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'];++const CATEGORIES = ['news', 'blog', 'reviews', 'deals', 'coupons', 'trade', 'hosting', 'crowdfunding'];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}$/;@@@ -78,6 +78,7 @@ function validateSource(input, { maxItemsCap = 12000 } = {}) {// `where` is only copied when given, so records without conditions stay byte-identical.if (e.where) out.where = e.where;if (e.max_bytes !== undefined) out.max_bytes = e.max_bytes;++ if (e.request_content_type !== undefined) out.request_content_type = e.request_content_type;} else {// A feed may read structured facts out of its items' prose (adapters/extract.js).for (const k of Object.keys(e)) if (k !== 'url' && !(k === 'extract' && (input.type === 'rss' || input.type === 'atom'))) throw new RegistryError(`endpoints[${i}].${k} is only for api sources`);nnew file mode 100644@@@ -0,0 +1,67 @@++'use strict';++/**++ * opencollective-accounts (seeds/sources.json), category crowdfunding: Open Collective's public GraphQL API read by GET.++ * - the seed validates, is disabled, and both endpoints send Content-Type: application/json (the API's CSRF guard++ * refuses a GET without it); no other request content type is accepted;++ * - each account becomes one campaign item keyed by its slug, with the amounts and counts as the API states them;++ * - a run against a stub that insists on the header ingests, and the GET carried the header.++ */++const assert = require('assert');++const api = require('../server/adapters/api');++const { validateSource } = require('../server/registry');++const { boot, site, sourceDef, suite } = require('./helpers');++++const t = suite('crowdfunding-source');++const seed = require('../seeds/sources.json').sources.find((s) => s.key === 'opencollective-accounts');++++const BODY = JSON.stringify({ data: { accounts: { nodes: [++ { slug: 'logseq', name: 'Logseq', type: 'COLLECTIVE', description: 'A privacy-first platform.', currency: 'USD', createdAt: '2020-09-22T10:40:25.537Z',++ imageUrl: 'https://images.opencollective.com/logseq/logo.png', website: 'https://logseq.com/', tags: ['knowledge-base'],++ stats: { totalAmountReceived: { valueInCents: 95659957, currency: 'USD' }, yearlyBudget: { valueInCents: 23811274, currency: 'USD' }, contributorsCount: 12182 } },++ { slug: 'tiny', name: 'Tiny Project', type: 'PROJECT', description: null, currency: 'EUR', createdAt: '2026-10-14T08:00:00Z', imageUrl: null, website: null, tags: [],++ stats: { totalAmountReceived: { valueInCents: 0, currency: 'EUR' }, yearlyBudget: { valueInCents: 0, currency: 'EUR' }, contributorsCount: 0 } },++] } } });++++t('the seed is a disabled crowdfunding api source whose GETs carry Content-Type: application/json', () => {++ assert.ok(seed, 'opencollective-accounts is seeded');++ const rec = validateSource(seed);++ assert.strictEqual(rec.enabled, false);++ assert.strictEqual(rec.category, 'crowdfunding');++ assert.strictEqual(rec.endpoints.length, 2);++ for (const ep of rec.endpoints) assert.strictEqual(ep.request_content_type, 'application/json');++ assert.throws(() => validateSource({ ...seed, endpoints: [{ ...seed.endpoints[0], request_content_type: 'text/plain' }] }), /request_content_type may only be application\/json/);++ assert.throws(() => validateSource(sourceDef({ type: 'rss', endpoints: [{ url: 'https://example.org/f.xml', request_content_type: 'application/json' }] })), /only for api sources/);++});++++t('each account is one campaign item keyed by slug, amounts as the API states them', () => {++ const r = api.parse(BODY, { url: seed.endpoints[0].url, endpoint: seed.endpoints[0], maxItems: seed.max_items });++ assert.strictEqual(r.items.length, 2);++ const [logseq, tiny] = r.items;++ assert.deepStrictEqual([logseq.identity, logseq.kind, logseq.title, logseq.published_at], ['logseq', 'campaign', 'Logseq', '2020-09-22T10:40:25.537Z']);++ assert.deepStrictEqual(logseq.fields, {++ slug: 'logseq', account_type: 'COLLECTIVE', currency: 'USD', raised_cents: 95659957, raised_currency: 'USD', yearly_budget_cents: 23811274,++ backers: 12182, image_url: 'https://images.opencollective.com/logseq/logo.png', website: 'https://logseq.com/', tag: 'knowledge-base',++ });++ assert.deepStrictEqual([tiny.summary, tiny.fields.tag, tiny.fields.website], [null, null, null], 'missing values stay null');++});++++t('a run sends the header and ingests what the API answered', async () => {++ const svc = await boot();++ const s = await site({++ '/robots.txt': () => ({ status: 404, body: 'Not Found' }),++ '/graphql/v2': (req) => (String(req.headers['content-type'] || '').startsWith('application/json')++ ? { headers: { 'Content-Type': 'application/json' }, body: BODY }++ : { status: 400, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ errors: [{ message: 'blocked as a potential CSRF' }] }) }),++ });++ try {++ const ep = { ...seed.endpoints[0], url: `${s.origin}/graphql/v2?query=x` };++ await svc.registry.create(sourceDef({ key: 'oc', type: 'api', category: 'crowdfunding', endpoints: [ep] }), 'test');++ const r = await svc.ingest.run('oc', { trigger: 'manual' });++ assert.deepStrictEqual([r.runs[0].state, r.runs[0].items_seen], ['ok', 2]);++ assert.strictEqual(s.hits('/graphql/v2')[0].headers['content-type'], 'application/json');++ const rows = await svc.db.prepare("SELECT identity, category, kind FROM items WHERE source_key = 'oc' ORDER BY identity").all();++ assert.deepStrictEqual(rows.map((x) => [x.identity, x.category, x.kind]), [['logseq', 'crowdfunding', 'campaign'], ['tiny', 'crowdfunding', 'campaign']]);++ } finally { await s.close(); await svc.stop(); }++});++++t.run();
@@ -53,7 +53,7 @@ start and flushes on stop.
@@ -53,7 +53,7 @@ start and flushes on stop.
## The registry
## The registry
A source records: `key`, `name`, `type` (`rss|atom|sitemap|jsonld|api|manual`), `category`
A source records: `key`, `name`, `type` (`rss|atom|sitemap|jsonld|api|manual`), `category`
-(`news|blog|reviews|deals|coupons|trade|hosting`), `endpoints` (for `api`: a field mapping, no code per
+(`news|blog|reviews|deals|coupons|trade|hosting|crowdfunding`), `endpoints` (for `api`: a field mapping, no code per
provider), `auth` (`none|header|bearer|query` with the **name** of an environment variable —
provider), `auth` (`none|header|bearer|query` with the **name** of an environment variable —
names must start with `SOURCES_CRED_`, so an entry can never point at this service's own
names must start with `SOURCES_CRED_`, so an entry can never point at this service's own
secrets), `robots_note`, `terms_note`, `license_note`, the rate limit (`min_interval_ms`),
secrets), `robots_note`, `terms_note`, `license_note`, the rate limit (`min_interval_ms`),
@@ -150,6 +150,11 @@ A value is literal text or `$rec.<path>`, read from the record itself. No match
@@ -150,6 +150,11 @@ A value is literal text or `$rec.<path>`, read from the record itself. No match
`max_bytes` (an integer from 1 KiB to 32 MiB) sets one endpoint's body cap instead of `SOURCES_MAX_BYTES`. OVHcloud's
`max_bytes` (an integer from 1 KiB to 32 MiB) sets one endpoint's body cap instead of `SOURCES_MAX_BYTES`. OVHcloud's
catalog is about 12 MB.
catalog is about 12 MB.
+`request_content_type` (`application/json` only) is sent as the Content-Type of the endpoint's GET, which has no body.
+Open Collective's public GraphQL API answers a GET only with it (its CSRF guard): `opencollective-accounts` (category
+`crowdfunding`) reads the 200 most active and the 100 newest collectives, projects and funds every six hours, each a
+`campaign` item with the amounts and counts as Open Collective states them, for OpenVibe.Deals' crowdfunding discovery.
+
**Facts stated in prose (`extract`, rss/atom only).** A feed endpoint may carry
**Facts stated in prose (`extract`, rss/atom only).** A feed endpoint may carry
`extract: { kind, require, fields }` ([server/adapters/extract.js](server/adapters/extract.js)). Each field is read from
`extract: { kind, require, fields }` ([server/adapters/extract.js](server/adapters/extract.js)). Each field is read from
the item's own `title` or `summary` by a pattern with one capturing group. With `as: "date"`, "October 20" becomes
the item's own `title` or `summary` by a pattern with one capturing group. With `as: "date"`, "October 20" becomes
@@ -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"] },
+ "category": { "enum": ["news", "blog", "reviews", "deals", "coupons", "trade", "hosting", "crowdfunding"] },
"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,7 @@
@@ -49,7 +49,7 @@
"deals",
"deals",
"coupons",
"coupons",
"trade",
"trade",
- "hosting"
+ "hosting", "crowdfunding"
]
]
},
},
"homepage_url": {
"homepage_url": {
@@ -136,6 +136,10 @@
@@ -136,6 +136,10 @@
"type": "string",
"type": "string",
"pattern": "^[a-z][a-z0-9_]{1,39}$"
"pattern": "^[a-z][a-z0-9_]{1,39}$"
},
},
+ "request_content_type": {
+ "enum": ["application/json"],
+ "description": "Sent as the request's Content-Type on this endpoint's GET (no body). Open Collective's GraphQL API answers a GET only with it, as its CSRF guard (Sources#65)."
+ },
"max_bytes": {
"max_bytes": {
"type": "integer",
"type": "integer",
"minimum": 1024,
"minimum": 1024,
@@ -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.188.2",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.191.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -769,9 +769,9 @@
@@ -769,9 +769,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.188.2",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2",
- "integrity": "sha512-E64gQI7roKudmmJFnenyh1BjViJyI0j0MoVT4o35/jUD4rpzMkElwEh/fGbsPpnstdDFIcJR6gEcScbL0ZQhbg==",
+ "version": "0.191.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.191.0",
+ "integrity": "sha512-RA5K/K04KQce36VwTuBVVYJPeVk7FAuyHr5LU50WxyBesf/dfLiE0knnMQkDHWPLKFQ/HluWx0LUbqB+NJUo7A==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -27,7 +27,7 @@
@@ -27,7 +27,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.188.2",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.191.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1237,6 +1237,76 @@
@@ -1237,6 +1237,76 @@
"review_required": true,
"review_required": true,
"sensitivity": "health",
"sensitivity": "health",
"default_indexability": "noindex"
"default_indexability": "noindex"
+ },
+ {
+ "key": "opencollective-accounts",
+ "name": "Open Collective: collectives, projects and funds",
+ "type": "api",
+ "category": "crowdfunding",
+ "homepage_url": "https://opencollective.com/discover",
+ "endpoints": [
+ {
+ "url": "https://api.opencollective.com/graphql/v2?query=%7B+accounts%28type%3A+%5BCOLLECTIVE%2C+PROJECT%2C+FUND%5D%2C+limit%3A+200%2C+orderBy%3A+%7Bfield%3A+ACTIVITY%2C+direction%3A+DESC%7D%29+%7B+nodes+%7B+slug+name+type+description+currency+createdAt+imageUrl+website+tags+stats+%7B+totalAmountReceived+%7B+valueInCents+currency+%7D+yearlyBudget+%7B+valueInCents+currency+%7D+contributorsCount+%7D+%7D+%7D+%7D",
+ "format": "json",
+ "request_content_type": "application/json",
+ "items_path": "data.accounts.nodes",
+ "item_kind": "campaign",
+ "fields": {
+ "identity": "slug",
+ "title": "name",
+ "summary": "description",
+ "published_at": "createdAt"
+ },
+ "extra": {
+ "slug": "slug",
+ "account_type": "type",
+ "currency": "currency",
+ "raised_cents": "stats.totalAmountReceived.valueInCents",
+ "raised_currency": "stats.totalAmountReceived.currency",
+ "yearly_budget_cents": "stats.yearlyBudget.valueInCents",
+ "backers": "stats.contributorsCount",
+ "image_url": "imageUrl",
+ "website": "website",
+ "tag": "tags.0"
+ }
+ },
+ {
+ "url": "https://api.opencollective.com/graphql/v2?query=%7B+accounts%28type%3A+%5BCOLLECTIVE%2C+PROJECT%2C+FUND%5D%2C+limit%3A+100%2C+orderBy%3A+%7Bfield%3A+CREATED_AT%2C+direction%3A+DESC%7D%29+%7B+nodes+%7B+slug+name+type+description+currency+createdAt+imageUrl+website+tags+stats+%7B+totalAmountReceived+%7B+valueInCents+currency+%7D+yearlyBudget+%7B+valueInCents+currency+%7D+contributorsCount+%7D+%7D+%7D+%7D",
+ "format": "json",
+ "request_content_type": "application/json",
+ "items_path": "data.accounts.nodes",
+ "item_kind": "campaign",
+ "fields": {
+ "identity": "slug",
+ "title": "name",
+ "summary": "description",
+ "published_at": "createdAt"
+ },
+ "extra": {
+ "slug": "slug",
+ "account_type": "type",
+ "currency": "currency",
+ "raised_cents": "stats.totalAmountReceived.valueInCents",
+ "raised_currency": "stats.totalAmountReceived.currency",
+ "yearly_budget_cents": "stats.yearlyBudget.valueInCents",
+ "backers": "stats.contributorsCount",
+ "image_url": "imageUrl",
+ "website": "website",
+ "tag": "tags.0"
+ }
+ }
+ ],
+ "robots_note": "robots.txt checked 2026-10-15: https://api.opencollective.com/robots.txt answers 404, so the API host publishes no robots rules (a 404 is treated as no restrictions, never guessed as a disallow). The fetcher re-checks robots.txt on every run.",
+ "terms_note": "Open Collective's public GraphQL API v2 (documented at https://graphql-docs-v2.opencollective.com) answers anonymous reads of public accounts; a GET must carry Content-Type: application/json (its CSRF guard). Two requests every six hours: the 200 most active collectives, projects and funds and the 100 newest. Each becomes a campaign item: name, description, type, currency, the total received and yearly budget in cents, the number of contributors, logo URL, website and first tag, as Open Collective states them; the profile is https://opencollective.com/<slug>. Show amounts as observations with their fetch date, link each to its Open Collective page, and imply no endorsement.",
+ "license_note": "Public account data published by Open Collective; the descriptions are the collectives' own words (short summaries only, linked back).",
+ "min_interval_ms": 60000,
+ "poll_interval_sec": 21600,
+ "stale_after_sec": 86400,
+ "max_items": 300,
+ "enabled": false,
+ "review_required": true,
+ "sensitivity": "financial",
+ "default_indexability": "noindex"
}
}
]
]
}
}
@@ -48,6 +48,9 @@
@@ -48,6 +48,9 @@
* `max_bytes` (an integer from 1 KiB to 32 MiB) raises or lowers the body cap for one endpoint
* `max_bytes` (an integer from 1 KiB to 32 MiB) raises or lowers the body cap for one endpoint
* (SOURCES_MAX_BYTES otherwise); OVHcloud's VPS catalog is about 12 MB.
* (SOURCES_MAX_BYTES otherwise); OVHcloud's VPS catalog is about 12 MB.
*
*
+ * `request_content_type` ("application/json" only) is sent as the GET's Content-Type (the request has no body): Open
+ * Collective's public GraphQL API answers a GET only with it, as its CSRF guard.
+ *
* Some price lists key their records by id instead of listing them: Porkbun returns
* Some price lists key their records by id instead of listing them: Porkbun returns
* `{ "pricing": { "com": {…}, "net": {…} } }` and AWS Lightsail keys its price list by SKU.
* `{ "pricing": { "com": {…}, "net": {…} } }` and AWS Lightsail keys its price list by SKU.
* `"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
@@ -228,6 +231,7 @@ function checkMapping(ep) {
@@ -228,6 +231,7 @@ function checkMapping(ep) {
}
}
if (ep.item_kind !== undefined && !/^[a-z][a-z0-9_]{1,39}$/.test(ep.item_kind)) return 'item_kind is malformed';
if (ep.item_kind !== undefined && !/^[a-z][a-z0-9_]{1,39}$/.test(ep.item_kind)) return 'item_kind is malformed';
if (ep.max_bytes !== undefined && (!Number.isInteger(ep.max_bytes) || ep.max_bytes < 1024 || ep.max_bytes > 32 * 1024 * 1024)) return 'max_bytes is an integer from 1024 to 33554432';
if (ep.max_bytes !== undefined && (!Number.isInteger(ep.max_bytes) || ep.max_bytes < 1024 || ep.max_bytes > 32 * 1024 * 1024)) return 'max_bytes is an integer from 1024 to 33554432';
+ if (ep.request_content_type !== undefined && ep.request_content_type !== 'application/json') return 'request_content_type may only be application/json';
return null;
return null;
}
}
@@ -105,6 +105,8 @@ function createIngest({ db, config, registry, items, robots, fetcher, spacer, em
@@ -105,6 +105,8 @@ function createIngest({ db, config, registry, items, robots, fetcher, spacer, em
// credential by variable name, read now, never stored
// credential by variable name, read now, never stored
const headers = { Accept: adapter.accept };
const headers = { Accept: adapter.accept };
+ // An api endpoint may name the Content-Type its GET carries (adapters/api.js `request_content_type`).
+ if (endpoint.request_content_type) headers['Content-Type'] = endpoint.request_content_type;
const sensitive = [];
const sensitive = [];
let requestUrl = target;
let requestUrl = target;
if (source.auth.mode !== 'none') {
if (source.auth.mode !== 'none') {
@@ -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'];
+const CATEGORIES = ['news', 'blog', 'reviews', 'deals', 'coupons', 'trade', 'hosting', 'crowdfunding'];
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}$/;
@@ -78,6 +78,7 @@ function validateSource(input, { maxItemsCap = 12000 } = {}) {
@@ -78,6 +78,7 @@ function validateSource(input, { maxItemsCap = 12000 } = {}) {
// `where` is only copied when given, so records without conditions stay byte-identical.
// `where` is only copied when given, so records without conditions stay byte-identical.
if (e.where) out.where = e.where;
if (e.where) out.where = e.where;
if (e.max_bytes !== undefined) out.max_bytes = e.max_bytes;
if (e.max_bytes !== undefined) out.max_bytes = e.max_bytes;
+ if (e.request_content_type !== undefined) out.request_content_type = e.request_content_type;
} else {
} else {
// A feed may read structured facts out of its items' prose (adapters/extract.js).
// A feed may read structured facts out of its items' prose (adapters/extract.js).
for (const k of Object.keys(e)) if (k !== 'url' && !(k === 'extract' && (input.type === 'rss' || input.type === 'atom'))) throw new RegistryError(`endpoints[${i}].${k} is only for api sources`);
for (const k of Object.keys(e)) if (k !== 'url' && !(k === 'extract' && (input.type === 'rss' || input.type === 'atom'))) throw new RegistryError(`endpoints[${i}].${k} is only for api sources`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+'use strict';
+/**
+ * opencollective-accounts (seeds/sources.json), category crowdfunding: Open Collective's public GraphQL API read by GET.
+ * - the seed validates, is disabled, and both endpoints send Content-Type: application/json (the API's CSRF guard
+ * refuses a GET without it); no other request content type is accepted;
+ * - each account becomes one campaign item keyed by its slug, with the amounts and counts as the API states them;
+ * - a run against a stub that insists on the header ingests, and the GET carried the header.
+ */
+const assert = require('assert');
+const api = require('../server/adapters/api');
+const { validateSource } = require('../server/registry');
+const { boot, site, sourceDef, suite } = require('./helpers');
+
+const t = suite('crowdfunding-source');
+const seed = require('../seeds/sources.json').sources.find((s) => s.key === 'opencollective-accounts');
+
+const BODY = JSON.stringify({ data: { accounts: { nodes: [
+ { slug: 'logseq', name: 'Logseq', type: 'COLLECTIVE', description: 'A privacy-first platform.', currency: 'USD', createdAt: '2020-09-22T10:40:25.537Z',
+ imageUrl: 'https://images.opencollective.com/logseq/logo.png', website: 'https://logseq.com/', tags: ['knowledge-base'],
+ stats: { totalAmountReceived: { valueInCents: 95659957, currency: 'USD' }, yearlyBudget: { valueInCents: 23811274, currency: 'USD' }, contributorsCount: 12182 } },
+ { slug: 'tiny', name: 'Tiny Project', type: 'PROJECT', description: null, currency: 'EUR', createdAt: '2026-10-14T08:00:00Z', imageUrl: null, website: null, tags: [],
+ stats: { totalAmountReceived: { valueInCents: 0, currency: 'EUR' }, yearlyBudget: { valueInCents: 0, currency: 'EUR' }, contributorsCount: 0 } },
+] } } });
+
+t('the seed is a disabled crowdfunding api source whose GETs carry Content-Type: application/json', () => {
+ assert.ok(seed, 'opencollective-accounts is seeded');
+ const rec = validateSource(seed);
+ assert.strictEqual(rec.enabled, false);
+ assert.strictEqual(rec.category, 'crowdfunding');
+ assert.strictEqual(rec.endpoints.length, 2);
+ for (const ep of rec.endpoints) assert.strictEqual(ep.request_content_type, 'application/json');
+ assert.throws(() => validateSource({ ...seed, endpoints: [{ ...seed.endpoints[0], request_content_type: 'text/plain' }] }), /request_content_type may only be application\/json/);
+ assert.throws(() => validateSource(sourceDef({ type: 'rss', endpoints: [{ url: 'https://example.org/f.xml', request_content_type: 'application/json' }] })), /only for api sources/);
+});
+
+t('each account is one campaign item keyed by slug, amounts as the API states them', () => {
+ const r = api.parse(BODY, { url: seed.endpoints[0].url, endpoint: seed.endpoints[0], maxItems: seed.max_items });
+ assert.strictEqual(r.items.length, 2);
+ const [logseq, tiny] = r.items;
+ assert.deepStrictEqual([logseq.identity, logseq.kind, logseq.title, logseq.published_at], ['logseq', 'campaign', 'Logseq', '2020-09-22T10:40:25.537Z']);
+ assert.deepStrictEqual(logseq.fields, {
+ slug: 'logseq', account_type: 'COLLECTIVE', currency: 'USD', raised_cents: 95659957, raised_currency: 'USD', yearly_budget_cents: 23811274,
+ backers: 12182, image_url: 'https://images.opencollective.com/logseq/logo.png', website: 'https://logseq.com/', tag: 'knowledge-base',
+ });
+ assert.deepStrictEqual([tiny.summary, tiny.fields.tag, tiny.fields.website], [null, null, null], 'missing values stay null');
+});
+
+t('a run sends the header and ingests what the API answered', async () => {
+ const svc = await boot();
+ const s = await site({
+ '/robots.txt': () => ({ status: 404, body: 'Not Found' }),
+ '/graphql/v2': (req) => (String(req.headers['content-type'] || '').startsWith('application/json')
+ ? { headers: { 'Content-Type': 'application/json' }, body: BODY }
+ : { status: 400, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ errors: [{ message: 'blocked as a potential CSRF' }] }) }),
+ });
+ try {
+ const ep = { ...seed.endpoints[0], url: `${s.origin}/graphql/v2?query=x` };
+ await svc.registry.create(sourceDef({ key: 'oc', type: 'api', category: 'crowdfunding', endpoints: [ep] }), 'test');
+ const r = await svc.ingest.run('oc', { trigger: 'manual' });
+ assert.deepStrictEqual([r.runs[0].state, r.runs[0].items_seen], ['ok', 2]);
+ assert.strictEqual(s.hits('/graphql/v2')[0].headers['content-type'], 'application/json');
+ const rows = await svc.db.prepare("SELECT identity, category, kind FROM items WHERE source_key = 'oc' ORDER BY identity").all();
+ assert.deepStrictEqual(rows.map((x) => [x.identity, x.category, x.kind]), [['logseq', 'crowdfunding', 'campaign'], ['tiny', 'crowdfunding', 'campaign']]);
+ } finally { await s.close(); await svc.stop(); }
+});
+
+t.run();