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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ node_modules
|
||||
resources/
|
||||
public/
|
||||
.DS_Store
|
||||
tools/cache/
|
||||
|
||||
206
tools/add_lego.js
Normal file
206
tools/add_lego.js
Normal 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: "© 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: "© 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
5
tools/config.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"rebrickable": {
|
||||
"apiKey": ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user