Files
portal/tools/night/shoot.mjs
T

128 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Снималка экранов продукта к утренней сводке (кусок 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 };
}