// 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(path: string, useCached = true): Promise { const url = `${INAT}${path}`; const wait = last + 1100 - Date.now(); if (wait > 0) await sleep(wait); last = Date.now(); return getJson(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 = { 5: 'DD', 10: 'LC', 20: 'NT', 30: 'VU', 40: 'EN', 50: 'CR', 60: 'EW', 70: 'EX' };