Files
website/scripts/wildlife-inat.ts
Stan Runge ba44365c71
All checks were successful
Deploy production / deploy (push) Successful in 1m8s
wildlife: iNaturalist/Wikidata/GBIF import, CC photos and refresh script
2026-10-24 21:58:35 +00:00

212 lines
5.8 KiB
TypeScript

// Shared helpers for the wildlife pokedex scripts (scripts/wildlife-import.ts,
// scripts/wildlife-photos.ts): polite iNaturalist API access (about 1 request per second,
// as the API asks), the group definitions and photo licence handling.
import { getJson, sleep } from './planes-wiki';
export const UA =
'StanRungeWildlifeDex/1.0 (https://stanrunge.dev/trackers/wildlife; wildlife pokedex importer) bun';
export const INAT = 'https://api.inaturalist.org/v1';
export const PLACE_NL = 7506;
export const PLACE_EUROPE = 97391;
let last = 0;
/** GET an iNaturalist API path, at most one call per ~1.1 s (cached when --cache is set). */
export async function inat<T = InatList>(path: string, useCached = true): Promise<T> {
const url = `${INAT}${path}`;
const wait = last + 1100 - Date.now();
if (wait > 0) await sleep(wait);
last = Date.now();
return getJson<T>(url, { headers: { 'user-agent': UA } }, useCached);
}
export type InatPhoto = {
id: number;
license_code: string | null;
attribution: string;
attribution_name?: string | null;
url: string;
original_dimensions?: { width: number; height: number } | null;
};
export type InatTaxon = {
id: number;
name: string;
rank: string;
is_active?: boolean;
extinct?: boolean;
iconic_taxon_name?: string | null;
preferred_common_name?: string | null;
english_common_name?: string | null;
observations_count?: number;
wikipedia_url?: string | null;
wikipedia_summary?: string | null;
default_photo?: InatPhoto | null;
taxon_photos?: { photo: InatPhoto }[];
ancestors?: { id: number; rank: string; name: string }[];
ancestor_ids?: number[];
conservation_status?: { status?: string; iucn?: number; authority?: string } | null;
conservation_statuses?: {
status: string;
iucn: number | null;
authority: string | null;
description?: string | null;
place: { id: number } | null;
}[];
establishment_means?: { establishment_means?: string; place?: { id: number } } | null;
};
export type InatList = {
total_results: number;
page: number;
per_page: number;
results: { count: number; taxon: InatTaxon }[];
};
export const CC = [
'cc0',
'cc-by',
'cc-by-sa',
'cc-by-nc',
'cc-by-nc-sa',
'cc-by-nd',
'cc-by-nc-nd'
];
export const isCc = (code: string | null | undefined) => !!code && CC.includes(code.toLowerCase());
export const licenseLabel = (code: string) =>
code.toLowerCase() === 'cc0' ? 'CC0' : `${code.toUpperCase().replace(/^CC-/, 'CC ')} 4.0`;
export const licenseUrl = (code: string) =>
code.toLowerCase() === 'cc0'
? 'https://creativecommons.org/publicdomain/zero/1.0/'
: `https://creativecommons.org/licenses/${code.toLowerCase().replace(/^cc-/, '')}/4.0/`;
/** "(c) Jane Doe, some rights reserved (CC BY)" -> "Jane Doe". */
export const author = (p: InatPhoto) =>
p.attribution_name?.trim() ||
p.attribution
.replace(/^\(c\)\s*/i, '')
.replace(/,\s*(some|no) rights reserved.*$/i, '')
.replace(/^no known copyright restrictions.*$/i, '')
.trim() ||
null;
/** Any size URL -> the "medium" (500px) one. */
export const mediumUrl = (url: string) =>
url.replace(/\/(square|small|thumb|medium|large|original)\.(jpe?g|png|gif|webp)/i, '/medium.$2');
// Groups shown in the dex, first match wins (by ancestor names on iNaturalist).
export type GroupDef = {
slug: string;
name: string;
nameNl: string;
emoji: string;
match: string[];
};
export const GROUPS: GroupDef[] = [
{ slug: 'mammals', name: 'Mammals', nameNl: 'Zoogdieren', emoji: '🦊', match: ['Mammalia'] },
{ slug: 'birds', name: 'Birds', nameNl: 'Vogels', emoji: '🐦', match: ['Aves'] },
{
slug: 'reptiles',
name: 'Reptiles',
nameNl: 'Reptielen',
emoji: '🦎',
match: ['Reptilia', 'Testudines', 'Squamata', 'Crocodylia']
},
{ slug: 'amphibians', name: 'Amphibians', nameNl: 'Amfibieën', emoji: '🐸', match: ['Amphibia'] },
{
slug: 'sharks-and-rays',
name: 'Sharks & rays',
nameNl: 'Haaien en roggen',
emoji: '🦈',
match: ['Elasmobranchii', 'Chondrichthyes']
},
{
slug: 'fish',
name: 'Fish',
nameNl: 'Vissen',
emoji: '🐟',
match: ['Actinopterygii', 'Sarcopterygii', 'Petromyzonti', 'Myxini', 'Agnatha']
},
{
slug: 'butterflies-and-moths',
name: 'Butterflies & moths',
nameNl: 'Vlinders',
emoji: '🦋',
match: ['Lepidoptera']
},
{ slug: 'beetles', name: 'Beetles', nameNl: 'Kevers', emoji: '🪲', match: ['Coleoptera'] },
{
slug: 'bees-wasps-and-ants',
name: 'Bees, wasps & ants',
nameNl: 'Bijen, wespen en mieren',
emoji: '🐝',
match: ['Hymenoptera']
},
{
slug: 'dragonflies',
name: 'Dragonflies & damselflies',
nameNl: 'Libellen',
emoji: '🪽',
match: ['Odonata']
},
{
slug: 'flies',
name: 'Flies & mosquitoes',
nameNl: 'Vliegen en muggen',
emoji: '🪰',
match: ['Diptera']
},
{
slug: 'bugs-and-other-insects',
name: 'Bugs, crickets & other insects',
nameNl: 'Wantsen, sprinkhanen en andere insecten',
emoji: '🦗',
match: ['Insecta']
},
{
slug: 'spiders',
name: 'Spiders & kin',
nameNl: 'Spinnen en verwanten',
emoji: '🕷️',
match: ['Arachnida']
},
{
slug: 'crustaceans',
name: 'Crabs & other crustaceans',
nameNl: 'Kreeftachtigen',
emoji: '🦀',
match: [
'Crustacea',
'Malacostraca',
'Maxillopoda',
'Thecostraca',
'Branchiopoda',
'Hexanauplia'
]
},
{
slug: 'molluscs',
name: 'Snails, slugs & other molluscs',
nameNl: 'Weekdieren',
emoji: '🐌',
match: ['Mollusca']
},
{
slug: 'other-invertebrates',
name: 'Jellyfish, starfish, worms & co',
nameNl: 'Overige ongewervelden',
emoji: '🪼',
match: []
}
];
export function groupOf(ancestorNames: string[]): GroupDef {
for (const g of GROUPS) if (g.match.some((m) => ancestorNames.includes(m))) return g;
return GROUPS.at(-1)!;
}
export const IUCN_BY_NUMBER: Record<number, string> = {
5: 'DD',
10: 'LC',
20: 'NT',
30: 'VU',
40: 'EN',
50: 'CR',
60: 'EW',
70: 'EX'
};