planes: wikidata/wikipedia import, commons photos and refresh script
This commit is contained in:
1464
scripts/planes-import.ts
Normal file
1464
scripts/planes-import.ts
Normal file
File diff suppressed because it is too large
Load Diff
668
scripts/planes-photos.ts
Normal file
668
scripts/planes-photos.ts
Normal file
@@ -0,0 +1,668 @@
|
||||
// Finds freely licensed photos (Wikimedia Commons) for the plane pokedex (/trackers/planes)
|
||||
// and logos for its makers.
|
||||
//
|
||||
// DB_URL=... bun scripts/planes-photos.ts every type, replace all photos + logos
|
||||
// DB_URL=... bun scripts/planes-photos.ts --missing only types without photos / makers without a logo
|
||||
// DB_URL=... bun scripts/planes-photos.ts --logos maker logos only
|
||||
// DB_URL=... bun scripts/planes-photos.ts --enrich r.json [--fetch]
|
||||
// add lead photos to a planes-import --report-new file (stdout);
|
||||
// with --fetch, first match photos for types that have none
|
||||
// bun scripts/planes-photos.ts --dry-run --types built.json [--cache dir] [--out x.json]
|
||||
//
|
||||
// Every type already is a Wikidata item (from planes-import), so there is no fuzzy item
|
||||
// matching. Photos, in order (max 10, low-confidence files are skipped, never guessed):
|
||||
// 1. The item's own image (P18).
|
||||
// 2. Images of its variants (items that are "subclass of" it), captioned with the variant.
|
||||
// 3. Images used in its English Wikipedia article and files in its Commons category (P373).
|
||||
// These must name the type in the file name: its designator ("AH-64", "A320", "Boeing 737"
|
||||
// / "B737" for number-only designators) or, for names without one, a distinctive word
|
||||
// ("Spitfire") that no other type in the dex uses. Files naming a longer sibling type
|
||||
// of the same maker that is not a variant ("737 MAX" on a "737 Classic") are rejected,
|
||||
// as are cockpits, cabins, engines, crashes, drawings, maps, logos and small files.
|
||||
// Logos: the maker item's logo (P154, current/preferred first), else the infobox logo of its
|
||||
// article (Commons files only), else a plain "<Maker> logo" file in "<Maker> logos".
|
||||
// Nothing is downloaded: thumbnail URLs (standard widths) plus author and licence are stored.
|
||||
import postgres from 'postgres';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
chunks,
|
||||
cmp,
|
||||
fileName,
|
||||
log,
|
||||
mw,
|
||||
qid,
|
||||
saveCache,
|
||||
sparql,
|
||||
stripHtml,
|
||||
tokens,
|
||||
useCache,
|
||||
type ImageInfo
|
||||
} from './planes-wiki';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name: string) => args.includes(name);
|
||||
const opt = (name: string) => (args.includes(name) ? args[args.indexOf(name) + 1] : null);
|
||||
const dryRun = flag('--dry-run');
|
||||
const missingOnly = flag('--missing');
|
||||
const logosOnly = flag('--logos');
|
||||
const enrichFile = opt('--enrich');
|
||||
const typesFile = opt('--types');
|
||||
/** Only types whose full name matches this regex (for testing). */
|
||||
const only = opt('--only');
|
||||
const cacheDir = opt('--cache');
|
||||
const outFile = opt('--out');
|
||||
const MAX_PHOTOS = 10;
|
||||
const LOGO = 250;
|
||||
const THUMB = 960;
|
||||
|
||||
type Photo = {
|
||||
file: string;
|
||||
kind: 'lead' | 'variant' | 'article' | 'commons' | 'logo';
|
||||
caption: string | null;
|
||||
thumb: string;
|
||||
width: number;
|
||||
height: number;
|
||||
author: string | null;
|
||||
license: string | null;
|
||||
licenseUrl: string | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- commons
|
||||
|
||||
const BAD_FILE =
|
||||
/\.(svg|gif|tiff?|pdf|djvu|webm|ogv|stl)$|logo|emblem|badge|insignia|roundel|patch\b|wordmark|map\b|karte|locator|diagram|chart|graph|schema|schematic|drawing|sketch|blueprint|patent|3[- ]?view|three[- ]view|icon|flag|coat of arms|wappen|signature|symbol|cutaway|silhouette|commons-|wiki|stub|poster|advert|brochure|stamp|timeline|cockpit|flight deck|instrument|panel|cabin|interior|seats?\b|galley|lavatory|engine\b|nacelle|propeller\b|landing gear|undercarriage|wheel|tyre|tire\b|crash|wreck|accident|debris|incident|fire\b|burnt|scrap|graveyard|boneyard|\btoys?\b|scale model|model aircraft|lego|diecast|plastic model|kit\b|simulator|mock-?up|ticket|menu|uniform|crew\b|pilot\b|portrait|ceremony|memorial|grave|plaque|statue|stamp/i;
|
||||
|
||||
const infoCache = new Map<string, ImageInfo | null>();
|
||||
async function imageInfo(files: string[], width = THUMB) {
|
||||
const todo = [...new Set(files)].filter((f) => !infoCache.has(`${width}|${f}`));
|
||||
for (const chunk of chunks(todo, 50)) {
|
||||
const res = await mw('commons.wikimedia.org', {
|
||||
action: 'query',
|
||||
prop: 'imageinfo',
|
||||
iiprop: 'url|size|mime|extmetadata',
|
||||
iiurlwidth: String(width),
|
||||
iiextmetadatafilter: `Artist|LicenseShortName|LicenseUrl|AttributionRequired|NonFree${
|
||||
width === LOGO ? '|Restrictions' : ''
|
||||
}`,
|
||||
titles: chunk.map((f) => `File:${f}`).join('|')
|
||||
});
|
||||
const norm = new Map((res.query?.normalized ?? []).map((n) => [n.to, n.from]));
|
||||
for (const p of res.query?.pages ?? []) {
|
||||
const original = (norm.get(p.title) ?? p.title).replace(/^File:/, '');
|
||||
infoCache.set(`${width}|${original}`, p.missing || !p.imageinfo?.[0] ? null : p.imageinfo[0]);
|
||||
}
|
||||
for (const f of chunk)
|
||||
if (!infoCache.has(`${width}|${f}`)) infoCache.set(`${width}|${f}`, null);
|
||||
}
|
||||
}
|
||||
function toPhoto(
|
||||
file: string,
|
||||
kind: Photo['kind'],
|
||||
caption: string | null,
|
||||
width = THUMB
|
||||
): Photo | null {
|
||||
const info = infoCache.get(`${width}|${file}`);
|
||||
if (!info?.thumburl) return null;
|
||||
const meta = info.extmetadata ?? {};
|
||||
if (meta.NonFree?.value && meta.NonFree.value !== 'false') return null;
|
||||
let license = meta.LicenseShortName?.value ? stripHtml(meta.LicenseShortName.value) : null;
|
||||
if (!license || /fair use|non-free/i.test(license)) return null;
|
||||
if (kind === 'logo' && /trademark/i.test(meta.Restrictions?.value ?? ''))
|
||||
license += ' · trademark';
|
||||
if (kind !== 'logo') {
|
||||
if (!/^image\/(jpeg|png|webp)$/.test(info.mime)) return null;
|
||||
if (info.width < THUMB || info.height < 400) return null;
|
||||
if (info.width / info.height > 3 || info.height / info.width > 1.6) return null;
|
||||
}
|
||||
const author = meta.Artist?.value ? stripHtml(meta.Artist.value).slice(0, 200) || null : null;
|
||||
return {
|
||||
file,
|
||||
kind,
|
||||
caption,
|
||||
thumb: info.thumburl
|
||||
.replace(/\?.*$/, '')
|
||||
.replace('//thumb.wikimedia.org/', '//upload.wikimedia.org/'),
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
author,
|
||||
license,
|
||||
licenseUrl: meta.LicenseUrl?.value ?? null
|
||||
};
|
||||
}
|
||||
|
||||
async function articleImages(titles: string[]) {
|
||||
const out = new Map<string, string[]>();
|
||||
for (const chunk of chunks([...new Set(titles)], 50)) {
|
||||
let cont: Record<string, string> = {};
|
||||
for (let guard = 0; guard < 30; guard++) {
|
||||
const res = await mw('en.wikipedia.org', {
|
||||
action: 'query',
|
||||
prop: 'images',
|
||||
imlimit: 'max',
|
||||
redirects: '1',
|
||||
titles: chunk.join('|'),
|
||||
...cont
|
||||
});
|
||||
const norm = new Map<string, string>();
|
||||
for (const n of [...(res.query?.normalized ?? []), ...(res.query?.redirects ?? [])])
|
||||
norm.set(n.to, norm.get(n.from) ?? n.from);
|
||||
for (const p of res.query?.pages ?? []) {
|
||||
const key = norm.get(p.title) ?? p.title;
|
||||
out.set(key, [
|
||||
...(out.get(key) ?? []),
|
||||
...(p.images ?? []).map((im) => im.title.replace(/^File:/, ''))
|
||||
]);
|
||||
}
|
||||
if (!res.continue) break;
|
||||
cont = res.continue;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
async function categoryFiles(category: string, limit = 200) {
|
||||
const files: string[] = [];
|
||||
let cont: Record<string, string> = {};
|
||||
for (let guard = 0; guard < 4 && files.length < limit; guard++) {
|
||||
const res = await mw('commons.wikimedia.org', {
|
||||
action: 'query',
|
||||
list: 'categorymembers',
|
||||
cmtitle: `Category:${category}`,
|
||||
cmtype: 'file',
|
||||
cmlimit: '100',
|
||||
...cont
|
||||
});
|
||||
files.push(...(res.query?.categorymembers ?? []).map((m) => m.title.replace(/^File:/, '')));
|
||||
if (!res.continue) break;
|
||||
cont = res.continue;
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- matching
|
||||
|
||||
type Target = {
|
||||
id: number | null;
|
||||
wikidata: string;
|
||||
wikipedia: string | null;
|
||||
name: string;
|
||||
fullName: string;
|
||||
maker: string;
|
||||
makerSlug: string;
|
||||
numberBuilt: number;
|
||||
photos: number;
|
||||
};
|
||||
|
||||
/** File name in a comparable form: uppercase, "AH-64D" -> "AH64D", "Boeing 737" -> "BOEING737". */
|
||||
const norm = (s: string) =>
|
||||
` ${s
|
||||
.replace(/\.[a-z0-9]+$/i, '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toUpperCase()
|
||||
.replace(/[_]+/g, ' ')
|
||||
.replace(/([A-Z])[\s.-]+(?=\d)/g, '$1')
|
||||
.replace(/(\d)[\s-]+(?=\d)/g, '$1 ')
|
||||
.replace(/[^A-Z0-9]+/g, ' ')} `;
|
||||
const GENERIC = new Set(
|
||||
'FAMILY SERIES AIRCRAFT AIRPLANE AEROPLANE HELICOPTER GLIDER BOMBER FIGHTER TRAINER TRANSPORT CARGO SUPER MODEL TYPE CLASS CLASSIC NEXT GENERATION VARIANT VARIANTS COMMANDER STANDARD ROYAL AMERICAN BRITISH FLYING SPORT SPORTS SPECIAL MARK LIGHT HEAVY MEDIUM'.split(
|
||||
' '
|
||||
)
|
||||
);
|
||||
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
type Rule = { test: (f: string) => boolean; designators: string[]; required: string[] };
|
||||
/** Name-based tests for one type: designators like AH64 / A320 / 737 (with maker) and rare words. */
|
||||
function ruleFor(t: Target, wordUse: Map<string, number>): Rule {
|
||||
const makerWords = tokens(t.maker).filter((w) => w.length >= 3 && !GENERIC.has(w));
|
||||
const prefixes = [...new Set([...makerWords, ...makerWords.map((w) => w[0])])];
|
||||
const T = norm(t.name).trim().split(' ').filter(Boolean);
|
||||
const designators = T.filter((x) => /^[A-Z]{0,5}\d{1,5}[A-Z]{0,4}$/.test(x));
|
||||
// Short extra words that tell a sub-type apart ("737 MAX", "A320 NEO").
|
||||
const required = T.filter(
|
||||
(x) => /^[A-Z]{2,4}$/.test(x) && !GENERIC.has(x) && !prefixes.includes(x)
|
||||
);
|
||||
const words = T.filter((x) => /^[A-Z]{5,}$/.test(x) && !GENERIC.has(x));
|
||||
const tests: RegExp[] = [];
|
||||
for (const d of designators) {
|
||||
if (/^\d+$/.test(d)) {
|
||||
// Number-only designator: needs the maker ("Boeing 737", "B737", "Cessna 172").
|
||||
if (prefixes.length)
|
||||
tests.push(new RegExp(` (?:${prefixes.map(esc).join('|')}) ?${d}(?![0-9])`));
|
||||
} else if (d.length >= 3)
|
||||
tests.push(new RegExp(` (?:${prefixes.map(esc).join('|') || 'X'})?${esc(d)}(?![0-9])`));
|
||||
else if (makerWords.length)
|
||||
tests.push(new RegExp(` (?:${makerWords.map(esc).join('|')}) ${esc(d)}(?![0-9])`));
|
||||
}
|
||||
for (const w of words) {
|
||||
if ((wordUse.get(w) ?? 0) <= 1 && w.length >= 6) tests.push(new RegExp(` ${esc(w)} `));
|
||||
else if (makerWords.length)
|
||||
tests.push(new RegExp(`(?=.* (?:${makerWords.map(esc).join('|')}) ).* ${esc(w)} `));
|
||||
}
|
||||
return {
|
||||
designators,
|
||||
required,
|
||||
test: (f) => {
|
||||
const F = norm(f);
|
||||
return (
|
||||
tests.some((re) => re.test(F)) &&
|
||||
(!designators.length || required.every((r) => F.includes(r)))
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function loadWikidata(targets: Target[]) {
|
||||
const ids = targets.map((t) => t.wikidata);
|
||||
const own = new Map<string, { image: string | null; category: string | null }>();
|
||||
const children = new Map<string, { label: string; image: string }[]>();
|
||||
for (const c of chunks(ids, 300)) {
|
||||
for (const r of await sparql(`SELECT ?i ?img ?cat WHERE { VALUES ?i { ${c.map((x) => `wd:${x}`).join(' ')} }
|
||||
OPTIONAL { ?i wdt:P18 ?img } OPTIONAL { ?i wdt:P373 ?cat } }`)) {
|
||||
const id = qid(r.i);
|
||||
const cur = own.get(id) ?? { image: null, category: null };
|
||||
cur.image ??= r.img ? fileName(r.img) : null;
|
||||
cur.category ??= r.cat ?? null;
|
||||
own.set(id, cur);
|
||||
}
|
||||
for (const r of await sparql(`SELECT ?i ?c ?l ?img WHERE { VALUES ?i { ${c.map((x) => `wd:${x}`).join(' ')} }
|
||||
?c wdt:P279 ?i ; wdt:P18 ?img . ?c rdfs:label ?l FILTER(LANG(?l) = "en") }`)) {
|
||||
const id = qid(r.i);
|
||||
const list = children.get(id) ?? [];
|
||||
if (!list.some((x) => x.label === r.l)) list.push({ label: r.l, image: fileName(r.img) });
|
||||
children.set(id, list);
|
||||
}
|
||||
}
|
||||
await saveCache(true);
|
||||
return { own, children };
|
||||
}
|
||||
|
||||
async function photosFor(targets: Target[], all: Target[]) {
|
||||
const wd = await loadWikidata(targets);
|
||||
const wordUse = new Map<string, number>();
|
||||
for (const t of all)
|
||||
for (const w of new Set(tokens(t.name))) wordUse.set(w, (wordUse.get(w) ?? 0) + 1);
|
||||
const byMaker = new Map<string, Target[]>();
|
||||
for (const t of all) byMaker.set(t.makerSlug, [...(byMaker.get(t.makerSlug) ?? []), t]);
|
||||
const childLabels = (t: Target) => (wd.children.get(t.wikidata) ?? []).map((c) => cmp(c.label));
|
||||
const articles = await articleImages(
|
||||
targets.map((t) => t.wikipedia).filter((a): a is string => !!a)
|
||||
);
|
||||
const result = new Map<Target, Photo[]>();
|
||||
let done = 0;
|
||||
for (const t of targets) {
|
||||
const rule = ruleFor(t, wordUse);
|
||||
const ownCmp = cmp(t.name);
|
||||
const variants = new Set(childLabels(t));
|
||||
// Longer-named types of the same maker that are not variants of this one.
|
||||
const siblings = (byMaker.get(t.makerSlug) ?? [])
|
||||
.filter(
|
||||
(o) => o !== t && cmp(o.name).length > ownCmp.length && cmp(o.name).startsWith(ownCmp)
|
||||
)
|
||||
.filter((o) => ![...variants].some((v) => v.includes(cmp(o.name))))
|
||||
.map((o) => ruleFor(o, wordUse))
|
||||
// Only siblings with an extra telling word ("MAX") can claim a file from us.
|
||||
.filter(
|
||||
(o) =>
|
||||
o.required.some((r) => !rule.required.includes(r)) ||
|
||||
o.designators.some((d) => !rule.designators.includes(d))
|
||||
);
|
||||
const ok = (f: string) => !BAD_FILE.test(f) && rule.test(f) && !siblings.some((s) => s.test(f));
|
||||
const own = wd.own.get(t.wikidata);
|
||||
const picks: { file: string; kind: Photo['kind']; caption: string | null }[] = [];
|
||||
if (own?.image && !/\.(svg|gif)$/i.test(own.image))
|
||||
picks.push({ file: own.image, kind: 'lead', caption: null });
|
||||
for (const c of (wd.children.get(t.wikidata) ?? []).slice(0, 12))
|
||||
if (!BAD_FILE.test(c.image)) picks.push({ file: c.image, kind: 'variant', caption: c.label });
|
||||
for (const f of (t.wikipedia ? (articles.get(t.wikipedia) ?? []) : []).filter(ok))
|
||||
picks.push({ file: f, kind: 'article', caption: null });
|
||||
const unique = () => [...new Map(picks.map((p) => [p.file, p])).values()];
|
||||
await imageInfo(unique().map((p) => p.file));
|
||||
let photos = unique()
|
||||
.map((p) => toPhoto(p.file, p.kind, p.caption))
|
||||
.filter((p): p is Photo => !!p);
|
||||
if (photos.length < MAX_PHOTOS && own?.category) {
|
||||
const files = (await categoryFiles(own.category)).filter(ok);
|
||||
const want = MAX_PHOTOS * 2;
|
||||
const step = Math.max(1, files.length / want);
|
||||
const spread = Array.from(
|
||||
{ length: Math.min(want, files.length) },
|
||||
(_, i) => files[Math.floor(i * step)]
|
||||
);
|
||||
await imageInfo(spread);
|
||||
const seen = new Set(photos.map((p) => p.file));
|
||||
for (const f of spread) {
|
||||
if (seen.has(f)) continue;
|
||||
const p = toPhoto(f, 'commons', null);
|
||||
if (p) photos.push(p);
|
||||
seen.add(f);
|
||||
}
|
||||
}
|
||||
photos = photos.slice(0, MAX_PHOTOS);
|
||||
if (photos.length) result.set(t, photos);
|
||||
if (++done % 200 === 0) {
|
||||
log(`photos: ${done}/${targets.length} types`);
|
||||
await saveCache(true);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- logos
|
||||
|
||||
type Maker = {
|
||||
slug: string;
|
||||
name: string;
|
||||
fullName: string | null;
|
||||
wikidata: string | null;
|
||||
hasLogo: boolean;
|
||||
};
|
||||
const LOGO_WORD = /logo|wordmark|crest|emblem|marque|brand|logotip|insignia/i;
|
||||
const FREE_PD = /public domain|^pd\b|cc0/i;
|
||||
function logoFile(file: string, keys: string[]): Photo | null {
|
||||
const photo = toPhoto(file, 'logo', null, LOGO);
|
||||
if (!photo) return null;
|
||||
const named = keys.some((k) => cmp(file).includes(k));
|
||||
if (/\.svg$/i.test(file)) return LOGO_WORD.test(file) || named ? photo : null;
|
||||
if (/\.(png|gif)$/i.test(file))
|
||||
return LOGO_WORD.test(file) || (FREE_PD.test(photo.license ?? '') && named) ? photo : null;
|
||||
if (/\.(jpe?g|webp)$/i.test(file))
|
||||
return LOGO_WORD.test(file) && FREE_PD.test(photo.license ?? '') ? photo : null;
|
||||
return null;
|
||||
}
|
||||
const PLAIN_WORDS = new Set(
|
||||
'LOGO LOGOS LOGOTYPE WORDMARK EMBLEM BRAND MARQUE SYMBOL ICON BADGE CREST NEW OLD CURRENT SIMPLE FLAT COLOR COLOUR COLOURED COLORED BLACK WHITE GREY GRAY RED BLUE SILVER VERTICAL HORIZONTAL STACKED TRANSPARENT AIRCRAFT AVIATION AEROSPACE COMPANY CORPORATION INC SVG PNG'.split(
|
||||
' '
|
||||
)
|
||||
);
|
||||
function plainLogoName(file: string, names: string[]) {
|
||||
const t = tokens(file.replace(/\.[a-z]+$/i, ''));
|
||||
for (const n of names) {
|
||||
const nt = tokens(n);
|
||||
const at = t.findIndex((_, i) => nt.every((x, j) => t[i + j] === x));
|
||||
if (at < 0) continue;
|
||||
const rest = [...t.slice(0, at), ...t.slice(at + nt.length)];
|
||||
if (
|
||||
rest.some((x) => x.startsWith('LOGO')) &&
|
||||
rest.every((x) => PLAIN_WORDS.has(x) || /^\d+$/.test(x))
|
||||
)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async function infoboxLogo(article: string) {
|
||||
const res = await mw('en.wikipedia.org', {
|
||||
action: 'query',
|
||||
prop: 'revisions',
|
||||
rvprop: 'content',
|
||||
rvslots: 'main',
|
||||
rvsection: '0',
|
||||
redirects: '1',
|
||||
titles: article
|
||||
});
|
||||
const text = res.query?.pages?.[0]?.revisions?.[0]?.slots?.main?.content ?? '';
|
||||
const m = text.match(
|
||||
/\|\s*logo\s*=\s*(?:\[\[)?(?:File:|Image:)?\s*([^|\]\n{}<]+?\.(?:svg|png|gif))/i
|
||||
);
|
||||
return m ? m[1].trim().replace(/_/g, ' ') : null;
|
||||
}
|
||||
|
||||
async function findLogos(makers: Maker[]) {
|
||||
const ids = makers.map((m) => m.wikidata).filter((x): x is string => !!x);
|
||||
const info = new Map<
|
||||
string,
|
||||
{ article: string | null; category: string | null; logos: string[] }
|
||||
>();
|
||||
for (const c of chunks(ids, 200)) {
|
||||
const rows = await sparql(`
|
||||
SELECT ?m (SAMPLE(?a) AS ?article) (SAMPLE(?cat) AS ?category)
|
||||
(GROUP_CONCAT(DISTINCT CONCAT(STR(?logo), ";", STR(?rank), ";", COALESCE(STR(YEAR(?start)), ""), ";", COALESCE(STR(YEAR(?end)), "")); separator="|") AS ?logos)
|
||||
WHERE {
|
||||
VALUES ?m { ${c.map((x) => `wd:${x}`).join(' ')} }
|
||||
OPTIONAL { ?a schema:about ?m ; schema:isPartOf <https://en.wikipedia.org/> }
|
||||
OPTIONAL { ?m wdt:P373 ?cat }
|
||||
OPTIONAL { ?m p:P154 ?st . ?st ps:P154 ?logo ; wikibase:rank ?rank . FILTER(?rank != wikibase:DeprecatedRank)
|
||||
OPTIONAL { ?st pq:P580 ?start } OPTIONAL { ?st pq:P582 ?end } }
|
||||
} GROUP BY ?m`);
|
||||
for (const r of rows) {
|
||||
const logos = (r.logos ? r.logos.split('|').filter(Boolean) : [])
|
||||
.map((x) => {
|
||||
const [uri, rank, start, end] = x.split(';');
|
||||
return {
|
||||
file: fileName(uri),
|
||||
current: !end,
|
||||
score:
|
||||
(rank.endsWith('PreferredRank') ? 10000 : 0) + (end ? 0 : 5000) + (Number(start) || 0)
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.score - a.score);
|
||||
const current = logos.filter((x) => x.current);
|
||||
info.set(qid(r.m), {
|
||||
article: r.article
|
||||
? decodeURIComponent(r.article.split('/wiki/').at(-1)!).replace(/_/g, ' ')
|
||||
: null,
|
||||
category: r.category ?? null,
|
||||
logos: [...new Set((current.length ? current : logos).map((x) => x.file))]
|
||||
});
|
||||
}
|
||||
}
|
||||
await imageInfo(
|
||||
[...info.values()].flatMap((i) => i.logos),
|
||||
LOGO
|
||||
);
|
||||
const out = new Map<string, { photo: Photo | null; article: string | null }>();
|
||||
for (const m of makers) {
|
||||
const i = m.wikidata ? info.get(m.wikidata) : undefined;
|
||||
const names = [...new Set([m.name, m.fullName ?? m.name])];
|
||||
const keys = names.map(cmp);
|
||||
let photo: Photo | null = null;
|
||||
for (const f of i?.logos ?? []) if ((photo = logoFile(f, keys))) break;
|
||||
if (!photo && i?.article) {
|
||||
const f = await infoboxLogo(i.article);
|
||||
if (f) {
|
||||
await imageInfo([f], LOGO);
|
||||
const p = logoFile(f, keys);
|
||||
if (p && (LOGO_WORD.test(f) || keys.some((k) => cmp(f).includes(k)))) photo = p;
|
||||
}
|
||||
}
|
||||
if (!photo && i?.category) {
|
||||
const files = (await categoryFiles(`${i.category} logos`, 100))
|
||||
.filter((f) => /\.(svg|png)$/i.test(f) && /logo/i.test(f) && plainLogoName(f, names))
|
||||
.sort(
|
||||
(x, y) =>
|
||||
Math.max(0, ...(y.match(/(?:19|20)\d\d/g) ?? []).map(Number)) -
|
||||
Math.max(0, ...(x.match(/(?:19|20)\d\d/g) ?? []).map(Number)) ||
|
||||
Number(/\.svg$/i.test(y)) - Number(/\.svg$/i.test(x)) ||
|
||||
x.length - y.length
|
||||
)
|
||||
.slice(0, 5);
|
||||
await imageInfo(files, LOGO);
|
||||
for (const f of files) if ((photo = logoFile(f, keys))) break;
|
||||
}
|
||||
out.set(m.slug, { photo, article: i?.article ?? null });
|
||||
}
|
||||
await saveCache(true);
|
||||
log(`logos: ${[...out.values()].filter((x) => x.photo).length}/${makers.length} makers`);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- main
|
||||
|
||||
let sqlClient: ReturnType<typeof postgres> | null = null;
|
||||
function db() {
|
||||
if (!process.env.DB_URL) throw new Error('Set DB_URL (or use --dry-run --types <built.json>)');
|
||||
sqlClient ??= postgres(process.env.DB_URL, { max: 1, connect_timeout: 10, onnotice: () => {} });
|
||||
return sqlClient;
|
||||
}
|
||||
|
||||
async function loadTargets(): Promise<{ targets: Target[]; makers: Maker[] }> {
|
||||
if (typesFile) {
|
||||
const built = JSON.parse(await readFile(typesFile, 'utf8')) as {
|
||||
slug: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
wikidata: string;
|
||||
types: {
|
||||
wikidata: string;
|
||||
wikipedia: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
numberBuilt: number | null;
|
||||
}[];
|
||||
}[];
|
||||
return {
|
||||
targets: built.flatMap((m) =>
|
||||
m.types.map((t) => ({
|
||||
id: null,
|
||||
wikidata: t.wikidata,
|
||||
wikipedia: t.wikipedia,
|
||||
name: t.name,
|
||||
fullName: t.fullName,
|
||||
maker: m.name,
|
||||
makerSlug: m.slug,
|
||||
numberBuilt: t.numberBuilt ?? 0,
|
||||
photos: 0
|
||||
}))
|
||||
),
|
||||
makers: built.map((m) => ({
|
||||
slug: m.slug,
|
||||
name: m.name,
|
||||
fullName: m.fullName,
|
||||
wikidata: m.wikidata,
|
||||
hasLogo: false
|
||||
}))
|
||||
};
|
||||
}
|
||||
const sql = db();
|
||||
const rows = await sql<
|
||||
{
|
||||
id: number;
|
||||
wikidata: string;
|
||||
wikipedia: string | null;
|
||||
name: string;
|
||||
full_name: string;
|
||||
maker: string;
|
||||
maker_slug: string;
|
||||
built: number | null;
|
||||
photos: number;
|
||||
}[]
|
||||
>`select t.id, t.wikidata, t.wikipedia, t.name, t.full_name, m.name as maker, m.slug as maker_slug,
|
||||
t.number_built as built, t.photo_count as photos
|
||||
from planes.types t join planes.makers m on m.id = t.maker_id`;
|
||||
const makers = await sql<
|
||||
{
|
||||
slug: string;
|
||||
name: string;
|
||||
full_name: string | null;
|
||||
wikidata: string | null;
|
||||
logo: string | null;
|
||||
}[]
|
||||
>`
|
||||
select slug, name, full_name, wikidata, logo from planes.makers`;
|
||||
return {
|
||||
targets: rows.map((r) => ({
|
||||
id: r.id,
|
||||
wikidata: r.wikidata,
|
||||
wikipedia: r.wikipedia,
|
||||
name: r.name,
|
||||
fullName: r.full_name,
|
||||
maker: r.maker,
|
||||
makerSlug: r.maker_slug,
|
||||
numberBuilt: r.built ?? 0,
|
||||
photos: r.photos
|
||||
})),
|
||||
makers: makers.map((m) => ({
|
||||
slug: m.slug,
|
||||
name: m.name,
|
||||
fullName: m.full_name,
|
||||
wikidata: m.wikidata,
|
||||
hasLogo: !!m.logo
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
async function enrich(file: string) {
|
||||
const report = JSON.parse(await readFile(file, 'utf8')) as {
|
||||
newTypes?: { url: string; photo?: string | null }[];
|
||||
};
|
||||
const sql = db();
|
||||
for (const t of report.newTypes ?? []) {
|
||||
const [, maker, type] =
|
||||
new URL(t.url).pathname.match(/\/trackers\/planes\/([^/]+)\/([^/]+)/) ?? [];
|
||||
const [row] = await sql<{ photo: string | null }[]>`
|
||||
select t.photo from planes.types t join planes.makers m on m.id = t.maker_id
|
||||
where m.slug = ${maker} and t.slug = ${type}`;
|
||||
t.photo = row?.photo ?? null;
|
||||
}
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await useCache(cacheDir, 'photos.json');
|
||||
if (enrichFile && !flag('--fetch')) return enrich(enrichFile);
|
||||
const loaded = await loadTargets();
|
||||
const targets = loaded.targets
|
||||
.filter((t) => !missingOnly || t.photos === 0)
|
||||
.filter((t) => !only || new RegExp(only, 'i').test(t.fullName))
|
||||
.sort((a, b) => b.numberBuilt - a.numberBuilt);
|
||||
const photos = logosOnly ? new Map<Target, Photo[]>() : await photosFor(targets, loaded.targets);
|
||||
const makers = loaded.makers.filter(
|
||||
(m) => (!missingOnly || !m.hasLogo) && (!only || targets.some((t) => t.makerSlug === m.slug))
|
||||
);
|
||||
const logos = await findLogos(makers);
|
||||
await saveCache(true);
|
||||
const total = [...photos.values()].reduce((a, p) => a + p.length, 0);
|
||||
log(`photos: ${photos.size}/${targets.length} types with photos, ${total} photos`);
|
||||
if (outFile)
|
||||
await writeFile(
|
||||
outFile,
|
||||
JSON.stringify({
|
||||
types: targets.map((t) => ({
|
||||
type: t.fullName,
|
||||
photos: (photos.get(t) ?? []).map((p) => `${p.kind}: ${p.file}`)
|
||||
})),
|
||||
logos: makers.map((m) => ({ maker: m.name, logo: logos.get(m.slug)?.photo?.file ?? null }))
|
||||
})
|
||||
);
|
||||
if (dryRun) return;
|
||||
const sql = db();
|
||||
await sql.begin(async (tx) => {
|
||||
const ids = logosOnly ? [] : targets.map((t) => t.id!).filter(Boolean);
|
||||
await tx`delete from planes.photos where type_id = any(${ids})`;
|
||||
await tx`update planes.types set photo = null, photo_count = 0, photos_checked_at = now() where id = any(${ids})`;
|
||||
for (const t of logosOnly ? [] : targets) {
|
||||
const list = photos.get(t) ?? [];
|
||||
if (!t.id || !list.length) continue;
|
||||
await tx`update planes.types set photo = ${list[0].thumb}, photo_count = ${list.length} where id = ${t.id}`;
|
||||
await tx`insert into planes.photos ${tx(
|
||||
list.map((p, i) => ({
|
||||
type_id: t.id,
|
||||
position: i,
|
||||
kind: p.kind,
|
||||
file: p.file,
|
||||
caption: p.caption,
|
||||
thumb: p.thumb,
|
||||
width: p.width,
|
||||
height: p.height,
|
||||
author: p.author,
|
||||
license: p.license,
|
||||
license_url: p.licenseUrl
|
||||
}))
|
||||
)}`;
|
||||
}
|
||||
for (const m of makers) {
|
||||
const l = logos.get(m.slug);
|
||||
await tx`update planes.makers set wikipedia = ${l?.article ?? null},
|
||||
logo = ${l?.photo?.thumb ?? null}, logo_file = ${l?.photo?.file ?? null},
|
||||
logo_license = ${l?.photo?.license ?? null}, logo_author = ${l?.photo?.author ?? null}
|
||||
where slug = ${m.slug}`;
|
||||
}
|
||||
await tx`update planes.makers k set photo = (
|
||||
select t.photo from planes.types t where t.maker_id = k.id and t.photo is not null
|
||||
order by t.family_id is null desc, t.number_built desc nulls last limit 1)`;
|
||||
});
|
||||
log('written');
|
||||
if (enrichFile) await enrich(enrichFile);
|
||||
}
|
||||
|
||||
await main();
|
||||
await sqlClient?.end();
|
||||
27
scripts/planes-refresh.sh
Executable file
27
scripts/planes-refresh.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# Refresh of the plane pokedex (/trackers/planes) on stan-ovh.
|
||||
#
|
||||
# ssh stan@37.187.157.191 'cd ~/dev/website-planes && git fetch -q && git checkout -q --detach origin/main \
|
||||
# && bun install --frozen-lockfile >/dev/null && scripts/planes-refresh.sh'
|
||||
#
|
||||
# 1. bun scripts/planes-import.ts --report-new rebuilds makers/types from Wikidata + English
|
||||
# Wikipedia (infobox + specs) and notes which makers/types did not exist before. Dex numbers
|
||||
# are stable (types are matched by Wikidata id).
|
||||
# 2. bun scripts/planes-photos.ts --missing finds Wikimedia Commons photos for types that
|
||||
# have none yet (new ones included) and maker logos that are missing.
|
||||
# stdout: one JSON report {generatedAt, totals, newMakers, newTypes[] (with photo)}.
|
||||
# Progress goes to stderr. DB_URL is read from the running app container and never printed.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if [ -z "${DB_URL:-}" ]; then
|
||||
DB_URL=$(sudo docker inspect personal-website-app-1 --format '{{range .Config.Env}}{{println .}}{{end}}' |
|
||||
sed -n 's/^DB_URL=//p' | sed 's/@db:5432/@127.0.0.1:5440/')
|
||||
export DB_URL
|
||||
fi
|
||||
|
||||
report=$(mktemp /tmp/planes-report.XXXXXX.json)
|
||||
trap 'rm -f "$report"' EXIT
|
||||
|
||||
bun scripts/planes-import.ts --report-new "$@" >"$report"
|
||||
bun scripts/planes-photos.ts --missing --enrich "$report" --fetch
|
||||
184
scripts/planes-wiki.ts
Normal file
184
scripts/planes-wiki.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
// Shared HTTP helpers for the plane pokedex scripts (scripts/planes-import.ts,
|
||||
// scripts/planes-photos.ts): polite Wikidata SPARQL + MediaWiki API access with retries,
|
||||
// maxlag handling and an optional on-disk cache (--cache <dir>).
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const UA =
|
||||
'StanRungePlaneDex/1.0 (https://stanrunge.dev/trackers/planes; plane pokedex importer) bun';
|
||||
export const log = (...a: unknown[]) => console.error(...a);
|
||||
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
let cacheDir: string | null = null;
|
||||
let cache: Record<string, unknown> = {};
|
||||
let dirty = 0;
|
||||
let cacheName = 'api.json';
|
||||
|
||||
/** Use `<dir>/<name>` as a persistent response cache (only for dev runs and refreshes). */
|
||||
export async function useCache(dir: string | null, name = 'api.json') {
|
||||
cacheDir = dir;
|
||||
cacheName = name;
|
||||
if (!dir) return;
|
||||
await mkdir(dir, { recursive: true });
|
||||
try {
|
||||
cache = JSON.parse(await readFile(join(dir, name), 'utf8'));
|
||||
} catch {
|
||||
cache = {};
|
||||
}
|
||||
}
|
||||
export async function saveCache(force = false) {
|
||||
if (!cacheDir || (!force && dirty < 200)) return;
|
||||
dirty = 0;
|
||||
await writeFile(join(cacheDir, cacheName), JSON.stringify(cache));
|
||||
}
|
||||
|
||||
export async function getJson<T>(url: string, init?: RequestInit, useCached = true): Promise<T> {
|
||||
const key = `${url}${init?.body ? `#${String(init.body)}` : ''}`;
|
||||
if (useCached && key in cache) return cache[key] as T;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
'user-agent': UA,
|
||||
'api-user-agent': UA,
|
||||
accept: 'application/json',
|
||||
...init?.headers
|
||||
},
|
||||
signal: AbortSignal.timeout(120_000)
|
||||
});
|
||||
} catch (e) {
|
||||
if (attempt < 6) {
|
||||
await sleep(2000 * 2 ** attempt);
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const retryAfter = Number(res.headers.get('retry-after')) || 0;
|
||||
if (res.ok) {
|
||||
const body = (await res.json()) as T & { error?: { code?: string } };
|
||||
if (body?.error?.code === 'maxlag' && attempt < 8) {
|
||||
await sleep(Math.max(retryAfter, 5) * 1000);
|
||||
continue;
|
||||
}
|
||||
if (useCached && cacheDir) {
|
||||
cache[key] = body;
|
||||
dirty++;
|
||||
await saveCache();
|
||||
}
|
||||
return body;
|
||||
}
|
||||
if ((res.status === 429 || res.status >= 500) && attempt < 6) {
|
||||
await sleep(Math.max(retryAfter * 1000, 3000 * 2 ** attempt));
|
||||
continue;
|
||||
}
|
||||
throw new Error(`${res.status} ${url.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
let lastCall = 0;
|
||||
/** MediaWiki action API, one request at a time with a small gap (API etiquette). */
|
||||
export async function mw<T = MwResponse>(
|
||||
host: string,
|
||||
params: Record<string, string>,
|
||||
useCached = true
|
||||
): Promise<T> {
|
||||
const body = new URLSearchParams({ format: 'json', formatversion: '2', maxlag: '5', ...params });
|
||||
const key = `https://${host}/w/api.php?${body}`;
|
||||
if (!useCached || !(key in cache)) {
|
||||
const wait = lastCall + 150 - Date.now();
|
||||
if (wait > 0) await sleep(wait);
|
||||
lastCall = Date.now();
|
||||
}
|
||||
return getJson<T>(key, undefined, useCached);
|
||||
}
|
||||
export type MwPage = {
|
||||
pageid?: number;
|
||||
title: string;
|
||||
missing?: boolean;
|
||||
lastrevid?: number;
|
||||
extract?: string;
|
||||
images?: { title: string }[];
|
||||
imageinfo?: ImageInfo[];
|
||||
revisions?: { revid?: number; slots?: { main?: { content?: string } } }[];
|
||||
categorymembers?: { title: string }[];
|
||||
};
|
||||
export type ImageInfo = {
|
||||
width: number;
|
||||
height: number;
|
||||
mime: string;
|
||||
thumburl?: string;
|
||||
descriptionurl?: string;
|
||||
extmetadata?: Record<string, { value: string }>;
|
||||
};
|
||||
export type MwResponse = {
|
||||
query?: {
|
||||
pages?: MwPage[];
|
||||
normalized?: { from: string; to: string }[];
|
||||
redirects?: { from: string; to: string }[];
|
||||
categorymembers?: { title: string }[];
|
||||
};
|
||||
continue?: Record<string, string>;
|
||||
};
|
||||
|
||||
let lastSparql = 0;
|
||||
export async function sparql<T = Record<string, string>>(query: string): Promise<T[]> {
|
||||
const wait = lastSparql + 400 - Date.now();
|
||||
if (wait > 0) await sleep(wait);
|
||||
lastSparql = Date.now();
|
||||
const res = await getJson<{ results: { bindings: Record<string, { value: string }>[] } }>(
|
||||
'https://query.wikidata.org/sparql',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
accept: 'application/sparql-results+json'
|
||||
},
|
||||
body: new URLSearchParams({ query }).toString()
|
||||
}
|
||||
);
|
||||
return res.results.bindings.map(
|
||||
(b) => Object.fromEntries(Object.entries(b).map(([k, v]) => [k, v.value])) as T
|
||||
);
|
||||
}
|
||||
|
||||
export const qid = (uri: string) => uri.split('/').at(-1)!;
|
||||
export const fileName = (uri: string) =>
|
||||
decodeURIComponent(uri.split('/Special:FilePath/').at(-1)!).replace(/_/g, ' ');
|
||||
/** Uppercase letters and digits only: "Boeing 737-800" -> "BOEING737800". */
|
||||
export const cmp = (s: string) =>
|
||||
s
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toUpperCase()
|
||||
.replace(/&/g, ' AND ')
|
||||
.replace(/[^A-Z0-9]/g, '');
|
||||
export const tokens = (s: string) =>
|
||||
s
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toUpperCase()
|
||||
.replace(/&/g, ' ')
|
||||
.split(/[^A-Z0-9]+/)
|
||||
.filter(Boolean);
|
||||
export const slugify = (s: string) =>
|
||||
s
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80) || 'x';
|
||||
export const chunks = <T>(xs: T[], n: number) =>
|
||||
Array.from({ length: Math.ceil(xs.length / n) }, (_, i) => xs.slice(i * n, i * n + n));
|
||||
export const stripHtml = (s: string) =>
|
||||
s
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/�?39;/g, "'")
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
Reference in New Issue
Block a user