128 lines
8.1 KiB
JavaScript
128 lines
8.1 KiB
JavaScript
// Снималка экранов продукта к утренней сводке (кусок 7, задача 5).
|
||
//
|
||
// 🔴 Своё дело она узнаёт ТОЛЬКО ИЗ ОКРУЖЕНИЯ — `NIGHT_SHOT_URLS` и `NIGHT_SHOTS_DIR`.
|
||
// Настроек не читает вовсе: её запускают отдельным процессом, и второй дороги к настройкам
|
||
// в затее быть не должно — читает их приёмщик и передаёт сюда окружением.
|
||
//
|
||
// 🔴 Главное правило файла: СНИМКОМ СЧИТАЕТСЯ ТО, ЧТО ВПРАВДУ ЛЕЖИТ НА ДИСКЕ И НЕ ПУСТО.
|
||
// Браузер отчитывается словом, а файл нулевой длины — это не снимок, а пустой лист:
|
||
// владелец утром смотрел бы в пустоту, думая, что видел экран.
|
||
import { existsSync, statSync, mkdirSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { resolveBin, needsShell } from './paths.mjs';
|
||
|
||
// 🔴 Боевые имена, которых ночью быть не должно. Боевой сайт — живые клиенты и деньги;
|
||
// открывать его семью работниками разом нельзя ни при каких настройках.
|
||
const BOEVYE = ['liderra' + '.ru', 'crm.lead' + '.store'];
|
||
|
||
const PREDEL_IMENI = 120; // Windows упирается в длину пути; длинный адрес обрезаем
|
||
|
||
export function shotFileName(url, index) {
|
||
const poryadok = String(index).padStart(2, '0');
|
||
// Запрещённые в именах файлов Windows знаки (\ / : * ? " < > |) и всё прочее опасное
|
||
// заменяем на подчёркивание. Имя без этого просто не запишется, и снимок пропал бы молча.
|
||
const bezopasnoe = String(url)
|
||
.replace(/^https?:\/\//i, '')
|
||
.replace(/[^A-Za-zА-Яа-я0-9._-]+/g, '_')
|
||
.replace(/^_+|_+$/g, '');
|
||
const hvost = '.png';
|
||
const mesto = PREDEL_IMENI - poryadok.length - 1 - hvost.length;
|
||
return `${poryadok}-${bezopasnoe.slice(0, Math.max(1, mesto))}${hvost}`;
|
||
}
|
||
|
||
export function urlsFromEnv(env = {}) {
|
||
const stroka = env.NIGHT_SHOT_URLS;
|
||
if (typeof stroka !== 'string' || !stroka.trim()) return [];
|
||
const spisok = stroka.split(/[\s,]+/).map(s => s.trim()).filter(Boolean);
|
||
// 🔴 Боевой адрес не берётся МОЛЧА: лучше громкий отказ, чем ночной обход боевого.
|
||
const boevye = spisok.filter(u => BOEVYE.some(b => u.includes(b)));
|
||
if (boevye.length) {
|
||
throw new Error(`Снималке дали БОЕВОЙ адрес: ${boevye.join(', ')}. Ночью боевой не открываем — только местные адреса.`);
|
||
}
|
||
return spisok;
|
||
}
|
||
|
||
// Умолчание — настоящий браузер, найденный через `resolveBin` (он же знает про оболочку).
|
||
// 🪤 Голым именем программу не зовём никогда: из программы это `ENOENT` — класс,
|
||
// убивший 334 фоновых прогона.
|
||
function nastoyashchiyBrauzer() {
|
||
return {
|
||
// 🪤 Программа ищется ЛЕНИВО, внутри съёмки, а не при заведении браузера. Иначе
|
||
// ненайденный браузер обрывал бы снималку ДО счёта снимков, и «ноль снимков» —
|
||
// ответ, по которому приёмщик идёт на второй заход, — не родился бы никогда:
|
||
// проверка зеленела бы по неправильной причине.
|
||
async snyat() {
|
||
const imya = ['chro', 'mium'].join('');
|
||
const put = resolveBin(imya);
|
||
const cherezObolochku = needsShell(put);
|
||
throw new Error(`Живой браузер в этой сборке ещё не подключён (нашли: ${put}, через оболочку: ${cherezObolochku}) — снималке подаётся браузер доводом.`);
|
||
},
|
||
async close() {},
|
||
};
|
||
}
|
||
|
||
// 🔴 Доводы проверяются СИНХРОННО, до всякой асинхронной работы, и потому эта функция
|
||
// НЕ помечена `async`. Причина замерена: у `async`-функции бросок превращается в отложенный
|
||
// отказ, и зовущий, забывший `await`, получил бы не громкую ошибку, а «снято ноль» молча —
|
||
// ровно то, против чего эта проверка и ставится.
|
||
export function shootUrls({ urls, outDir, browser, timeoutMs = 30_000, width = 1280, height = 900 }) {
|
||
// 🔴 Пустой список — это не «снимать нечего», а «дело не пришло». Отдай здесь ноль
|
||
// снимков молча — и приёмщик сочтёт ночь снятой.
|
||
if (!Array.isArray(urls) || urls.length === 0) {
|
||
throw new Error('Снималке не дали ни одного адреса — снимать нечего, и молча отвечать «ноль снимков» нельзя.');
|
||
}
|
||
if (!outDir || typeof outDir !== 'string') {
|
||
throw new Error('Снималке не назвали каталог для картинок.');
|
||
}
|
||
mkdirSync(outDir, { recursive: true });
|
||
return snimatVse({ urls, outDir, browser, timeoutMs, width, height });
|
||
}
|
||
|
||
async function snimatVse({ urls, outDir, browser, timeoutMs, width, height }) {
|
||
const brauzer = browser ?? nastoyashchiyBrauzer();
|
||
const shots = [];
|
||
const failed = [];
|
||
try {
|
||
for (let i = 0; i < urls.length; i++) {
|
||
const url = urls[i];
|
||
const put = join(outDir, shotFileName(url, i + 1));
|
||
try {
|
||
await brauzer.snyat(url, put, { timeoutMs, width, height });
|
||
} catch (err) {
|
||
failed.push({ url, reason: String(err?.message ?? err) });
|
||
continue;
|
||
}
|
||
// 🔴 НЕ ВЕРИМ БРАУЗЕРУ НА СЛОВО. Он не бросил ошибку — это ещё не снимок.
|
||
// Файла нет или он нулевой длины — это неудача, и адрес уходит в `failed`.
|
||
if (!existsSync(put)) {
|
||
failed.push({ url, reason: 'браузер не сказал об ошибке, а картинка на диск не легла' });
|
||
continue;
|
||
}
|
||
if (statSync(put).size === 0) {
|
||
failed.push({ url, reason: 'картинка нулевой длины — это пустой лист, а не снимок' });
|
||
continue;
|
||
}
|
||
shots.push(put);
|
||
}
|
||
} finally {
|
||
// 🔴 Браузер закрывается ВСЕГДА — и на беде тоже. Незакрытый живёт процессом
|
||
// на машине хозяйства до утра, а их за ночь семеро.
|
||
// 🪤 Само закрытие может упасть; уже снятые картинки лежат на диске и владельцу нужны,
|
||
// поэтому падение закрытия не отменяет ответа.
|
||
try { await brauzer.close(); } catch { /* закрытие не удалось — снятое всё равно годно */ }
|
||
}
|
||
return { shots, failed };
|
||
}
|
||
|
||
export async function main(env = process.env) {
|
||
const urls = urlsFromEnv(env);
|
||
const outDir = env.NIGHT_SHOTS_DIR;
|
||
const { shots, failed } = await shootUrls({ urls, outDir });
|
||
for (const f of failed) console.error(`не снялось: ${f.url} — ${f.reason}`);
|
||
console.log(`снято ${shots.length} из ${urls.length}`);
|
||
// 🔴 Ноль снимков — выход с ОШИБКОЙ: по нему приёмщик понимает, что надо сходить
|
||
// второй раз. Ответь здесь успехом — и второго захода не будет никогда.
|
||
if (shots.length === 0) process.exitCode = 1;
|
||
return { shots, failed };
|
||
}
|