670 lines
28 KiB
JavaScript
670 lines
28 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict';
|
||
|
||
const http = require('http');
|
||
const https = require('https');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
const crypto = require('crypto');
|
||
const childProcess = require('child_process');
|
||
|
||
const ROOT = __dirname;
|
||
const PORT = Number(process.env.PORT || 8787);
|
||
const HOST = process.env.HOST || '0.0.0.0';
|
||
const SECRET_FILE = path.join(ROOT, '.sun-sync-secret');
|
||
const DATA_FILE = path.join(ROOT, 'sun-sync-data.json');
|
||
const PHONE_LINK_FILE = path.join(ROOT, 'PHONE-LINK.txt');
|
||
const RELEASE_MANIFEST_FILE = path.join(ROOT, 'release-manifest.json');
|
||
|
||
function readReleaseVersion() {
|
||
try {
|
||
const data = JSON.parse(fs.readFileSync(RELEASE_MANIFEST_FILE, 'utf8'));
|
||
if (data && data.version) return String(data.version);
|
||
} catch (_) {}
|
||
return 'unknown';
|
||
}
|
||
|
||
const APP_VERSION = readReleaseVersion();
|
||
|
||
function getSecret() {
|
||
if (process.env.SUN_SYNC_TOKEN) return String(process.env.SUN_SYNC_TOKEN).trim();
|
||
try {
|
||
const existing = fs.readFileSync(SECRET_FILE, 'utf8').trim();
|
||
if (existing) return existing;
|
||
} catch (_) {}
|
||
const token = crypto.randomBytes(24).toString('hex');
|
||
fs.writeFileSync(SECRET_FILE, token, { mode: 0o600 });
|
||
return token;
|
||
}
|
||
|
||
const TOKEN = getSecret();
|
||
|
||
function emptyDb() {
|
||
return { version: 1, workspaces: {} };
|
||
}
|
||
|
||
function loadDb() {
|
||
try {
|
||
const parsed = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
|
||
if (parsed && typeof parsed === 'object' && parsed.workspaces && typeof parsed.workspaces === 'object') return parsed;
|
||
} catch (_) {}
|
||
return emptyDb();
|
||
}
|
||
|
||
let db = loadDb();
|
||
let saveTimer = null;
|
||
function saveDbNow() {
|
||
const tmp = DATA_FILE + '.tmp';
|
||
fs.writeFileSync(tmp, JSON.stringify(db));
|
||
fs.renameSync(tmp, DATA_FILE);
|
||
}
|
||
function scheduleSave() {
|
||
clearTimeout(saveTimer);
|
||
saveTimer = setTimeout(() => {
|
||
try { saveDbNow(); }
|
||
catch (e) { console.error('ERROR: cannot save sync data:', e.message); }
|
||
}, 40);
|
||
}
|
||
|
||
const mime = {
|
||
'.html': 'text/html; charset=utf-8',
|
||
'.js': 'text/javascript; charset=utf-8',
|
||
'.css': 'text/css; charset=utf-8',
|
||
'.json': 'application/json; charset=utf-8',
|
||
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
||
'.png': 'image/png',
|
||
'.jpg': 'image/jpeg',
|
||
'.jpeg': 'image/jpeg',
|
||
'.pdf': 'application/pdf',
|
||
'.svg': 'image/svg+xml',
|
||
'.txt': 'text/plain; charset=utf-8'
|
||
};
|
||
|
||
function send(res, code, body, type = 'application/json; charset=utf-8') {
|
||
res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' });
|
||
res.end(body);
|
||
}
|
||
|
||
function authorized(req) {
|
||
const h = String(req.headers['x-sun-token'] || '');
|
||
if (!h) return false;
|
||
const a = Buffer.from(h), b = Buffer.from(TOKEN);
|
||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||
}
|
||
|
||
function readBody(req, max = 80 * 1024 * 1024) {
|
||
return new Promise((resolve, reject) => {
|
||
let size = 0;
|
||
const chunks = [];
|
||
req.on('data', chunk => {
|
||
size += chunk.length;
|
||
if (size > max) {
|
||
reject(new Error('too large'));
|
||
req.destroy();
|
||
return;
|
||
}
|
||
chunks.push(chunk);
|
||
});
|
||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||
req.on('error', reject);
|
||
});
|
||
}
|
||
|
||
const SITE_CATALOG_PAGES = [
|
||
'https://solnce-keytering.ru/catalog',
|
||
'https://solnce-keytering.ru/catalog_black'
|
||
];
|
||
let siteCatalogCache = { at: 0, data: null };
|
||
const CATALOG_PHOTO_SOURCE_FILE = path.join(ROOT, 'catalog', 'photo-sources.json');
|
||
function catalogPhotoStatus() {
|
||
try {
|
||
const list = JSON.parse(fs.readFileSync(CATALOG_PHOTO_SOURCE_FILE, 'utf8'));
|
||
const items = Array.isArray(list) ? list : [];
|
||
let ready = 0;
|
||
for (const item of items) {
|
||
const rel = String(item && item.path || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
||
if (!rel) continue;
|
||
const file = path.resolve(ROOT, rel);
|
||
if (!file.startsWith(ROOT + path.sep)) continue;
|
||
try { const st = fs.statSync(file); if (st.isFile() && st.size > 1000) ready++; } catch (_) {}
|
||
}
|
||
return { ready, total: items.length, complete: items.length > 0 && ready === items.length };
|
||
} catch (_) {
|
||
return { ready: 0, total: 0, complete: false };
|
||
}
|
||
}
|
||
|
||
let catalogPhotoSourcesCache = null;
|
||
function catalogPhotoSources() {
|
||
if (catalogPhotoSourcesCache) return catalogPhotoSourcesCache;
|
||
try {
|
||
const list = JSON.parse(fs.readFileSync(CATALOG_PHOTO_SOURCE_FILE, 'utf8'));
|
||
catalogPhotoSourcesCache = Array.isArray(list) ? list : [];
|
||
} catch (_) { catalogPhotoSourcesCache = []; }
|
||
return catalogPhotoSourcesCache;
|
||
}
|
||
function imageType(buf) {
|
||
if (!Buffer.isBuffer(buf) || buf.length < 12) return '';
|
||
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg';
|
||
if (buf.subarray(0,8).equals(Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]))) return 'image/png';
|
||
if (buf.toString('ascii',0,4)==='RIFF' && buf.toString('ascii',8,12)==='WEBP') return 'image/webp';
|
||
return '';
|
||
}
|
||
function fetchCatalogPhotoBuffer(target, redirects = 0, timeoutMs = 5000) {
|
||
return new Promise((resolve, reject) => {
|
||
if (redirects > 4) return reject(new Error('too many redirects'));
|
||
let url; try { url = new URL(target); } catch (e) { return reject(e); }
|
||
const allowed = new Set(['static.tildacdn.com','static3.tildacdn.com','static.tildacdn.net']);
|
||
if (url.protocol !== 'https:' || !allowed.has(url.hostname)) return reject(new Error('photo host is not allowed'));
|
||
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 SunCateringPhotoProxy/17.5.17',
|
||
'Accept':'image/avif,image/webp,image/apng,image/jpeg,image/*,*/*;q=0.8',
|
||
'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();return fetchCatalogPhotoBuffer(next,redirects+1,timeoutMs).then(resolve,reject);}
|
||
if(res.statusCode<200||res.statusCode>=300){res.resume();return reject(new Error('remote HTTP '+res.statusCode));}
|
||
const chunks=[];let size=0;const max=20*1024*1024;
|
||
res.on('data',chunk=>{size+=chunk.length;if(size>max){req.destroy(new Error('photo too large'));return;}chunks.push(chunk)});
|
||
res.on('end',()=>{const buf=Buffer.concat(chunks),type=imageType(buf);if(!type||buf.length<1000)return reject(new Error('invalid image'));resolve({buf,type})});
|
||
res.on('error',reject);
|
||
});
|
||
req.on('timeout',()=>req.destroy(new Error('timeout')));req.on('error',reject);
|
||
});
|
||
}
|
||
async function catalogPhotoApi(req,res,url){
|
||
const index=Number(url.searchParams.get('i')||0);
|
||
const sources=catalogPhotoSources();
|
||
if(!Number.isInteger(index)||index<1||index>sources.length)return send(res,400,JSON.stringify({ok:false,error:'bad photo index'}));
|
||
const item=sources[index-1]||{},rel=String(item.path||'').replace(/\\/g,'/').replace(/^\/+/,''),remote=String(item.url||'');
|
||
const file=path.resolve(ROOT,rel);
|
||
if(rel&&file.startsWith(ROOT+path.sep)){
|
||
try{const buf=fs.readFileSync(file),type=imageType(buf);if(type&&buf.length>1000){res.writeHead(200,{'Content-Type':type,'Content-Length':buf.length,'Cache-Control':'public, max-age=86400'});return res.end(buf)}}catch(_){}
|
||
}
|
||
try{
|
||
const out=await fetchCatalogPhotoBuffer(remote);
|
||
if(out.type==='image/jpeg'&&rel&&file.startsWith(ROOT+path.sep)){try{fs.mkdirSync(path.dirname(file),{recursive:true});const tmp=file+'.tmp-'+process.pid+'-'+Date.now();fs.writeFileSync(tmp,out.buf);fs.renameSync(tmp,file)}catch(_){} }
|
||
res.writeHead(200,{'Content-Type':out.type,'Content-Length':out.buf.length,'Cache-Control':'public, max-age=86400'});res.end(out.buf);
|
||
}catch(error){send(res,502,JSON.stringify({ok:false,error:String(error&&error.message||error)}));}
|
||
}
|
||
|
||
function fetchRemoteText(target, redirects = 0, timeoutMs = 15000) {
|
||
return new Promise((resolve, reject) => {
|
||
if (redirects > 5) return reject(new Error('too many redirects'));
|
||
let url;
|
||
try { url = new URL(target); } catch (e) { return reject(e); }
|
||
if (url.protocol !== 'https:') return reject(new Error('https only'));
|
||
const allowed = new Set(['solnce-keytering.ru','www.solnce-keytering.ru','store.tildacdn.com','store2.tildacdn.com']);
|
||
if (!allowed.has(url.hostname)) return reject(new Error('remote host is not allowed'));
|
||
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 SunCateringCatalogSync/17.5.17',
|
||
'Accept': 'text/html,application/json,text/plain;q=0.9,*/*;q=0.5',
|
||
'Accept-Language': 'ru-RU,ru;q=0.9,en;q=0.5',
|
||
'Accept-Encoding': 'identity'
|
||
},
|
||
timeout: timeoutMs
|
||
}, res => {
|
||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||
res.resume();
|
||
const next = new URL(res.headers.location, url).toString();
|
||
return fetchRemoteText(next, redirects + 1, timeoutMs).then(resolve, reject);
|
||
}
|
||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||
res.resume();
|
||
return reject(new Error(`remote HTTP ${res.statusCode}`));
|
||
}
|
||
const chunks = []; let size = 0; const max = 12 * 1024 * 1024;
|
||
res.on('data', chunk => {
|
||
size += chunk.length;
|
||
if (size > max) { req.destroy(new Error('remote response too large')); return; }
|
||
chunks.push(chunk);
|
||
});
|
||
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||
res.on('error', reject);
|
||
});
|
||
req.on('timeout', () => req.destroy(new Error('remote timeout')));
|
||
req.on('error', reject);
|
||
});
|
||
}
|
||
|
||
function balancedObject(text, start) {
|
||
let depth = 0, quote = null, esc = false;
|
||
for (let i = start; i < text.length; i++) {
|
||
const c = text[i];
|
||
if (quote) {
|
||
if (esc) esc = false;
|
||
else if (c === '\\') esc = true;
|
||
else if (c === quote) quote = null;
|
||
continue;
|
||
}
|
||
if (c === '"' || c === "'") quote = c;
|
||
else if (c === '{') depth++;
|
||
else if (c === '}' && --depth === 0) return text.slice(start, i + 1);
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function discoverStoreConfigs(html) {
|
||
const out = [], seen = new Set();
|
||
const re = /t_store_init\(\s*['"]?(\d+)['"]?\s*,/g;
|
||
let m;
|
||
while ((m = re.exec(html))) {
|
||
const recid = m[1];
|
||
const brace = html.indexOf('{', re.lastIndex);
|
||
if (brace < 0 || brace - re.lastIndex > 5000) continue;
|
||
const raw = balancedObject(html, brace);
|
||
if (!raw) continue;
|
||
const sm = raw.match(/["']?storepart["']?\s*:\s*(?:["']([^"']+)["']|([\w-]+))/i);
|
||
const storepart = (sm && (sm[1] || sm[2]) || '').trim();
|
||
if (!storepart) continue;
|
||
const key = recid + '|' + storepart;
|
||
if (!seen.has(key)) { seen.add(key); out.push({ recid, storepart }); }
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function extractProductUids(html) {
|
||
const set = new Set(); let m;
|
||
const patterns = [
|
||
/data-product-uid=["'](\d+)["']/gi,
|
||
/\/tproduct\/(\d+)-/gi,
|
||
/["']productuid["']\s*:\s*["']?(\d+)/gi
|
||
];
|
||
for (const re of patterns) while ((m = re.exec(html))) set.add(m[1]);
|
||
return [...set];
|
||
}
|
||
|
||
function parseStoreJson(raw) {
|
||
const text = String(raw || '').trim();
|
||
if (!text) return null;
|
||
try { return JSON.parse(text); } catch (_) {}
|
||
const a = text.indexOf('{'), b = text.lastIndexOf('}');
|
||
if (a >= 0 && b > a) { try { return JSON.parse(text.slice(a, b + 1)); } catch (_) {} }
|
||
return null;
|
||
}
|
||
|
||
async function fetchProductsForConfig(config) {
|
||
const products = [];
|
||
let slice = '';
|
||
for (let page = 0; page < 8; page++) {
|
||
const query = new URLSearchParams({ storepartuid: config.storepart, recid: config.recid, c: String(Date.now()), getparts: 'true', getoptions: 'true', size: '100' });
|
||
if (slice) query.set('slice', String(slice));
|
||
let parsed = null, lastError = null;
|
||
for (const host of ['store.tildacdn.com','store2.tildacdn.com']) {
|
||
try { parsed = parseStoreJson(await fetchRemoteText(`https://${host}/api/getproductslist/?${query}`)); if (parsed) break; }
|
||
catch (e) { lastError = e; }
|
||
}
|
||
if (!parsed) throw lastError || new Error('cannot parse Tilda product list');
|
||
const list = Array.isArray(parsed.products) ? parsed.products : [];
|
||
products.push(...list);
|
||
if (!parsed.nextslice || !list.length) break;
|
||
slice = parsed.nextslice;
|
||
}
|
||
return products;
|
||
}
|
||
|
||
async function fetchProductsByUids(uids) {
|
||
const out = [];
|
||
for (const uid of uids.slice(0, 160)) {
|
||
const query = new URLSearchParams({ productsuid: uid, c: String(Date.now()) });
|
||
let parsed = null;
|
||
for (const host of ['store.tildacdn.com','store2.tildacdn.com']) {
|
||
try { parsed = parseStoreJson(await fetchRemoteText(`https://${host}/api/getproductsbyuid/?${query}`)); if (parsed) break; }
|
||
catch (_) {}
|
||
}
|
||
if (!parsed) continue;
|
||
if (Array.isArray(parsed.products)) out.push(...parsed.products);
|
||
else if (Array.isArray(parsed)) out.push(...parsed);
|
||
else if (parsed.product) out.push(parsed.product);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function firstProductFromPayload(parsed) {
|
||
if (!parsed) return null;
|
||
if (parsed.product && typeof parsed.product === 'object') return parsed.product;
|
||
if (Array.isArray(parsed.products) && parsed.products.length) return parsed.products[0];
|
||
if (Array.isArray(parsed) && parsed.length) return parsed[0];
|
||
if (typeof parsed === 'object' && (parsed.uid || parsed.id || parsed.title || parsed.name)) return parsed;
|
||
return null;
|
||
}
|
||
|
||
async function fetchOneProduct(config, uid) {
|
||
if (!config || !uid) return null;
|
||
const query = new URLSearchParams({
|
||
storepartuid: config.storepart,
|
||
recid: config.recid,
|
||
productuid: String(uid),
|
||
c: String(Date.now())
|
||
});
|
||
let lastError = null;
|
||
for (const host of ['store.tildacdn.com','store2.tildacdn.com']) {
|
||
try {
|
||
const parsed = parseStoreJson(await fetchRemoteText(`https://${host}/api/getproduct/?${query}`, 0, 8000));
|
||
const product = firstProductFromPayload(parsed);
|
||
if (product) return product;
|
||
} catch (e) { lastError = e; }
|
||
}
|
||
if (lastError) throw lastError;
|
||
return null;
|
||
}
|
||
|
||
async function enrichProductsWithDetails(config, products, forceAll = false) {
|
||
const list = (Array.isArray(products) ? products : []).slice(0, 160);
|
||
const out = list.slice();
|
||
const indexes = [];
|
||
for (let i = 0; i < list.length; i++) {
|
||
const summary = list[i];
|
||
const uid = String(summary && (summary.uid || summary.id) || '');
|
||
if (uid && (forceAll || !extractComposition(summary || {}).length)) indexes.push(i);
|
||
}
|
||
let cursor = 0;
|
||
const worker = async () => {
|
||
while (cursor < indexes.length) {
|
||
const index = indexes[cursor++];
|
||
const summary = list[index];
|
||
const uid = String(summary && (summary.uid || summary.id) || '');
|
||
try {
|
||
const detail = await fetchOneProduct(config, uid);
|
||
if (detail) out[index] = {...summary, ...detail};
|
||
} catch (_) {}
|
||
}
|
||
};
|
||
const count = Math.min(2, indexes.length);
|
||
await Promise.all(Array.from({length: count}, () => worker()));
|
||
return out;
|
||
}
|
||
|
||
function decodeEntities(text) {
|
||
return String(text || '')
|
||
.replace(/ | /gi, ' ')
|
||
.replace(/&/gi, '&').replace(/"/gi, '"').replace(/'|'/gi, "'")
|
||
.replace(/</gi, '<').replace(/>/gi, '>')
|
||
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n) || 32))
|
||
.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16) || 32));
|
||
}
|
||
|
||
function htmlLines(value) {
|
||
let s = String(value || '');
|
||
s = s.replace(/<br\s*\/?\s*>/gi, '\n').replace(/<\/(?:p|div|li|ul|ol|h\d)>/gi, '\n').replace(/<li[^>]*>/gi, '').replace(/<[^>]+>/g, ' ');
|
||
s = decodeEntities(s).replace(/\r/g, '');
|
||
return s.split('\n').map(x => x.replace(/\s+/g, ' ').trim()).filter(Boolean);
|
||
}
|
||
|
||
function cleanCompositionLines(lines) {
|
||
const out = [];
|
||
for (let line of lines) {
|
||
line = String(line || '').replace(/^[-–—•·*]+\s*/, '').trim();
|
||
if (!line) continue;
|
||
if (/^(?:состав(?:\s+(?:бокса|набора|сета))?|купить|подробнее)\s*:?[\s]*$/i.test(line)) continue;
|
||
if (/^(?:общий\s+)?вес(?:\s|:|$)|^(?:на\s+)?кол-?во\s+(?:персон|гостей)(?:\s|:|$)|^цена(?:\s|:|$)|^артикул(?:\s|:|$)|^доставка(?:\s|:|$)/i.test(line)) break;
|
||
if (/\b(?:персон|гост(?:ей|я))\b/i.test(line) && !/(?:шт\.?|пор\.?|г\b|гр\.?)/i.test(line)) continue;
|
||
if (/^\d[\d\s]*\s*(?:₽|р\.?|руб\.?)$/i.test(line)) continue;
|
||
out.push(line);
|
||
}
|
||
return [...new Set(out)].slice(0, 50);
|
||
}
|
||
|
||
function collectProductTextValues(value, depth = 0, out = [], seen = new Set()) {
|
||
if (value == null || depth > 5) return out;
|
||
if (typeof value === 'string') { if (value.trim()) out.push(value); return out; }
|
||
if (typeof value !== 'object') return out;
|
||
if (seen.has(value)) return out;
|
||
seen.add(value);
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) collectProductTextValues(item, depth + 1, out, seen);
|
||
return out;
|
||
}
|
||
for (const [key, item] of Object.entries(value)) {
|
||
if (/^(?:photo|photos|image|images|gallery|url|sku|uid|id|price|price_old|priceold|quantity|title|name)$/i.test(key)) continue;
|
||
collectProductTextValues(item, depth + 1, out, seen);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function extractComposition(product) {
|
||
const priority = [product && product.text, product && product.descr, product && product.description, product && product.fulltext];
|
||
const fields = [];
|
||
const seen = new Set();
|
||
for (const value of [...priority, ...collectProductTextValues(product || {})]) {
|
||
const field = String(value || '').trim();
|
||
if (!field || seen.has(field)) continue;
|
||
seen.add(field); fields.push(field);
|
||
}
|
||
let fallback = [];
|
||
for (const field of fields) {
|
||
let raw = String(field).replace(/(\u0421\u043e\u0441\u0442\u0430\u0432(?:\s+(?:\u0431\u043e\u043a\u0441\u0430|\u043d\u0430\u0431\u043e\u0440\u0430|\u0441\u0435\u0442\u0430))?\s*:)/ig, '\n$1\n');
|
||
const lines = htmlLines(raw);
|
||
const start = lines.findIndex(x => /^\u0441\u043e\u0441\u0442\u0430\u0432(?:\s+(?:\u0431\u043e\u043a\u0441\u0430|\u043d\u0430\u0431\u043e\u0440\u0430|\u0441\u0435\u0442\u0430))?\s*:?$/i.test(x));
|
||
if (start >= 0) {
|
||
const found = cleanCompositionLines(lines.slice(start + 1));
|
||
if (found.length) return found;
|
||
}
|
||
const plausible = cleanCompositionLines(lines).filter(x => /\b(?:\u0448\u0442\.?|\u043f\u043e\u0440\.?|\u043a\u0443\u0441\.?|\u0433\b|\u0433\u0440\.?)\b/i.test(x));
|
||
if (plausible.length > fallback.length) fallback = plausible;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function normalizeSiteProduct(product) {
|
||
const title = decodeEntities(String(product && (product.title || product.name) || '')).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||
const composition = extractComposition(product || {});
|
||
return { uid: String(product && (product.uid || product.id) || ''), title, composition, url: String(product && product.url || '') };
|
||
}
|
||
|
||
async function loadSiteCatalogCompositions() {
|
||
const all = [], errors = [], configs = [];
|
||
let premiumCompositionCount = 0;
|
||
for (const pageUrl of SITE_CATALOG_PAGES) {
|
||
try {
|
||
const html = await fetchRemoteText(pageUrl);
|
||
const found = discoverStoreConfigs(html);
|
||
configs.push(...found.map(x => ({...x, pageUrl})));
|
||
if (found.length) {
|
||
for (const config of found) {
|
||
try {
|
||
const summaries = await fetchProductsForConfig(config);
|
||
const isPremiumPage = /\/catalog_black(?:$|[?#])/i.test(pageUrl);
|
||
const detailed = await enrichProductsWithDetails(config, summaries, isPremiumPage);
|
||
if (isPremiumPage) premiumCompositionCount += detailed.filter(item => extractComposition(item || {}).length).length;
|
||
all.push(...detailed);
|
||
} catch (e) { errors.push(`${pageUrl}: ${e.message}`); }
|
||
}
|
||
} else {
|
||
const uids = extractProductUids(html);
|
||
if (uids.length) all.push(...await fetchProductsByUids(uids));
|
||
else errors.push(`${pageUrl}: Tilda store config not found`);
|
||
}
|
||
} catch (e) { errors.push(`${pageUrl}: ${e.message}`); }
|
||
}
|
||
const byKey = new Map();
|
||
for (const raw of all) {
|
||
const p = normalizeSiteProduct(raw);
|
||
if (!p.title || !p.composition.length) continue;
|
||
const key = p.uid || p.title.toLowerCase();
|
||
if (!byKey.has(key) || byKey.get(key).composition.length < p.composition.length) byKey.set(key, p);
|
||
}
|
||
return { source: 'solnce-keytering.ru', fetchedAt: new Date().toISOString(), configs: configs.length, premiumCompositionCount, products: [...byKey.values()], errors };
|
||
}
|
||
|
||
async function catalogCompositionsApi(req, res) {
|
||
const now = Date.now();
|
||
const requestUrl = new URL(req.url, 'http://localhost');
|
||
const force = requestUrl.searchParams.get('force') === '1';
|
||
if (!force && siteCatalogCache.data && now - siteCatalogCache.at < 30 * 60 * 1000) {
|
||
return send(res, 200, JSON.stringify({...siteCatalogCache.data, cached: true}));
|
||
}
|
||
try {
|
||
const data = await loadSiteCatalogCompositions();
|
||
if (!data.products.length) return send(res, 502, JSON.stringify({ error: 'site catalog compositions were not received', details: data.errors.slice(0, 6) }));
|
||
if (Number(data.premiumCompositionCount || 0) > 0) siteCatalogCache = { at: now, data };
|
||
return send(res, 200, JSON.stringify({...data, cached: false}));
|
||
} catch (e) {
|
||
return send(res, 502, JSON.stringify({ error: e.message || String(e) }));
|
||
}
|
||
}
|
||
|
||
|
||
function api(req, res, url) {
|
||
if (!authorized(req)) return send(res, 401, JSON.stringify({ error: 'unauthorized' }));
|
||
|
||
if (req.method === 'GET') {
|
||
const workspace = String(url.searchParams.get('workspace') || 'main').slice(0, 120);
|
||
const source = db.workspaces[workspace] || {};
|
||
const entries = {};
|
||
for (const [key, item] of Object.entries(source)) {
|
||
if (!item) continue;
|
||
entries[key] = { value: String(item.value ?? ''), updatedAt: Number(item.updatedAt || 0) };
|
||
}
|
||
return send(res, 200, JSON.stringify({ workspace, entries, serverTime: Date.now() }));
|
||
}
|
||
|
||
if (req.method === 'POST') {
|
||
return readBody(req).then(raw => {
|
||
let data;
|
||
try { data = JSON.parse(raw || '{}'); }
|
||
catch (_) { return send(res, 400, JSON.stringify({ error: 'bad json' })); }
|
||
|
||
const workspace = String(data.workspace || 'main').slice(0, 120);
|
||
const changes = data.changes && typeof data.changes === 'object' ? data.changes : {};
|
||
const target = db.workspaces[workspace] || (db.workspaces[workspace] = {});
|
||
let count = 0;
|
||
|
||
for (const [key, item] of Object.entries(changes)) {
|
||
if (!key || key.length > 240 || !item) continue;
|
||
const updatedAt = Number(item.updatedAt || Date.now());
|
||
const oldAt = Number(target[key]?.updatedAt || 0);
|
||
if (updatedAt >= oldAt) {
|
||
target[key] = { value: String(item.value ?? ''), updatedAt };
|
||
count++;
|
||
}
|
||
}
|
||
scheduleSave();
|
||
return send(res, 200, JSON.stringify({ ok: true, count, serverTime: Date.now() }));
|
||
}).catch(e => send(res, 500, JSON.stringify({ error: e.message })));
|
||
}
|
||
|
||
return send(res, 405, JSON.stringify({ error: 'method not allowed' }));
|
||
}
|
||
|
||
function staticFile(req, res, url) {
|
||
let pathname = decodeURIComponent(url.pathname);
|
||
if (pathname === '/' || pathname === '') pathname = '/index.html';
|
||
const rel = pathname.replace(/^\/+/, '');
|
||
const base = path.basename(rel);
|
||
const protectedNames = new Set(['server.js', 'prepare-catalog-photos.js', 'sun-sync-data.json', 'sun-sync-data.json.tmp', 'PHONE-LINK.txt']);
|
||
if (base.startsWith('.') || /\.(?:sqlite|db)$/i.test(base) || protectedNames.has(base)) {
|
||
return send(res, 404, 'Not found', 'text/plain; charset=utf-8');
|
||
}
|
||
|
||
const file = path.resolve(ROOT, rel);
|
||
if (!file.startsWith(ROOT + path.sep) && file !== path.join(ROOT, 'index.html')) {
|
||
return send(res, 403, 'Forbidden', 'text/plain; charset=utf-8');
|
||
}
|
||
|
||
fs.stat(file, (err, st) => {
|
||
if (err || !st.isFile()) return send(res, 404, 'Not found', 'text/plain; charset=utf-8');
|
||
const ext = path.extname(file).toLowerCase();
|
||
res.writeHead(200, {
|
||
'Content-Type': mime[ext] || 'application/octet-stream',
|
||
'Cache-Control': ['.html', '.js', '.css', '.webmanifest'].includes(ext) ? 'no-cache' : 'public, max-age=86400'
|
||
});
|
||
fs.createReadStream(file).pipe(res);
|
||
});
|
||
}
|
||
|
||
function isPrivateIPv4(ip) {
|
||
const p = ip.split('.').map(Number);
|
||
return p.length === 4 && (p[0] === 10 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168));
|
||
}
|
||
|
||
function getLanAddresses() {
|
||
const out = [];
|
||
for (const list of Object.values(os.networkInterfaces())) {
|
||
for (const n of list || []) {
|
||
if (n.family !== 'IPv4' || n.internal) continue;
|
||
if (isPrivateIPv4(n.address)) out.push(n.address);
|
||
}
|
||
}
|
||
return [...new Set(out)];
|
||
}
|
||
|
||
function linkFor(host) {
|
||
return `http://${host}:${PORT}/`;
|
||
}
|
||
|
||
function tryOpenBrowser(url) {
|
||
try {
|
||
if (process.platform === 'win32') {
|
||
childProcess.spawn('cmd.exe', ['/d', '/s', '/c', 'start', '""', url], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
const server = http.createServer((req, res) => {
|
||
const url = new URL(req.url, 'http://localhost');
|
||
if (url.pathname === '/healthz') return send(res, 200, JSON.stringify({ok:true,app:'sun-catering',mode:'local-test',version:APP_VERSION,catalogPhotos:catalogPhotoStatus(),time:new Date().toISOString()}));
|
||
if (url.pathname === '/api/catalog-photo' && req.method === 'GET') return catalogPhotoApi(req, res, url);
|
||
if (url.pathname === '/api/catalog-compositions' && req.method === 'GET') return catalogCompositionsApi(req, res);
|
||
if (url.pathname === '/api/sync') return api(req, res, url);
|
||
return staticFile(req, res, url);
|
||
});
|
||
|
||
server.on('error', err => {
|
||
console.error('\n============================================================');
|
||
console.error('SERVER START ERROR');
|
||
console.error(err && err.message ? err.message : String(err));
|
||
if (err && err.code === 'EADDRINUSE') console.error(`Port ${PORT} is already in use. Close another server window and try again.`);
|
||
console.error('============================================================\n');
|
||
process.exitCode = 1;
|
||
});
|
||
|
||
server.listen(PORT, HOST, () => {
|
||
// Catalog photos prefer local files; missing originals are fetched and cached on demand without blocking startup.
|
||
const computer = linkFor('localhost');
|
||
const lan = getLanAddresses().map(linkFor);
|
||
const lines = [
|
||
'SUN CATERING - MOBILE SERVER',
|
||
'',
|
||
'OPEN ON THIS COMPUTER:',
|
||
computer,
|
||
'',
|
||
'OPEN ON IPHONE / PHONE:',
|
||
...(lan.length ? lan : ['No local Wi-Fi address found. Connect the computer to Wi-Fi and restart this file.']),
|
||
'',
|
||
'The phone and computer must be connected to the same Wi-Fi network.',
|
||
'Keep this black window open while using the shared database.',
|
||
'',
|
||
'The same phone link is saved in this folder as PHONE-LINK.txt.'
|
||
];
|
||
|
||
const text = lines.join('\r\n') + '\r\n';
|
||
try { fs.writeFileSync(PHONE_LINK_FILE, text, 'utf8'); }
|
||
catch (e) { console.error('Cannot write PHONE-LINK.txt:', e.message); }
|
||
|
||
console.log('\n============================================================');
|
||
console.log('SUN CATERING - MOBILE SERVER IS RUNNING');
|
||
console.log('============================================================\n');
|
||
console.log('OPEN ON THIS COMPUTER:');
|
||
console.log(' ' + computer + '\n');
|
||
console.log('OPEN ON IPHONE / PHONE:');
|
||
if (lan.length) lan.forEach(x => console.log(' ' + x));
|
||
else console.log(' No local Wi-Fi address found. Connect the computer to Wi-Fi and restart.');
|
||
console.log('\nThe phone link is also saved in:');
|
||
console.log(' ' + PHONE_LINK_FILE);
|
||
console.log('\nKeep this window open while using the shared database.\n');
|
||
|
||
tryOpenBrowser(computer);
|
||
});
|
||
|
||
function shutdown() {
|
||
try { if (saveTimer) clearTimeout(saveTimer); saveDbNow(); } catch (_) {}
|
||
server.close(() => process.exit(0));
|
||
setTimeout(() => process.exit(0), 500).unref();
|
||
}
|
||
process.on('SIGINT', shutdown);
|
||
process.on('SIGTERM', shutdown);
|