1

feat(tools): add LEGO importer using Rebrickable API

- Implement  to add sets by query or ID
- Interactive selection, set/theme fetch, image downloads
- Include minifig images and YAML with title + attribution
- Persist API responses under tools/cache/rebrickable (gitignored)
- Add tools/config.json for API key
This commit is contained in:
2025-09-06 17:52:50 +02:00
parent cc7141ec15
commit 061e21f745
3 changed files with 213 additions and 1 deletions

3
.gitignore vendored
View File

@@ -2,4 +2,5 @@ node_modules
.hugo_build.lock .hugo_build.lock
resources/ resources/
public/ public/
.DS_Store .DS_Store
tools/cache/

206
tools/add_lego.js Normal file
View File

@@ -0,0 +1,206 @@
#!/usr/bin/env node
/*
Adds a LEGO set to the collection using Rebrickable API.
Usage: node tools/add_lego.js <query>
Flow:
1) Search sets with the provided query
2) Interactive choice if multiple results
3) Fetch chosen set details and its theme
4) Create content/collections/lego/<theme-slug>/<set-id>/
- Always create images/
- Create data/images/
- Download all available set images from Rebrickable
- Create data/images/<image-name>.yaml with attribution
- Create index.md with title and date
*/
const fs = require('fs/promises');
const fsSync = require('fs');
const path = require('path');
const https = require('https');
const readline = require('readline');
const ROOT = process.cwd();
const LEGO_ROOT = path.join(ROOT, 'content', 'collections', 'lego');
const CONFIG_PATH = path.join(ROOT, 'tools', 'config.json');
const CACHE_ROOT = path.join(ROOT, 'tools', 'cache', 'rebrickable');
function loadConfig() {
const raw = fsSync.readFileSync(CONFIG_PATH, 'utf8');
return JSON.parse(raw);
}
function slugify(input) {
return input
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-');
}
function prompt(question) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => rl.question(question, (ans) => { rl.close(); resolve(ans.trim()); }));
}
function apiGetJson(host, path, apiKey) {
return new Promise((resolve) => {
const req = https.request({
host,
path,
method: 'GET',
headers: {
'Authorization': `key ${apiKey}`,
'Accept': 'application/json',
'User-Agent': 'add_lego_script/1.0',
},
}, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => { resolve(JSON.parse(data)); });
});
req.end();
});
}
function downloadFile(url, destPath) {
return new Promise((resolve) => {
const file = fsSync.createWriteStream(destPath);
https.get(url, (res) => {
res.pipe(file);
file.on('finish', () => file.close(() => resolve()));
});
});
}
async function ensureDir(dir) {
await fs.mkdir(dir, { recursive: true });
}
async function saveJson(relPath, data) {
const out = path.join(CACHE_ROOT, relPath);
await ensureDir(path.dirname(out));
await fs.writeFile(out, JSON.stringify(data, null, 2), 'utf8');
}
async function main() {
const query = process.argv[2];
if (!query) {
console.log('Usage: node tools/add_lego.js <query|set-id>');
process.exit(1);
}
const config = loadConfig();
const apiKey = config.rebrickable.apiKey;
const host = 'rebrickable.com';
// 1) Search
const searchPath = `/api/v3/lego/sets/?search=${encodeURIComponent(query)}&page_size=25`;
const search = await apiGetJson(host, searchPath, apiKey);
const results = search.results || [];
await saveJson(`search/${encodeURIComponent(query)}.json`, search);
if (results.length === 0) {
console.log('No sets found for query:', query);
process.exit(0);
}
// 2) Selection
let selected = results[0];
if (results.length > 1) {
console.log(`Found ${results.length} sets:`);
results.forEach((r, i) => {
const id = (r.set_num || '').split('-')[0];
console.log(`${i + 1}. ${r.set_num}${r.name} (${r.year}) [id: ${id}]`);
});
const ans = await prompt('Choose a set (1..N): ');
const idx = Math.max(1, Math.min(results.length, parseInt(ans, 10) || 1)) - 1;
selected = results[idx];
}
const setNum = selected.set_num; // e.g., 10283-1
const setId = (setNum || '').split('-')[0];
// 3) Fetch exact set and theme
const setDetails = await apiGetJson(host, `/api/v3/lego/sets/${encodeURIComponent(setNum)}/`, apiKey);
await saveJson(`sets/${setNum}.json`, setDetails);
const themeId = setDetails.theme_id || selected.theme_id;
const theme = await apiGetJson(host, `/api/v3/lego/themes/${themeId}/`, apiKey);
await saveJson(`themes/${themeId}.json`, theme);
const themeName = theme.name;
const themeSlug = slugify(themeName);
// 4) Prepare dirs
const setDir = path.join(LEGO_ROOT, themeSlug, setId);
const imagesDir = path.join(setDir, 'images');
const dataImagesDir = path.join(setDir, 'data', 'images');
await ensureDir(imagesDir); // created even if no images provided
await ensureDir(dataImagesDir);
// Images provided by Rebrickable (set images)
const imageUrls = [];
if (setDetails.set_img_url) imageUrls.push(setDetails.set_img_url);
if (setDetails.set_img_url_2) imageUrls.push(setDetails.set_img_url_2);
for (const url of imageUrls) {
const urlObj = new URL(url);
const base = path.basename(urlObj.pathname);
const dest = path.join(imagesDir, base);
await downloadFile(url, dest);
const nameNoExt = base.replace(/\.[^.]+$/, '');
const yamlPath = path.join(dataImagesDir, `${nameNoExt}.yaml`);
const titleForSetImage = (setDetails.name || selected.name || '').replace(/"/g, '\\"');
const yaml = `attribution: "&copy; Rebrickable"\n` +
`title: "${titleForSetImage}"\n`;
await fs.writeFile(yamlPath, yaml, 'utf8');
}
// Minifigs images (stored directly in images/)
const figsResp = await apiGetJson(host, `/api/v3/lego/sets/${encodeURIComponent(setNum)}/minifigs/?page_size=1000`, apiKey);
const figs = figsResp.results || [];
await saveJson(`sets/${setNum}_minifigs.json`, figsResp);
if (figs.length > 0) {
for (const fig of figs) {
const figNum = fig.fig_num || fig.set_num;
let figImg = fig.set_img_url || fig.fig_img_url;
let figTitle = fig.name || '';
if (!figImg && figNum) {
const figDetails = await apiGetJson(host, `/api/v3/lego/minifigs/${encodeURIComponent(figNum)}/`, apiKey);
await saveJson(`minifigs/${figNum}.json`, figDetails);
figImg = figDetails.set_img_url || figDetails.fig_img_url;
figTitle = figTitle || figDetails.name || '';
}
if (!figImg) continue;
const urlObj = new URL(figImg);
const base = path.basename(urlObj.pathname);
const dest = path.join(imagesDir, base);
await downloadFile(figImg, dest);
const nameNoExt = base.replace(/\.[^.]+$/, '');
const yamlPath = path.join(dataImagesDir, `${nameNoExt}.yaml`);
const yaml = `attribution: "&copy; Rebrickable"\n` +
`title: "${(figTitle || '').replace(/"/g, '\\"')}"\n`;
await fs.writeFile(yamlPath, yaml, 'utf8');
}
}
// index.md
const title = setDetails.name || selected.name;
const today = new Date();
const date = today.toISOString().slice(0, 10);
const md = `---\n` +
`title: "${title.replace(/"/g, '\\"')}"\n` +
`date: ${date}\n` +
`---\n`;
await fs.writeFile(path.join(setDir, 'index.md'), md, 'utf8');
console.log(`\nAdded set ${setNum}${path.relative(ROOT, setDir)}`);
}
main();

5
tools/config.json Normal file
View File

@@ -0,0 +1,5 @@
{
"rebrickable": {
"apiKey": ""
}
}