Curated public-domain and Creative Commons artworks/icons for paperlesspaper.de. The catalog is generated offline, uploaded to object storage, and exposed through a small read-only API that paperlesspaper-web can search.
Check out paperlesspaper.de.
Production base URL:
https://art.paperlesspaper.deObject storage asset base:
https://fsn1.your-objectstorage.com/paperlesspaper-artpaperlesspaper-web should call the API on art.paperlesspaper.de and render the
returned image.url.
GET /api/artworksExample:
GET https://art.paperlesspaper.de/api/artworks?q=bell&source=svgrepo&limit=10
GET https://art.paperlesspaper.de/api/artworks?selected=true&rating=5Query parameters:
q: searches title, artist, author, collection, license, source, source id, and tagssource:met,artic,wikimedia, orsvgrepopublicDomain:trueorfalselicense: text filter on licensetag: text filter on tagscollection: text filter on collection nameselected:trueorfalse, filters by the curation checkbox statehighlighted: alias forselectedrating:1,2,3,4,5,rated, orunratedsort:curated,relevance,title-asc,title-desc,date-desc,date-asc,downloaded-desc,downloaded-asc,rating-desc, orrating-asclimit: defaults to100, max200offset: pagination offset
Response shape:
{
"items": [
{
"id": "svgrepo:526478",
"source": "svgrepo",
"sourceId": "526478",
"title": "Bell bing SVG Vector",
"artist": "Solar Icons",
"isPublicDomain": false,
"license": "CC Attribution License",
"licenseUrl": "https://www.svgrepo.com/page/licensing/#CC%20Attribution",
"sourceUrl": "https://www.svgrepo.com/svg/526478/bell-bing",
"selected": true,
"highlighted": true,
"rating": 5,
"collection": {
"name": "Solar Line Duotone Icons",
"url": "https://www.svgrepo.com/collection/solar-line-duotone-icons/"
},
"author": {
"name": "Solar Icons",
"url": "https://www.figma.com/community/file/1166831539721848736"
},
"tags": ["Bell bing", "Bell Canada"],
"image": {
"originalUrl": "https://www.svgrepo.com/download/526478/bell-bing.svg",
"width": 800,
"height": 800,
"url": "https://fsn1.your-objectstorage.com/paperlesspaper-art/images/svgrepo/526478/original.svg",
"localOriginalPath": "https://fsn1.your-objectstorage.com/paperlesspaper-art/images/svgrepo/526478/original.svg"
},
"search": {
"query": "collection:solar-line-duotone-icons",
"downloadedAt": "2026-01-07T16:58:51.123Z"
}
}
],
"total": 45,
"limit": 10,
"offset": 0,
"meta": {
"totalCatalogItems": 12000,
"sourceCounts": {
"svgrepo": 9000,
"wikimedia": 3000
},
"sources": ["svgrepo", "wikimedia"],
"curation": {
"highlighted": 42,
"rated": 120
}
}
}GET /api/artworks/:idIDs contain :, so URL-encode them:
GET https://art.paperlesspaper.de/api/artworks/svgrepo%3A526478Response shape:
{
"item": {
"id": "svgrepo:526478",
"title": "Bell bing SVG Vector",
"source": "svgrepo",
"selected": true,
"highlighted": true,
"rating": 5,
"image": {
"width": 800,
"height": 800,
"url": "https://fsn1.your-objectstorage.com/paperlesspaper-art/images/svgrepo/526478/original.svg"
}
}
}The gallery page includes a lightweight curation tool for reviewing the local catalog:
- Search by title, artist, tag, collection, license, source, or source id.
- Filter by source, selected/highlighted state, and rating.
- Toggle the checkbox on each card to mark an artwork as selected/highlighted.
- Set a rating from
1to5; click the active rating again to clear it.
Curation is stored in the Postgres artwork_curation table, keyed by artwork
id:
{
"wikimedia:21856227": {
"highlighted": true,
"rating": 5
}
}Local JSON files are not used by the web runtime.
The public artwork APIs hydrate this state onto each returned item as
selected, highlighted, and rating. selected and highlighted currently
represent the same checkbox state; selected is the consumer-facing field.
The curation map can also be read or updated through:
GET /api/curation
PATCH /api/curationPatch body:
{
"id": "wikimedia:21856227",
"highlighted": true,
"rating": 5
}Use "rating": null to clear a rating, and "highlighted": false to clear the
selected/highlighted state.
Patch response:
{
"id": "wikimedia:21856227",
"item": {
"highlighted": true,
"rating": 5
}
}The canonical image URL for consumers is:
item.image.urlThe API rewrites local catalog paths like:
/images/svgrepo/526478/original.svgto object-storage URLs using ART_ASSET_BASE_URL.
The web app also supports app-domain image URLs by proxying them:
GET https://art.paperlesspaper.de/images/svgrepo/528659/original.svgstreams the object-storage asset from:
https://fsn1.your-objectstorage.com/paperlesspaper-art/images/svgrepo/528659/original.svgThis keeps URLs convenient, adds CORS headers for canvas/Fabric.js use cases, and avoids exposing object-storage CORS as a client dependency.
type ArtworkSearchResponse = {
items: Artwork[];
total: number;
limit: number;
offset: number;
meta: {
totalCatalogItems: number;
sourceCounts: Partial<Record<Artwork["source"], number>>;
sources: Artwork["source"][];
curation: {
highlighted: number;
rated: number;
};
};
};
type Artwork = {
id: string;
source: "met" | "artic" | "wikimedia" | "svgrepo";
sourceId: string;
title: string;
artist?: string;
date?: string;
isPublicDomain: boolean;
license: string;
licenseUrl?: string;
sourceUrl: string;
selected: boolean;
highlighted: boolean;
rating?: 1 | 2 | 3 | 4 | 5;
collection?: {
name: string;
url: string;
};
author?: {
name: string;
url: string;
};
tags?: string[];
image: {
originalUrl: string;
url: string;
localOriginalPath?: string;
localResizedPaths?: Record<string, string>;
resizedUrls?: Record<string, string>;
};
};
export async function searchPaperlesspaperArt(params: {
q?: string;
source?: Artwork["source"];
limit?: number;
offset?: number;
}) {
const url = new URL("https://art.paperlesspaper.de/api/artworks");
if (params.q) url.searchParams.set("q", params.q);
if (params.source) url.searchParams.set("source", params.source);
if (params.limit) url.searchParams.set("limit", String(params.limit));
if (params.offset) url.searchParams.set("offset", String(params.offset));
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Artwork search failed: ${response.status}`);
}
return (await response.json()) as ArtworkSearchResponse;
}Recommended UI behavior for paperlesspaper-web:
- Use
item.image.urlfor preview/render/import. - Display
item.title,item.artist,item.license, anditem.source. - Keep
item.sourceUrlanditem.licenseUrlavailable for attribution/details. - Use
limitandoffsetfor pagination or infinite scroll. - Prefer
source=svgrepowhen the user searches for icons. - Prefer
publicDomain=truewhen the target workflow requires public-domain only. - Prefer
selected=trueorrating=5when surfacing curated picks.
GET /api/artworksreturns200.GET /api/artworks/:idreturns200or404.GET /api/curationreturns the raw curation map.PATCH /api/curationupdates one curation entry and is only available from a local development host.- If
ART_API_KEYis configured, clients must pass either:Authorization: Bearer <key>X-API-Key: <key>
- CORS is controlled by
ART_ALLOWED_ORIGINS. - Artwork and curation API responses use
Cache-Control: no-store, max-age=0.
Dokploy/web runtime:
DATABASE_URL=postgres://...
ART_ASSET_BASE_URL=https://fsn1.your-objectstorage.com/paperlesspaper-art
ART_ALLOWED_ORIGINS=https://paperlesspaper.de,https://app.paperlesspaper.de
ART_API_KEY=Local/CI sync environment:
DATABASE_URL=postgres://...
S3_BUCKET=paperlesspaper-art
S3_ENDPOINT=https://fsn1.your-objectstorage.com
S3_REGION=fsn1
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...Do not put AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY into Dokploy unless the
server is also responsible for uploading assets. In the normal setup, Dokploy
reads catalog and curation data from Postgres and public assets from object
storage.
Deploy this repo as a Dockerfile application.
Dokploy settings:
- Build context:
. - Dockerfile path:
Dockerfile - Container port:
3000 - Domain:
art.paperlesspaper.de - HTTPS: enabled
The Docker image runs the Next standalone server and does not include
apps/web/public/images. Images are served from object storage.
The scraper is an offline content pipeline. Do not run it inside API request handlers.
Generate/update locally or in CI:
cd packages/scraper
npm ci
npm run build
node dist/index.js met --query "landscape" --limit 100
node dist/index.js artic --query "portrait" --limit 100
node dist/index.js wikimedia --query "paintings" --limit 100
node dist/index.js wikimedia --query "paintings" --limit 100 --min-global-usage 5 --min-local-usage 2 --thumb-width 1024 --download-delay-ms 1000
node dist/index.js svgrepo --query "cat" --limit 100If a non-interactive shell cannot find node, use the absolute Node binary, for
example /usr/local/bin/node dist/index.js wikimedia --query "paintings" --limit 100, or export a PATH that includes your Node install.
The scraper writes catalog metadata directly to Postgres and requires
DATABASE_URL or POSTGRES_URL. It writes downloaded assets to
apps/web/public/images by default. Use --web-root for the compiled CLI, or
ARTWORK_IMAGES_ROOT for scripts, when running outside the repo layout.
Scraper database helpers load environment values from the repo-root .env,
then apps/web/.env.local, then apps/web/.env; already-exported shell values
take precedence.
All scraper commands skip artwork IDs that already exist in Postgres before
downloading assets. Pass --refresh-existing to intentionally redownload and
update existing catalog rows.
The Wikimedia scraper upserts each accepted image into Postgres immediately
after download and resize, so interrupted overnight runs keep the catalog rows
that already finished.
When preview review is enabled, Wikimedia candidates download a small local
preview first and are added to a Postgres-backed review queue. The scraper holds
the current search offset while that batch has pending previews, so you can
approve or reject several previews in the web UI at once. Yes proceeds to the
full download on the next scraper pass, while no skips that candidate in future
runs. The queue persists across web app and scraper restarts.
To keep rerunning the finite Wikimedia search scraper, use:
cd packages/scraper
npm ci
npm run build
QUERY=paintings \
LIMIT=100 \
MIN_GLOBAL_USAGE=5 \
MIN_LOCAL_USAGE=2 \
THUMB_WIDTH=1024 \
DOWNLOAD_DELAY_MS=5000 \
WIKIMEDIA_USER_AGENT="paperlesspaper-art/0.1 (https://github.com/paperlesspaper/paperlesspaper-art; you@example.com)" \
bash scripts/scrape-wikimedia-loop.shTo rotate through several searches overnight, pass comma-separated keywords:
cd packages/scraper
npm ci
npm run build
KEYWORDS="landscape paintings, portrait paintings, still life paintings, botanical illustration" \
LIMIT=100 \
MIN_GLOBAL_USAGE=0 \
MIN_LOCAL_USAGE=0 \
THUMB_WIDTH=2048 \
DOWNLOAD_DELAY_MS=5000 \
SUCCESS_DELAY=30 \
WIKIMEDIA_USER_AGENT="paperlesspaper-art/0.1 (https://github.com/paperlesspaper/paperlesspaper-art; you@example.com)" \
bash scripts/scrape-wikimedia-loop.shYou can also pass keywords as quoted script arguments:
bash scripts/scrape-wikimedia-loop.sh "landscape paintings" "portraits" "still life"This loops the equivalent of:
node dist/index.js wikimedia --query "paintings" --limit 100 --min-global-usage 5 --min-local-usage 2 --thumb-width 1024 --download-delay-ms 5000Useful environment variables:
QUERY: Wikimedia search query, defaultpaintingsKEYWORDS: comma-, semicolon-, pipe-, or newline-separated Wikimedia search queries; when set, the loop runs each query in turnLIMIT: results per run, default100WIDTHS: resized image widths, default512,1024MIN_GLOBAL_USAGE: minimum global Wikimedia usage count, default0MIN_LOCAL_USAGE: minimum local Commons usage count, default0THUMB_WIDTH: Wikimedia thumbnail width to request, default1024PREVIEW_REVIEW: set to1to download a small review preview and wait for a yes/no decision before full download, default0PREVIEW_WIDTH: preview thumbnail width whenPREVIEW_REVIEW=1, default160DOWNLOAD_DELAY_MS: delay between image downloads, default5000WIKIMEDIA_USER_AGENT: descriptive Wikimedia request User-Agent with contact info; strongly recommended for long-running Commons scrapingWIKIMEDIA_MAXLAG: Commons Action APImaxlagvalue, default5; set0to omit itSEARCH_OFFSET: initial Wikimedia search offset, default0or the saved offset file valueOFFSET_FILE: file used to persist the next search offset, default.wikimedia-search-offset; used for single-query runsOFFSET_DIR: directory used to persist per-keyword search offsets during multi-keyword runs, default.wikimedia-search-offsetsREFRESH_EXISTING: redownload artwork IDs already in Postgres, default0WEB_ROOT: optional path toapps/webNODE_BIN: Node executable, defaultnodeSUCCESS_DELAY: delay before the next run after success, default60RESTART_DELAY: delay after a failed run, default10MAX_FAILURES: stop after this many failures;0means retry forever
The loop persists the next Wikimedia search offset in OFFSET_FILE for a
single query, or in one file per query under OFFSET_DIR for multi-keyword
runs. Each successful run advances that query by LIMIT, so later runs continue
through the search results instead of reprocessing only the first page. For a
broader Wikimedia backfill that actively hunts for Google Art Project images
across categories, use the Google Art Project continuous scraper below.
When running the web app locally, the curation page also exposes a local-only Wikimedia scraper panel:
cd apps/web
npm run devOpen http://localhost:3000/scraper, enter keywords, and start an overnight
run. The scraper page shows exact Wikimedia search queries already present in
Postgres, skips covered keywords by default, suggests next keywords from the
uncovered starter list, shows current download/Postgres status, supports a
multi-preview approval queue, and streams recent scraper logs while the loop
runs.
For large Wikimedia runs, set WIKIMEDIA_USER_AGENT to identify your scraper
with a contact URL or email. If Wikimedia reports HTTP 429 Too many requests
or HTTP 503 Service Unavailable, the scraper now retries with backoff and
honors the Retry-After response header when Wikimedia sends one. If throttling
continues, lower LIMIT, temporarily lower THUMB_WIDTH, increase
DOWNLOAD_DELAY_MS, increase SUCCESS_DELAY/RESTART_DELAY, and let the client
cool down before retrying. The scraper logs per-run skip stats so you can see
whether candidates were filtered out or downloads were throttled.
For long-running Wikimedia/Google Art Project backfills, use the dedicated loop script:
cd packages/scraper
npm ci
TARGET=1000 \
CONCURRENCY=10 \
bash scripts/download-google-art-project-loop.shThe loop runs scripts/download-google-art-project.mjs, then restarts it until
you stop it with Ctrl-C.
Useful environment variables:
TARGET: items to add per run, default1000PER_SOURCE_CAP: max items per category/search source per run, defaults toTARGETCATEGORY_LIMIT: max Wikimedia category members to inspect, default50000SEARCH_LIMIT: max Wikimedia search results to inspect, default500CONCURRENCY: parallel page workers, default10WIDTHS: resized image widths, default512,1024THUMB_WIDTH: Wikimedia thumbnail width to download, default3840ARTWORK_IMAGES_ROOT: override the local image output directorySUCCESS_DELAY: delay before the next run after success, default5EMPTY_DELAY: delay after a run adds no new items, default300RESTART_DELAY: delay after a failed run, default10MAX_FAILURES: stop after this many failures;0means retry foreverSTOP_ON_EMPTY: defaults to1in the loop, causing empty runs to exit with status20so the loop can wait longer before retrying
Publish generated images to object storage:
cd ../..
set -a
source .env
set +a
aws s3 sync apps/web/public/images s3://$S3_BUCKET/images --delete --only-show-errors --endpoint-url $S3_ENDPOINTThe web API reads the catalog directly from the Postgres artworks and
artwork_tags tables; static catalog JSON files are not part of the runtime or
Docker image.
apps/web: Next.js web/API app- Postgres
artworks,artwork_tags, andartwork_curationtables: runtime catalog and curation data apps/web/public/images: generated local images, ignored by git and Dockerpackages/scraper: Node.js CLI for downloading and resizing assets
- The Metropolitan Museum of Art Collection API: https://metmuseum.github.io/
- Art Institute of Chicago API: https://api.artic.edu/docs/
- Wikimedia Commons API: https://www.mediawiki.org/wiki/API:Main_page
- SVG Repo: https://www.svgrepo.com/
For svgrepo, you can optionally connect to an already-running Chrome session:
node dist/index.js svgrepo --all-collections --collections-start 0 --per-collection-limit 100000 --api-prefer --cdp-url http://127.0.0.1:9222
