cars: Wikimedia Commons photos (cars.photos, card heroes, model gallery + lightbox, brand hero/logo), cars:photos + cars:refresh with --report-new, fold duplicate names (Golf VI, Peugot, Mustang Mach 1, Model 3/Y, i10/i30, Ceed, Lynk & Co)
All checks were successful
Deploy production / deploy (push) Successful in 3m0s

This commit is contained in:
2026-10-24 16:44:37 +00:00
parent 0612270545
commit bf0a1e1d2a
16 changed files with 21786 additions and 39 deletions

View File

@@ -1,7 +1,7 @@
// Car pokedex queries (/trackers/cars). Public, read-only; data is written only by
// scripts/cars-import.ts.
import { db } from '#lib/server/db/index.js';
import { carBrands, carModels, carVariants } from '#lib/server/db/schema/index.js';
import { carBrands, carModels, carPhotos, carVariants } from '#lib/server/db/schema/index.js';
import { and, arrayContains, asc, desc, eq, gt, gte, ilike, lte, sql, type SQL } from 'drizzle-orm';
import { BODIES, DRIVES, FUELS, SORTS, type CarCard, type CarSort } from '#lib/cars/shared.js';
@@ -55,7 +55,8 @@ const cardColumns = {
bodies: carModels.bodies,
nlCount: carModels.nlCount,
nlPriceLatest: carModels.nlPriceLatest,
powerKwMax: carModels.powerKwMax
powerKwMax: carModels.powerKwMax,
photo: carModels.photo
};
const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
@@ -145,7 +146,21 @@ export async function getBrand(slug: string) {
.innerJoin(carBrands, eq(carBrands.id, carModels.brandId))
.where(eq(carModels.brandId, brand.id))
.orderBy(desc(carModels.nlCount), desc(carModels.variantCount), asc(carModels.name));
return { brand, models };
const [hero] = brand.photo
? await db
.select({
file: carPhotos.file,
author: carPhotos.author,
license: carPhotos.license,
licenseUrl: carPhotos.licenseUrl,
model: carModels.name
})
.from(carPhotos)
.innerJoin(carModels, eq(carModels.id, carPhotos.modelId))
.where(and(eq(carModels.brandId, brand.id), eq(carPhotos.thumb, brand.photo)))
.limit(1)
: [];
return { brand, models, hero: hero ?? null };
}
export async function getModel(brandSlug: string, modelSlug: string) {
@@ -156,7 +171,7 @@ export async function getModel(brandSlug: string, modelSlug: string) {
.where(and(eq(carBrands.slug, brandSlug), eq(carModels.slug, modelSlug)))
.limit(1);
if (!row) return null;
const [variants, neighbours] = await Promise.all([
const [variants, neighbours, photos] = await Promise.all([
db
.select()
.from(carVariants)
@@ -175,11 +190,27 @@ export async function getModel(brandSlug: string, modelSlug: string) {
sql`${carModels.dex} in (
(select max(dex) from cars.models where dex < ${row.model.dex}),
(select min(dex) from cars.models where dex > ${row.model.dex}))`
)
),
db
.select({
file: carPhotos.file,
kind: carPhotos.kind,
caption: carPhotos.caption,
thumb: carPhotos.thumb,
width: carPhotos.width,
height: carPhotos.height,
author: carPhotos.author,
license: carPhotos.license,
licenseUrl: carPhotos.licenseUrl
})
.from(carPhotos)
.where(eq(carPhotos.modelId, row.model.id))
.orderBy(asc(carPhotos.position))
]);
return {
...row,
variants,
photos,
prev: neighbours.find((n) => n.dex < row.model.dex) ?? null,
next: neighbours.find((n) => n.dex > row.model.dex) ?? null
};

View File

@@ -7,6 +7,7 @@ import {
real,
smallint,
text,
timestamp,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { carsSchema } from './schemas';
@@ -38,6 +39,16 @@ export const carBrands = carsSchema.table(
firstYear: smallint('first_year'),
lastYear: smallint('last_year'),
sources: text('sources').array().notNull().default([]),
/** Wikidata item of the brand/manufacturer and its enwiki article (scripts/cars-photos.ts). */
wikidata: text('wikidata'),
wikipedia: text('wikipedia'),
/** Logo thumbnail (Wikimedia Commons, 250px) and its credit. */
logo: text('logo'),
logoFile: text('logo_file'),
logoAuthor: text('logo_author'),
logoLicense: text('logo_license'),
/** Hero photo: lead photo of the brand's most common model. */
photo: text('photo'),
...timestamps
},
(t) => [uniqueIndex('brands_slug_key').on(t.slug)]
@@ -96,6 +107,13 @@ export const carModels = carsSchema.table(
usYears: smallint('us_years').array().notNull().default([]),
variantCount: integer('variant_count').notNull().default(0),
sources: text('sources').array().notNull().default([]),
/** Matched Wikidata item / enwiki article (scripts/cars-photos.ts); null = no confident match. */
wikidata: text('wikidata'),
wikipedia: text('wikipedia'),
/** Lead photo thumbnail URL (960px), denormalised from cars.photos for the card grid. */
photo: text('photo'),
photoCount: smallint('photo_count').notNull().default(0),
photosCheckedAt: timestamp('photos_checked_at', { withTimezone: true }),
...timestamps
},
(t) => [
@@ -135,3 +153,28 @@ export const carVariants = carsSchema.table(
},
(t) => [index('variants_model_year_idx').on(t.modelId, t.year)]
);
/** Freely licensed photos from Wikimedia Commons; only thumbnail URLs are stored, nothing is downloaded. */
export const carPhotos = carsSchema.table(
'photos',
{
id: id(),
modelId: integer('model_id')
.notNull()
.references(() => carModels.id, { onDelete: 'cascade' }),
position: smallint('position').notNull(),
/** lead (Wikidata image), generation, article (enwiki), commons (Commons category). */
kind: text('kind').notNull(),
/** Commons file name without the "File:" prefix. */
file: text('file').notNull(),
caption: text('caption'),
/** 960px thumbnail on upload.wikimedia.org; other standard widths are derived from it. */
thumb: text('thumb').notNull(),
width: integer('width').notNull(),
height: integer('height').notNull(),
author: text('author'),
license: text('license'),
licenseUrl: text('license_url')
},
(t) => [uniqueIndex('photos_model_position_key').on(t.modelId, t.position)]
);