← back to AsSeenInMovies
feat(asseeninmovies): fan-art credit policy — Commons artist + license + source on every poster
2b1ecd3e6a090885f8c518f063ac0d683c8328b3 · 2026-05-12 11:12:13 -0700 · SteveStudio2
Per Steve's fan-art rule: every non-PD image must render artist + license
+ source-link on its page, or it can't be used. CC0/PD images get a
'Public domain · source' pill. Made-with-AI placeholder keeps the
existing pill.
Schema (idempotent ALTER):
ALTER TABLE asim_movies ADD COLUMN poster_credit TEXT;
ALTER TABLE asim_movies ADD COLUMN poster_license TEXT;
ALTER TABLE asim_movies ADD COLUMN poster_source_url TEXT;
ALTER TABLE asim_people ADD COLUMN headshot_credit TEXT;
ALTER TABLE asim_people ADD COLUMN headshot_license TEXT;
ALTER TABLE asim_people ADD COLUMN headshot_source_url TEXT;
scrapers/wikimedia-credit-backfill.js — batched Commons API
(prop=imageinfo, iiprop=extmetadata) fetcher that pulls Artist /
LicenseShortName / LicenseUrl + the canonical descriptionurl for every
poster_url we already imported from Wikimedia. 50 files per request,
1.1s sleep between batches, polite User-Agent.
Backfill result on this pass:
movies 783/783 ok (100%, no misses)
people 5/5 ok
All carry Artist + License + Commons File-page URL now.
server.js — imageCreditHtml(credit, license, sourceUrl, sourceKind):
CC0 / public-domain-mark / PD → '<span class="img-credit pd">Public domain · source</span>'
CC-BY / CC-BY-SA / anything else → '<span class="img-credit">Photo: {artist} / {license} / source</span>'
made-with-ai → existing '<span class="ai-pill">made with AI</span>'
No source URL → render nothing (safe default)
Rendered on both /movie/:id (poster chip) and /person/:id (headshot chip).
CSS chip uses a thin gold border for PD, line-color border for credited.
Smoke-tested live:
GET /movie/40 (Living in Oblivion) → 200
HTML contains: 'Photo: Machete kills / CC BY-SA 4.0 / <a ...>source</a>'
Memory codified: feedback_fan_art_credit_policy.md.
Hooks into existing IMDb-no-assets policy: fan art on Commons is the
legitimate substitute for IMDb posters.
Files touched
M scrapers/wikidata-enrich.jsA scrapers/wikimedia-credit-backfill.jsM server.js
Diff
commit 2b1ecd3e6a090885f8c518f063ac0d683c8328b3
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 12 11:12:13 2026 -0700
feat(asseeninmovies): fan-art credit policy — Commons artist + license + source on every poster
Per Steve's fan-art rule: every non-PD image must render artist + license
+ source-link on its page, or it can't be used. CC0/PD images get a
'Public domain · source' pill. Made-with-AI placeholder keeps the
existing pill.
Schema (idempotent ALTER):
ALTER TABLE asim_movies ADD COLUMN poster_credit TEXT;
ALTER TABLE asim_movies ADD COLUMN poster_license TEXT;
ALTER TABLE asim_movies ADD COLUMN poster_source_url TEXT;
ALTER TABLE asim_people ADD COLUMN headshot_credit TEXT;
ALTER TABLE asim_people ADD COLUMN headshot_license TEXT;
ALTER TABLE asim_people ADD COLUMN headshot_source_url TEXT;
scrapers/wikimedia-credit-backfill.js — batched Commons API
(prop=imageinfo, iiprop=extmetadata) fetcher that pulls Artist /
LicenseShortName / LicenseUrl + the canonical descriptionurl for every
poster_url we already imported from Wikimedia. 50 files per request,
1.1s sleep between batches, polite User-Agent.
Backfill result on this pass:
movies 783/783 ok (100%, no misses)
people 5/5 ok
All carry Artist + License + Commons File-page URL now.
server.js — imageCreditHtml(credit, license, sourceUrl, sourceKind):
CC0 / public-domain-mark / PD → '<span class="img-credit pd">Public domain · source</span>'
CC-BY / CC-BY-SA / anything else → '<span class="img-credit">Photo: {artist} / {license} / source</span>'
made-with-ai → existing '<span class="ai-pill">made with AI</span>'
No source URL → render nothing (safe default)
Rendered on both /movie/:id (poster chip) and /person/:id (headshot chip).
CSS chip uses a thin gold border for PD, line-color border for credited.
Smoke-tested live:
GET /movie/40 (Living in Oblivion) → 200
HTML contains: 'Photo: Machete kills / CC BY-SA 4.0 / <a ...>source</a>'
Memory codified: feedback_fan_art_credit_policy.md.
Hooks into existing IMDb-no-assets policy: fan art on Commons is the
legitimate substitute for IMDb posters.
---
scrapers/wikidata-enrich.js | 4 +
scrapers/wikimedia-credit-backfill.js | 144 ++++++++++++++++++++++++++++++++++
server.js | 24 +++++-
3 files changed, 170 insertions(+), 2 deletions(-)
diff --git a/scrapers/wikidata-enrich.js b/scrapers/wikidata-enrich.js
index edbe20c..5a8d42e 100644
--- a/scrapers/wikidata-enrich.js
+++ b/scrapers/wikidata-enrich.js
@@ -134,6 +134,10 @@ async function enrichMovies() {
if (!e) { noMatch++; continue; }
const posterUrl = commonsImage(e.img);
if (!DRY) {
+ // poster_credit/license/source_url are filled by the
+ // wikimedia-credit-backfill.js post-pass (Commons extmetadata fetch
+ // is one extra API call per file — done in batched backfill rather
+ // than slowing the SPARQL drain).
await pool.query(`
UPDATE asim_movies
SET wikidata_id = $1,
diff --git a/scrapers/wikimedia-credit-backfill.js b/scrapers/wikimedia-credit-backfill.js
new file mode 100644
index 0000000..c3c6a32
--- /dev/null
+++ b/scrapers/wikimedia-credit-backfill.js
@@ -0,0 +1,144 @@
+#!/usr/bin/env node
+'use strict';
+// Backfill image credit + license + source URL for every asim_movies poster
+// and asim_people headshot we previously imported from Wikimedia Commons.
+//
+// Per Steve's fan-art credit policy (memory: feedback_fan_art_credit_policy):
+// every fan-art / non-PD image must carry artist + license + source URL or it
+// cannot be displayed. We have 783 movie posters + a handful of headshots
+// already in place without that metadata. This script fetches it.
+//
+// Commons API: action=query, titles=File:..., prop=imageinfo,
+// iiprop=extmetadata returns Artist, LicenseShortName, LicenseUrl per file.
+// Batches up to 50 files per request.
+
+require('dotenv').config();
+const { Pool } = require('pg');
+
+const pool = new Pool({
+ connectionString: process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp&user=stevestudio2',
+ max: 4,
+});
+
+const COMMONS_API = 'https://commons.wikimedia.org/w/api.php';
+const UA = 'AsSeenInMovies/0.1 (https://asseeninmovies.com; steve@designerwallcoverings.com)';
+const BATCH = 50;
+const SLEEP_MS = 1100;
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+function fileNameFromCommonsUrl(url) {
+ // https://commons.wikimedia.org/wiki/Special:FilePath/Goosebumps%202023%20poster.jpg?width=600
+ // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ if (!url) return null;
+ const m = url.match(/Special:FilePath\/([^?]+)/i);
+ if (!m) return null;
+ try { return decodeURIComponent(m[1]).replace(/_/g, ' '); }
+ catch { return m[1].replace(/_/g, ' '); }
+}
+
+function stripHtml(s) {
+ if (!s) return null;
+ // Commons returns "Artist" wrapped in <a>...</a> HTML
+ return String(s).replace(/<[^>]+>/g, '').replace(/&/g, '&').trim();
+}
+
+async function commonsImageInfo(filenames) {
+ const titles = filenames.map(n => `File:${n}`).join('|');
+ const params = new URLSearchParams({
+ action: 'query',
+ titles,
+ prop: 'imageinfo',
+ iiprop: 'extmetadata|url|user',
+ format: 'json',
+ formatversion: '2',
+ });
+ const r = await fetch(`${COMMONS_API}?${params}`, { headers: { 'User-Agent': UA } });
+ if (!r.ok) throw new Error(`commons ${r.status}`);
+ const j = await r.json();
+ const out = new Map();
+ const pages = j.query?.pages || [];
+ for (const p of pages) {
+ if (!p.imageinfo || !p.imageinfo[0]) continue;
+ const ii = p.imageinfo[0];
+ const ext = ii.extmetadata || {};
+ const title = p.title.replace(/^File:/, '');
+ out.set(title, {
+ artist: stripHtml(ext.Artist?.value) || ii.user || null,
+ license: ext.LicenseShortName?.value || ext.License?.value || null,
+ licenseUrl: ext.LicenseUrl?.value || null,
+ descriptionUrl: ii.descriptionurl || `https://commons.wikimedia.org/wiki/File:${encodeURIComponent(title)}`,
+ });
+ }
+ return out;
+}
+
+async function backfillMovies() {
+ const sel = await pool.query(`
+ SELECT id, poster_url FROM asim_movies
+ WHERE poster_source='wikimedia-cc' AND poster_credit IS NULL
+ ORDER BY id LIMIT 2000`);
+ console.log(`[backfill-movies] ${sel.rows.length} rows`);
+ let ok = 0, miss = 0;
+ for (let i = 0; i < sel.rows.length; i += BATCH) {
+ const slice = sel.rows.slice(i, i + BATCH);
+ const idToFile = new Map();
+ for (const r of slice) {
+ const f = fileNameFromCommonsUrl(r.poster_url);
+ if (f) idToFile.set(r.id, f);
+ }
+ if (!idToFile.size) continue;
+ let meta;
+ try { meta = await commonsImageInfo([...idToFile.values()]); }
+ catch (e) { console.error(` batch ${i / BATCH}: ${e.message}`); await sleep(5000); continue; }
+ for (const [id, file] of idToFile) {
+ const m = meta.get(file);
+ if (!m) { miss++; continue; }
+ await pool.query(
+ `UPDATE asim_movies SET poster_credit=$1, poster_license=$2, poster_source_url=$3, updated_at=NOW() WHERE id=$4`,
+ [m.artist, m.license, m.descriptionUrl, id]
+ );
+ ok++;
+ }
+ if ((i / BATCH) % 5 === 0) console.log(` …batch ${Math.floor(i/BATCH)+1}/${Math.ceil(sel.rows.length/BATCH)} ok=${ok} miss=${miss}`);
+ await sleep(SLEEP_MS);
+ }
+ console.log(`[backfill-movies] done. ok=${ok} miss=${miss}`);
+}
+
+async function backfillPeople() {
+ const sel = await pool.query(`
+ SELECT id, headshot_url FROM asim_people
+ WHERE headshot_source='wikimedia-cc' AND headshot_credit IS NULL
+ ORDER BY id LIMIT 1000`);
+ console.log(`[backfill-people] ${sel.rows.length} rows`);
+ let ok = 0, miss = 0;
+ for (let i = 0; i < sel.rows.length; i += BATCH) {
+ const slice = sel.rows.slice(i, i + BATCH);
+ const idToFile = new Map();
+ for (const r of slice) {
+ const f = fileNameFromCommonsUrl(r.headshot_url);
+ if (f) idToFile.set(r.id, f);
+ }
+ if (!idToFile.size) continue;
+ let meta;
+ try { meta = await commonsImageInfo([...idToFile.values()]); }
+ catch (e) { console.error(` batch ${i / BATCH}: ${e.message}`); await sleep(5000); continue; }
+ for (const [id, file] of idToFile) {
+ const m = meta.get(file);
+ if (!m) { miss++; continue; }
+ await pool.query(
+ `UPDATE asim_people SET headshot_credit=$1, headshot_license=$2, headshot_source_url=$3, updated_at=NOW() WHERE id=$4`,
+ [m.artist, m.license, m.descriptionUrl, id]
+ );
+ ok++;
+ }
+ await sleep(SLEEP_MS);
+ }
+ console.log(`[backfill-people] done. ok=${ok} miss=${miss}`);
+}
+
+(async () => {
+ await backfillMovies();
+ await backfillPeople();
+ await pool.end();
+})().catch(e => { console.error('fatal:', e); process.exit(1); });
diff --git a/server.js b/server.js
index b626d5a..7374733 100644
--- a/server.js
+++ b/server.js
@@ -182,6 +182,23 @@ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'&
const posterFor = (m) => (m && m.poster_url && (m.poster_source || '').toLowerCase() !== 'made-with-ai') ? m.poster_url : '/img/made-with-ai.svg';
const headshotFor = (p) => (p && p.headshot_url && (p.headshot_source || '').toLowerCase() !== 'made-with-ai') ? p.headshot_url : '/img/made-with-ai.svg';
+// Per Steve's fan-art credit policy (memory: feedback_fan_art_credit_policy):
+// every non-PD image must render an artist + license + source-URL line.
+// CC0 / PD images get a "Public domain" pill with source link.
+// AI-placeholder gets the existing "made with AI" pill.
+function imageCreditHtml(credit, license, sourceUrl, sourceKind) {
+ if (!sourceUrl && sourceKind === 'made-with-ai') return '<span class="ai-pill">made with AI</span>';
+ if (!sourceUrl) return '';
+ const lic = (license || '').toLowerCase();
+ const isPd = lic === 'cc0' || lic === 'cc0-1.0' || lic.includes('public domain') || lic === 'pd';
+ if (isPd) {
+ return `<span class="img-credit pd">Public domain · <a href="${esc(sourceUrl)}" rel="nofollow noopener" target="_blank">source</a></span>`;
+ }
+ const artistPart = credit ? `${esc(credit)} / ` : '';
+ const licPart = license ? `${esc(license)} / ` : '';
+ return `<span class="img-credit">Photo: ${artistPart}${licPart}<a href="${esc(sourceUrl)}" rel="nofollow noopener" target="_blank">source</a></span>`;
+}
+
function htmlShell(title, body) {
return `<!doctype html>
<html lang="en"><head>
@@ -232,6 +249,9 @@ function htmlShell(title, body) {
footer.site a { color:var(--gold); }
.empty { padding:60px 0; text-align:center; color:var(--ink-faint); font-style:italic; }
.ai-pill { display:inline-block; padding:3px 9px; border:1px dotted var(--ink-faint); color:var(--ink-faint); font-size:10px; letter-spacing:0.16em; border-radius:3px; text-transform:uppercase; }
+ .img-credit { display:inline-block; padding:3px 9px; border:1px solid var(--line); color:var(--ink-soft); font-size:10px; letter-spacing:0.06em; border-radius:3px; }
+ .img-credit a { color:var(--gold); text-decoration:underline; }
+ .img-credit.pd { border-color:var(--gold); }
</style>
</head><body>
<header class="site">
@@ -294,7 +314,7 @@ app.get('/movie/:id', async (req, res) => {
${mv.imdb_id ? `<span class="chip">imdb · ${esc(mv.imdb_id)}</span>` : ''}
${mv.tmdb_id ? `<span class="chip">tmdb · ${esc(mv.tmdb_id)}</span>` : ''}
${mv.status ? `<span class="chip">${esc(mv.status)}</span>` : ''}
- ${mv.poster_source === 'made-with-ai' ? '<span class="ai-pill">made with AI</span>' : ''}
+ ${imageCreditHtml(mv.poster_credit, mv.poster_license, mv.poster_source_url, mv.poster_source)}
</div>
</div>
</div>`;
@@ -354,7 +374,7 @@ app.get('/person/:id', async (req, res) => {
<div class="chips">
${(person.primary_profession || []).map(p => `<span class="chip gold">${esc(p)}</span>`).join('')}
${(person.union_memberships || []).map(u => `<span class="chip">${esc(u)}</span>`).join('')}
- ${person.headshot_source === 'made-with-ai' ? '<span class="ai-pill">made with AI</span>' : ''}
+ ${imageCreditHtml(person.headshot_credit, person.headshot_license, person.headshot_source_url, person.headshot_source)}
</div>
${person.bio ? `<p class="overview">${esc(person.bio).slice(0, 800)}${person.bio.length > 800 ? '…' : ''}</p>` : ''}
${!isPremium ? `<p style="margin-top:18px"><a href="/claim/person/${esc(id)}" style="display:inline-block;padding:8px 16px;border:1px solid var(--gold);color:var(--gold);font-size:12px;letter-spacing:0.1em">Claim this profile →</a></p>` : ''}
← f69197c feat(asseeninmovies/tick3): Wikidata SPARQL enrichment
·
back to AsSeenInMovies
·
feat(asseeninmovies): inject Big Red widget into htmlShell f 2b42577 →