caterium-app/ops/prepare-catalog-photos.js
2026-09-07 15:29:20 +03:00

232 lines
8.1 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const https = require('https');
const ROOT = __dirname;
const SOURCE_FILE = path.join(ROOT, 'catalog', 'photo-sources.json');
const EXPECTED_TOTAL = 113;
const MAX_BYTES = 20 * 1024 * 1024;
const CONCURRENCY = 3;
const RETRIES = 3;
const CHECK_ONLY = process.argv.includes('--check-only');
const FORCE = process.argv.includes('--force');
function normalizeRel(value) {
return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '');
}
function readSources() {
let data;
try {
data = JSON.parse(fs.readFileSync(SOURCE_FILE, 'utf8'));
} catch (error) {
throw new Error(`Cannot read catalog/photo-sources.json: ${error.message}`);
}
if (!Array.isArray(data)) throw new Error('catalog/photo-sources.json must contain an array');
const out = data.map((item, index) => ({
index: index + 1,
path: normalizeRel(item && item.path),
url: String(item && item.url || '').trim()
})).filter(item => item.path && item.url);
if (out.length !== EXPECTED_TOTAL) {
throw new Error(`Expected ${EXPECTED_TOTAL} catalog photo sources, found ${out.length}`);
}
const uniquePaths = new Set(out.map(item => item.path));
if (uniquePaths.size !== EXPECTED_TOTAL) throw new Error('Catalog photo source paths are not unique');
return out;
}
function isAllowedUrl(target) {
let url;
try { url = new URL(target); } catch (_) { return false; }
if (url.protocol !== 'https:') return false;
return new Set(['static.tildacdn.com', 'static3.tildacdn.com', 'static.tildacdn.net']).has(url.hostname);
}
function imageKind(buf) {
if (!Buffer.isBuffer(buf) || buf.length < 12) return '';
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'jpeg';
if (buf.subarray(0, 8).equals(Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]))) return 'png';
if (buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') return 'webp';
return '';
}
function validLocalPhoto(rel) {
const file = path.resolve(ROOT, rel);
if (!file.startsWith(ROOT + path.sep)) return false;
try {
const st = fs.statSync(file);
if (!st.isFile() || st.size < 1000) return false;
const fd = fs.openSync(file, 'r');
const head = Buffer.alloc(16);
fs.readSync(fd, head, 0, head.length, 0);
fs.closeSync(fd);
return imageKind(head) === 'jpeg';
} catch (_) {
return false;
}
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function downloadBuffer(target, redirects = 0, timeoutMs = 25000) {
return new Promise((resolve, reject) => {
if (redirects > 6) return reject(new Error('too many redirects'));
let url;
try { url = new URL(target); } catch (error) { return reject(error); }
if (!isAllowedUrl(url.toString())) return reject(new Error(`photo host is not allowed: ${url.hostname}`));
const req = https.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/142 Safari/537.36 SunCateringCatalogPhotoPrep/17.5.17',
'Accept': 'image/jpeg,image/*;q=0.8,*/*;q=0.1',
'Accept-Language': 'ru-RU,ru;q=0.9,en;q=0.5',
'Accept-Encoding': 'identity',
'Referer': 'https://solnce-keytering.ru/'
},
timeout: timeoutMs
}, res => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
const next = new URL(res.headers.location, url).toString();
if (!isAllowedUrl(next)) return reject(new Error('redirected to an unsupported photo host'));
return downloadBuffer(next, redirects + 1, timeoutMs).then(resolve, reject);
}
if (res.statusCode < 200 || res.statusCode >= 300) {
res.resume();
return reject(new Error(`HTTP ${res.statusCode}`));
}
const type = String(res.headers['content-type'] || '').toLowerCase();
if (type && !type.startsWith('image/')) {
res.resume();
return reject(new Error(`unexpected content type: ${type}`));
}
const chunks = [];
let size = 0;
res.on('data', chunk => {
size += chunk.length;
if (size > MAX_BYTES) {
req.destroy(new Error('photo is too large'));
return;
}
chunks.push(chunk);
});
res.on('end', () => {
const buf = Buffer.concat(chunks);
const kind = imageKind(buf);
if (kind !== 'jpeg' || buf.length < 1000) return reject(new Error('downloaded file is not a valid JPEG'));
resolve(buf);
});
res.on('error', reject);
});
req.on('timeout', () => req.destroy(new Error('timeout')));
req.on('error', reject);
});
}
function atomicWrite(rel, buffer) {
const file = path.resolve(ROOT, rel);
if (!file.startsWith(ROOT + path.sep)) throw new Error('invalid local photo path');
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.download-${process.pid}-${Date.now()}`;
fs.writeFileSync(tmp, buffer);
fs.renameSync(tmp, file);
}
async function fetchWithRetry(url) {
let last;
for (let attempt = 1; attempt <= RETRIES; attempt++) {
try { return await downloadBuffer(url); }
catch (error) {
last = error;
if (attempt < RETRIES) await delay(700 * attempt);
}
}
throw last || new Error('download failed');
}
function summarize(sources) {
const ready = sources.filter(item => validLocalPhoto(item.path));
const missing = sources.filter(item => !validLocalPhoto(item.path));
return { ready, missing };
}
async function main() {
const sources = readSources();
const initial = summarize(sources);
console.log(`Catalog photos: ${initial.ready.length}/${sources.length} local.`);
if (CHECK_ONLY) {
if (initial.missing.length) {
console.log(`Missing: ${initial.missing.length}.`);
process.exitCode = 2;
} else {
console.log('All catalog photos are local and valid.');
}
return;
}
const targets = FORCE ? sources : initial.missing;
if (!targets.length) {
console.log('All 113 catalog photos are already local. No network access is needed.');
return;
}
const byUrl = new Map();
for (const item of targets) {
if (!isAllowedUrl(item.url)) throw new Error(`Unsupported photo URL for ${item.path}`);
if (!byUrl.has(item.url)) byUrl.set(item.url, []);
byUrl.get(item.url).push(item);
}
const jobs = [...byUrl.entries()].map(([url, items]) => ({ url, items }));
console.log(`Preparing ${targets.length} missing file(s) from ${jobs.length} unique image(s).`);
console.log('This happens only once. After completion the app uses local catalog photos.');
let cursor = 0;
let finished = 0;
const failures = [];
async function worker() {
while (true) {
const pos = cursor++;
if (pos >= jobs.length) return;
const job = jobs[pos];
try {
const buf = await fetchWithRetry(job.url);
for (const item of job.items) atomicWrite(item.path, buf);
finished += job.items.length;
const current = initial.ready.length + finished;
console.log(`[${current}/${sources.length}] ${job.items.map(x => path.basename(x.path)).join(', ')}`);
} catch (error) {
failures.push({ job, error });
console.error(`FAILED: ${job.items.map(x => x.path).join(', ')} - ${error.message}`);
}
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, jobs.length) }, () => worker()));
const final = summarize(sources);
if (final.missing.length) {
console.error('');
console.error(`Catalog photo preparation is incomplete: ${final.ready.length}/${sources.length} ready.`);
console.error('Check the internet connection and run this file again. Existing downloaded photos are kept.');
console.error('Missing files:');
console.error(final.missing.map(x => path.basename(x.path)).join(', '));
process.exitCode = 2;
return;
}
console.log('');
console.log('Catalog photos ready: 113/113. The app can now use the catalog photos locally.');
}
main().catch(error => {
console.error('Catalog photo preparation error:', error && error.message ? error.message : String(error));
process.exitCode = 2;
});