Skip to content

Commit 44b6cd2

Browse files
anuveyatsuclaude
andcommitted
feat(metadata): inbound DCAT/DCAT-AP RDF harvest — two-way interop (po-z7g)
Add the CONSUMING counterpart to /portaljs-add-dcat (po-uu5, which EXPOSES DCAT-AP). /portaljs-migrate could read DCAT-US /data.json + CKAN/Socrata/ODS/ ArcGIS, but not full DCAT / DCAT-AP RDF feeds — so PortalJS could publish DCAT-AP yet not ingest a European/national DCAT-AP catalog. This closes the gap: a portal is now a full two-way DCAT interop node. Template (examples/portaljs-catalog): - lib/metadata/dcat-harvest.ts — zero-dep inbound harvester, the inverse of dcat-rdf.ts's serializer. parseRdf() reads JSON-LD, Turtle, and RDF/XML into one triple model; harvestTriples() walks Catalog -> Dataset -> Distribution and maps to the canonical {slug,namespace,name,description,keywords, resources[]} shape /portaljs-migrate writes. Format auto-detected from the payload + Content-Type. Profile inferred from dct:conformsTo via the SAME dcat-profiles.ts registry po-uu5 exposes (expose/consume stay in sync). Reads DCAT 2/3, DCAT-AP + national (SE/CH/DE), GeoDCAT-AP spatial fields (dct:spatial/locn:geometry/dcat:bbox), and Croissant (schema.org JSON-LD) best-effort. Resilient: skips datasets with no title/distribution to a warnings[] rather than aborting a partial/nonstandard feed. - lib/metadata/index.ts — export harvest/harvestTriples/parseRdf/detectFormat/ toCanonicalEntry + types. - scripts/harvest-roundtrip.test.ts — round-trip proof: serialize the sample catalog to 6 profiles x 3 serializations, harvest each back, assert field fidelity + cross-serialization triple-count equivalence. Skill: - .claude/commands/portaljs-migrate.md — new --source dcat-rdf (feed URL or a portal page that autodiscovers one via <link rel="alternate">); RDF->canonical mapping table; pagination (hydra:next); resilience notes; two-way-interop section linking /portaljs-add-dcat. Verified: tsc clean; round-trip clean (6 profiles x 3 serializations). Real sources harvested — data.europa.eu JSON-LD (multilingual description intact) and govdata.de RDF/XML (100 DCAT-AP.de datasets in 22ms). End-to-end: merged 5 real DE datasets into datasets.json, next build green, showcase HTML + /search render the harvested titles/namespaces (datasets.json restored after). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d73ac72 commit 44b6cd2

4 files changed

Lines changed: 1245 additions & 11 deletions

File tree

.claude/commands/portaljs-migrate.md

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
description: Migrate (harvest) datasets between open-data platforms. Reads CKAN, a DCAT-US /data.json catalog (DKAN, ArcGIS Hub, data.gov), Socrata, OpenDataSoft, or an ArcGIS FeatureServer, and writes them to a static PortalJS catalog (datasets.json, link-by-URL or download data files into Cloudflare R2 via Git LFS / Giftless) or pushes them into a CKAN instance over its API.
2+
description: Migrate (harvest) datasets between open-data platforms. Reads CKAN, a DCAT-US /data.json catalog (DKAN, ArcGIS Hub, data.gov), a DCAT / DCAT-AP RDF feed (JSON-LD, Turtle, or RDF/XML — data.europa.eu, national DCAT-AP portals, GeoDCAT-AP), Socrata, OpenDataSoft, or an ArcGIS FeatureServer, and writes them to a static PortalJS catalog (datasets.json, link-by-URL or download data files into Cloudflare R2 via Git LFS / Giftless) or pushes them into a CKAN instance over its API.
33
allowed-tools: Read, Write, Edit, Bash, WebFetch
44
---
55

@@ -21,21 +21,31 @@ catalog, so the portal stands alone and needs no backend.
2121
Every source is read into one **canonical** shape (the template's `Dataset`/`Resource`
2222
type — a Frictionless-aligned `{ slug, namespace, name, description, resources[] }`), then
2323
written to the target from that canonical form. Add a source once and it migrates to every
24-
target. v1 ships two readers and one writer.
24+
target.
2525

26-
**Sources (v1):**
26+
**Sources:**
2727

2828
| Source | `--source` | How it's read | Covers |
2929
| ------ | ---------- | ------------- | ------ |
3030
| **CKAN** | `ckan` | REST API (`package_search` / `package_show`) | any CKAN instance |
31-
| **DCAT-US `/data.json`** | `dcat` | one catalog document | **DKAN, ArcGIS Hub, data.gov**, other DCAT-US publishers |
31+
| **DCAT-US `/data.json`** | `dcat` | one catalog document (plain JSON) | **DKAN, ArcGIS Hub, data.gov**, other DCAT-US publishers |
32+
| **DCAT / DCAT-AP RDF feed** | `dcat-rdf` | RDF catalog in JSON-LD, Turtle, or RDF/XML | **data.europa.eu**, national **DCAT-AP** portals (SE/CH/DE), GeoDCAT-AP, any DCAT 2/3 RDF feed |
3233
| **Socrata** | `socrata` | Discovery API + per-dataset resource exports | Socrata-powered open-data sites |
3334
| **OpenDataSoft** | `ods` | Explore API v2 catalog + exports | ODS-powered portals |
3435
| **ArcGIS FeatureServer / MapServer** | `arcgis` | layer metadata + GeoJSON query | individual ArcGIS map/feature services |
3536

3637
> DKAN, ArcGIS Hub, and data.gov publish a DCAT-US `/data.json` — use the **dcat** source for
3738
> those whole catalogs. Use **arcgis** for an individual FeatureServer/MapServer (each layer
3839
> becomes a GeoJSON dataset, which `/data.json` doesn't expose).
40+
>
41+
> **`dcat` vs `dcat-rdf`.** `dcat` reads the flat DCAT-US **`/data.json`** (Project Open Data
42+
> JSON — `dataset[]` with `distribution[]`). `dcat-rdf` reads a **DCAT / DCAT-AP RDF graph**
43+
> serialized as JSON-LD (`.jsonld`), Turtle (`.ttl`), or RDF/XML (`.rdf`) — the form
44+
> data.europa.eu and national DCAT-AP portals publish. This is the **inbound** counterpart of
45+
> [`/portaljs-add-dcat`](portaljs-add-dcat.md) (which EXPOSES the portal as DCAT-AP RDF): the two
46+
> make a portal a full **two-way DCAT interop node** — expose to, and harvest from, national
47+
> DCAT-AP portals. Both read through the SAME profile registry (`lib/metadata/dcat-profiles.ts`),
48+
> so expose and consume stay in sync.
3949
4050
**Targets:**
4151

@@ -50,10 +60,12 @@ since any reader can feed any writer through the canonical shape.
5060
## Required input — ask, don't error
5161

5262
**Source:**
53-
- **Source type**`ckan`, `dcat`, `socrata`, `ods`, or `arcgis` (auto-detected from the
54-
URL if omitted; see step 3).
55-
- **Source URL** (required) — e.g. a CKAN base URL, a DCAT `/data.json` URL, a Socrata or
56-
OpenDataSoft site root, or an ArcGIS `…/FeatureServer` (or `…/MapServer`) URL.
63+
- **Source type**`ckan`, `dcat`, `dcat-rdf`, `socrata`, `ods`, or `arcgis` (auto-detected
64+
from the URL if omitted; see step 3).
65+
- **Source URL** (required) — e.g. a CKAN base URL, a DCAT-US `/data.json` URL, a DCAT-AP RDF
66+
feed URL (`…/catalog.jsonld` / `.ttl` / `.rdf`, or a portal page that autodiscovers one via
67+
`<link rel="alternate">`), a Socrata or OpenDataSoft site root, or an ArcGIS
68+
`…/FeatureServer` (or `…/MapServer`) URL.
5769
- **Filters** (optional) — CKAN: org / group names. Socrata/ODS: pass a search term or
5870
category to scope large catalogs.
5971

@@ -78,7 +90,7 @@ dead-end with a missing-input error.** For `--target ckan`, if the target URL or
7890
### 1. Gather input from `$ARGUMENTS` (interview if thin)
7991

8092
Extract:
81-
- `SOURCE_TYPE``ckan` | `dcat` | `socrata` | `ods` | `arcgis` (default: auto-detect in step 3).
93+
- `SOURCE_TYPE``ckan` | `dcat` | `dcat-rdf` | `socrata` | `ods` | `arcgis` (default: auto-detect in step 3).
8294
- `SOURCE_URL` — required; strip any trailing slash.
8395
- `ORG_FILTER` / `GROUP_FILTER` — lists (CKAN source only; default empty).
8496
- `TARGET``static` | `ckan` (default `static`).
@@ -122,12 +134,18 @@ write without a confirmed key. If `OWNER_ORG` is set, confirm it exists
122134

123135
If `SOURCE_TYPE` is unset, auto-detect:
124136
- URL contains `/FeatureServer` or `/MapServer`**arcgis**.
125-
- URL ends in `.json` or contains `/data.json`**dcat**.
137+
- URL ends in `.jsonld`, `.ttl`, or `.rdf`, or contains `/catalog.` (an RDF feed) → **dcat-rdf**.
138+
- URL ends in `.json` or contains `/data.json`**dcat** (DCAT-US Project Open Data JSON).
126139
- URL contains `/api/explore/`**ods**; `/api/catalog/`**socrata**.
127140
- Otherwise probe CKAN: `curl -s -m 20 "SOURCE_URL/api/3/action/package_search?rows=1"`
128141
if JSON with `"success": true`, it's **ckan**.
129142
- Else probe in turn: `SOURCE_URL/api/explore/v2.1/catalog/datasets?limit=1` (ods),
130-
`SOURCE_URL/data.json` (dcat).
143+
`SOURCE_URL/data.json` (dcat), then content-negotiate RDF:
144+
`curl -fsSL -H "Accept: application/ld+json,text/turtle,application/rdf+xml" SOURCE_URL` — if
145+
the body is JSON-LD/Turtle/RDF-XML (starts with `{`/`[`, `@prefix`/`PREFIX`, or `<?xml`/`<rdf:RDF`),
146+
it's **dcat-rdf**. For an HTML page, look for a feed `<link rel="alternate" type="application/ld+json"
147+
…>` (or `text/turtle` / `application/rdf+xml`) and follow its `href` — this is the autodiscovery
148+
target `/portaljs-add-dcat` emits, so a portal's homepage URL is enough.
131149
- If still nothing resolves, tell the user the URL didn't look like a supported source and
132150
ask them to confirm the URL / pick the `--source` type — don't dead-end. (Socrata is read
133151
through the central Discovery API, so for a Socrata site pass `--source socrata` with the
@@ -190,6 +208,67 @@ Apply `ORG_FILTER`/`GROUP_FILTER` via the `fq` query
190208
| `resources[].path` | distribution `downloadURL` \|\| `accessURL` (link mode) |
191209
| `resources[].format` | distribution `format` \|\| `mediaType` → normalized (below) |
192210

211+
**DCAT / DCAT-AP RDF mapping** (`dcat-rdf`) — an RDF catalog graph in JSON-LD, Turtle, or
212+
RDF/XML. Do **not** hand-parse RDF: reuse the portal's harvester
213+
`lib/metadata/dcat-harvest.ts` — the inbound counterpart of the `lib/metadata/dcat-rdf.ts`
214+
serializer `/portaljs-add-dcat` uses, so expose/consume round-trip through one profile
215+
registry. It parses all three serializations into a triple graph and walks Catalog → Dataset
216+
→ Distribution → the canonical shape. (If the target portal predates it — no
217+
`lib/metadata/dcat-harvest.ts` — copy it and `dcat-profiles.ts` from
218+
`examples/portaljs-catalog/lib/metadata/`, same as `/portaljs-add-dcat` step 3.)
219+
220+
Fetch the raw RDF (never a WebFetch summary — you need the bytes) and run the harvester:
221+
222+
```bash
223+
cd PORTAL_DIR
224+
curl -fsSL -H "Accept: application/ld+json,text/turtle,application/rdf+xml" \
225+
"SOURCE_URL" -o /tmp/portaljs-harvest-feed
226+
# lib/metadata is TypeScript — run the harvester through tsx (already a devDependency):
227+
npx tsx -e '
228+
import { readFileSync, writeFileSync } from "node:fs"
229+
import { harvest, toCanonicalEntry } from "./lib/metadata/dcat-harvest"
230+
const r = harvest(readFileSync("/tmp/portaljs-harvest-feed", "utf8")) // format auto-detected
231+
console.error(`harvested ${r.datasets.length} datasets; profiles=[${r.profiles}]; warnings=${r.warnings.length}`)
232+
for (const w of r.warnings.slice(0, 10)) console.error(" ⚠ " + w)
233+
writeFileSync("/tmp/portaljs-harvest.json", JSON.stringify(r.datasets.map(toCanonicalEntry), null, 2))
234+
'
235+
```
236+
The written `/tmp/portaljs-harvest.json` is the canonical dataset array to feed step 4's
237+
in-memory list (then continue to step 5+ exactly as any other source).
238+
239+
`harvest(text, { format? })` auto-detects the serialization (Content-Type + payload sniff) —
240+
pass `{ format: "ttl" | "rdf" | "jsonld" }` to force it. It returns
241+
`{ catalog, datasets, profiles, warnings }`; `toCanonicalEntry(d)` yields exactly the
242+
`datasets.json` entry shape. The RDF→canonical field map (see `dcat-harvest.ts`):
243+
244+
| Canonical | DCAT RDF term |
245+
| --------- | ------------- |
246+
| `slug` | tail of `dct:identifier` \|\| slugified `dct:title` (unique within namespace) |
247+
| `namespace` | slugified `dct:publisher``foaf:name` (fallback first `dcat:theme` label, else `dataset`) |
248+
| `name` | `dct:title` |
249+
| `description` | `dct:description` |
250+
| `keywords` | `dcat:keyword[]` |
251+
| `resources[].path` | distribution `dcat:downloadURL` \|\| `dcat:accessURL` |
252+
| `resources[].format` | distribution `dct:format` \|\| `dcat:mediaType` → normalized |
253+
| `resources[].title` | distribution `dct:title` |
254+
255+
**Profiles read.** DCAT 2/3, DCAT-AP + national (SE/CH/DE), and **GeoDCAT-AP** (spatial fields
256+
`dct:spatial``locn:geometry` / `dcat:bbox` are captured on the harvested dataset). The
257+
feed's `dct:conformsTo` is mapped back to the profile id via the same registry
258+
(`result.profiles`) so you can tell the user which profile the source claims. **Croissant**
259+
(schema.org / MLCommons ML-dataset JSON-LD) is read best-effort through the JSON-LD path
260+
(`schema:name`/`description`/`keywords`/`distribution` → canonical).
261+
262+
**Large / paginated catalogs.** A single feed URL is one RDF document. A whole national
263+
catalog is usually paginated: follow `hydra:next` (or `dcat:Catalog` links / an `?page=` cursor
264+
the portal documents) and concatenate the harvest across pages, deduping on
265+
`(namespace, slug)`. As with CKAN, cap very large harvests and tell the user how many were
266+
imported vs. available (thousands of datasets = thousands of static pages, slow build).
267+
268+
**Resilience.** The harvester skips datasets with no title/identifier and datasets with no
269+
distribution or landing page (logged in `warnings`) rather than aborting — a partial or
270+
slightly nonstandard feed still imports what it can. Surface `warnings` to the user.
271+
193272
**Socrata mapping** (Discovery API at the central host, then per-dataset file exports):
194273

195274
Page the catalog: `https://api.us.socrata.com/api/catalog/v1?domains=<host>&limit=100&offset=…`
@@ -433,4 +512,13 @@ Next: open <target-url> to review the imported datasets.
433512
- **DKAN / ArcGIS Hub / data.gov.** These are DCAT-US publishers — point `/portaljs-migrate` at their
434513
whole-catalog `/data.json` with `--source dcat`. For one ArcGIS service (not a Hub site),
435514
use `--source arcgis` against its `…/FeatureServer` so each layer becomes a GeoJSON dataset.
515+
- **Two-way DCAT interop.** `--source dcat-rdf` is the harvest (inbound) half;
516+
[`/portaljs-add-dcat`](portaljs-add-dcat.md) is the expose (outbound) half. Together a PortalJS
517+
portal both **publishes to** and **harvests from** national/EU DCAT-AP portals. Both use the
518+
same `lib/metadata/dcat-profiles.ts` registry, so a feed this skill emits harvests back into
519+
the canonical shape with no field loss on what both sides support — verify with the round-trip
520+
test `examples/portaljs-catalog/scripts/harvest-roundtrip.test.ts`
521+
(`npx tsx scripts/harvest-roundtrip.test.ts`: serialize the sample catalog to all profiles ×
522+
serializations, harvest each back, assert fidelity). For a real source, harvest a
523+
data.europa.eu dataset feed or a national DCAT-AP portal's `catalog.rdf`/`.ttl`/`.jsonld`.
436524
```

0 commit comments

Comments
 (0)