Загрузка данных
// ==UserScript==
// @name Fuel QR Helper MAX Web - reviewed
// @version 10.2-reviewed
// @match https://web.max.ru/*
// @match https://fuel.sevtech.org/*
// @run-at document-idle
// @grant none
// ==/UserScript==
(function () {
'use strict';
const DEFAULT_PLATES = [
'А777АА92',
'А777АА82'
];
// Prefer stable code first, then id/title fallback.
const FUEL_PRIORITY = [
{ codes: ['a92'], id: 3, title: /92/ },
{ codes: ['a95'], id: 4, title: /95(?!.*\+|.*plus|.*ultra|.*ультра)/i },
{ codes: ['a95_plus', 'a95_ultra'], id: 5, title: /95.*(\+|plus|ultra|ультра)|(?:\+|plus|ultra|ультра).*95/i }
];
const SESSION_KEY = 'fuelQrSessionUntil';
const PLATES_KEY = 'fuelQrHelperPlates';
const HARD_MODE_KEY = 'fuelQrHelperHardMode';
const AUTH_MODE = new URLSearchParams(window.location.search).get('mode') || 'max-mini-app';
const DEFAULT_POLL_MS = 1000;
const HARD_POLL_MS = 250;
const HARD_WINDOW_MS = 30000;
const CREATE_FAIL_COOLDOWN_MS = 250;
const MAX_POLL_MS = 10000;
const BACKOFF = 1.7;
const DEBUG = true;
let stopped = false;
let paused = false;
let busy = false;
let index = 0;
let timer = null;
let pollMs = DEFAULT_POLL_MS;
let sessionProbeInFlight = false;
let lastSessionProbeAt = 0;
let lastStatusText = '';
let lastStatusLogAt = 0;
let hardModeEnabled = loadBool(HARD_MODE_KEY, true);
let hardModeUntil = 0;
let lastHardAlertAt = 0;
let fuelAttemptIndex = 0;
const DONE = new Set();
const SKIPPED = new Set();
let PLATES = loadPlates();
if (location.hostname === 'web.max.ru') {
console.log('[FUEL QR] MAX Web открыт. Рабочая часть запускается внутри iframe fuel.sevtech.org.');
return;
}
if (location.hostname !== 'fuel.sevtech.org') return;
class ApiError extends Error {
constructor(message, data, status) {
super(message);
this.name = 'ApiError';
this.data = data || {};
this.status = status || 0;
this.path = '';
this.code = this.data.error_code || this.data.errorCode || this.data.code || '';
this.sessionExpired = this.status === 401 || this.code === 'session_expired';
}
}
function log(...args) {
console.log('%c[FUEL QR]', 'background:#063;color:#fff;padding:2px 6px;border-radius:4px', ...args);
}
function debug(...args) {
if (!DEBUG) return;
console.debug('%c[FUEL QR DEBUG]', 'background:#123;color:#9ff;padding:2px 6px;border-radius:4px', ...args);
}
function warn(...args) {
console.warn('%c[FUEL QR WARN]', 'background:#421;color:#ffd;padding:2px 6px;border-radius:4px', ...args);
}
function safeJson(value) {
try {
return JSON.parse(JSON.stringify(value, (key, val) => {
if (/hash|init_data|phone|token|contact|photo|session_public_id/i.test(key)) return '[redacted]';
return val;
}));
} catch (e) {
return value;
}
}
function sessionLeft() {
const until = parseInt(localStorage.getItem(SESSION_KEY) || '0', 10);
return Math.max(0, Math.floor((until - Date.now()) / 1000));
}
function hasSession() {
return sessionLeft() > 5;
}
function loadPlates() {
try {
const saved = JSON.parse(localStorage.getItem(PLATES_KEY) || '[]');
if (Array.isArray(saved) && saved.length) {
return saved.map(normalizePlate).filter(x => x.length >= 7);
}
} catch (e) {
// Ignore broken saved settings.
}
return DEFAULT_PLATES.map(normalizePlate).filter(x => x.length >= 7);
}
function savePlates(values) {
const next = Array.from(new Set(values.map(normalizePlate).filter(x => x.length >= 7)));
if (!next.length) return false;
PLATES = next;
index = 0;
try {
localStorage.setItem(PLATES_KEY, JSON.stringify(PLATES));
} catch (e) {
// localStorage can be unavailable in embedded webviews.
}
return true;
}
function loadBool(key, defaultValue) {
try {
const saved = localStorage.getItem(key);
if (saved === '1') return true;
if (saved === '0') return false;
} catch (e) {
// localStorage can be unavailable in embedded webviews.
}
return defaultValue;
}
function saveBool(key, value) {
try {
localStorage.setItem(key, value ? '1' : '0');
} catch (e) {
// localStorage can be unavailable in embedded webviews.
}
}
function markSessionActive(ttl) {
const seconds = parseInt(ttl, 10);
if (!Number.isFinite(seconds) || seconds <= 0) return;
try {
localStorage.setItem(SESSION_KEY, String(Date.now() + seconds * 1000));
} catch (e) {
// localStorage can be unavailable in embedded webviews.
}
}
async function ensureSession() {
if (hasSession()) return true;
if (sessionProbeInFlight) return false;
if (Date.now() - lastSessionProbeAt < 3000) return false;
sessionProbeInFlight = true;
lastSessionProbeAt = Date.now();
try {
const data = await api('/session/status', null, 5000);
if (data && data.auth_mode && data.auth_mode !== AUTH_MODE) {
warn('session/status:auth-mode-mismatch', { server: data.auth_mode, client: AUTH_MODE });
return false;
}
if (data && data.active !== false && data.ttl) {
debug('session/status:active', { authMode: data.auth_mode || '', ttl: data.ttl, sessionPublicId: '[redacted]' });
markSessionActive(data.ttl);
return hasSession();
}
} catch (e) {
if (!e.sessionExpired) {
log('session/status probe failed:', e.message || e);
}
} finally {
sessionProbeInFlight = false;
}
return false;
}
function normalizePlate(value) {
const latin = 'ABEKMHOPCTYX';
const cyr = 'АВЕКМНОРСТУХ';
return String(value || '')
.trim()
.toUpperCase()
.split('')
.map(ch => {
const i = latin.indexOf(ch);
return i >= 0 ? cyr[i] : ch;
})
.join('')
.replace(/[^0-9A-ZА-ЯЁ]/g, '');
}
function isStandardPlate(value) {
return /^[АВЕКМНОРСТУХ][0-9]{3}[АВЕКМНОРСТУХ]{2}[0-9]{2,3}$/.test(value || '');
}
function plateFormatConfirmed(value) {
return !isStandardPlate(value);
}
function getUiPlate() {
const input = document.querySelector('[data-plate-input]');
return input && input.value ? normalizePlate(input.value) : '';
}
function currentPlate() {
const uiPlate = getUiPlate();
if (uiPlate && uiPlate.length >= 7) return uiPlate;
return normalizePlate(PLATES[index % PLATES.length]);
}
function hasTargetsLeft() {
const uiPlate = getUiPlate();
if (uiPlate && uiPlate.length >= 7) {
return !DONE.has(uiPlate) && !SKIPPED.has(uiPlate);
}
return PLATES.some(x => {
const p = normalizePlate(x);
return !DONE.has(p) && !SKIPPED.has(p);
});
}
function moveToNextTarget() {
if (getUiPlate()) return currentPlate();
for (let i = 0; i < PLATES.length; i++) {
index = (index + 1) % PLATES.length;
const p = currentPlate();
if (!DONE.has(p) && !SKIPPED.has(p)) return p;
}
return currentPlate();
}
function panel() {
let p = document.getElementById('fuelQrPanel');
if (p) return p;
p = document.createElement('div');
p.id = 'fuelQrPanel';
p.style.cssText = [
'position:fixed',
'top:15px',
'right:15px',
'z-index:2147483647',
'background:#061b13',
'color:#d9ffe8',
'padding:14px',
'font:12px monospace',
'border:2px solid #00ff66',
'border-radius:10px',
'width:390px',
'max-width:calc(100vw - 30px)',
'white-space:pre-wrap',
'box-shadow:0 0 20px rgba(0,255,102,.35)'
].join(';');
const statusBox = document.createElement('div');
statusBox.id = 'fuelQrStatus';
statusBox.textContent = 'Инициализация...';
p.appendChild(statusBox);
const plateBox = document.createElement('div');
plateBox.style.cssText = 'margin-top:10px;';
plateBox.innerHTML = [
'<div style="margin-bottom:4px;color:#9fffc0;">Номера, по одному в строке</div>',
'<textarea id="fuelPlateList" rows="3" spellcheck="false" style="box-sizing:border-box;width:100%;resize:vertical;background:#001a0b;color:#d9ffe8;border:1px solid #00ff66;border-radius:4px;padding:6px;font:12px monospace;"></textarea>',
'<label style="display:flex;align-items:center;gap:6px;margin-top:8px;color:#9fffc0;">',
'<input id="fuelHardMode" type="checkbox">',
'<span>Хард-режим при появлении топлива</span>',
'</label>',
'<div style="margin-top:6px;display:flex;gap:4px;flex-wrap:wrap;">',
'<button id="fuelSavePlates" type="button">Сохранить номера</button>',
'<button id="fuelResetPlates" type="button">Сбросить</button>',
'</div>'
].join('');
p.appendChild(plateBox);
const actions = document.createElement('div');
actions.style.cssText = 'margin-top:10px;display:flex;gap:4px;flex-wrap:wrap;';
actions.innerHTML = [
'<button id="fuelStart" type="button">Старт</button>',
'<button id="fuelPause" type="button">Пауза</button>',
'<button id="fuelStop" type="button">Стоп</button>',
'<button id="fuelCheckQr" type="button">QR по номеру</button>'
].join('');
p.appendChild(actions);
document.body.appendChild(p);
p.querySelectorAll('button').forEach(b => {
b.style.cssText = 'background:#001a0b;color:#00ff66;border:1px solid #00ff66;padding:4px 8px;cursor:pointer;border-radius:4px;';
});
document.getElementById('fuelStart').onclick = start;
document.getElementById('fuelPause').onclick = () => {
paused = !paused;
status(paused ? 'Пауза' : 'Мониторинг продолжается');
};
document.getElementById('fuelStop').onclick = stop;
document.getElementById('fuelCheckQr').onclick = checkCurrentPlateQr;
document.getElementById('fuelPlateList').value = PLATES.join('\n');
document.getElementById('fuelHardMode').checked = hardModeEnabled;
document.getElementById('fuelHardMode').onchange = (event) => {
hardModeEnabled = !!event.target.checked;
saveBool(HARD_MODE_KEY, hardModeEnabled);
status(hardModeEnabled ? 'Хард-режим включен.' : 'Хард-режим выключен.');
};
document.getElementById('fuelSavePlates').onclick = () => {
const raw = document.getElementById('fuelPlateList').value.split(/\s+/);
if (savePlates(raw)) {
DONE.clear();
SKIPPED.clear();
status('Список номеров сохранен.');
} else {
status('Не нашел ни одного корректного номера в списке.');
}
};
document.getElementById('fuelResetPlates').onclick = () => {
savePlates(DEFAULT_PLATES);
document.getElementById('fuelPlateList').value = PLATES.join('\n');
DONE.clear();
SKIPPED.clear();
status('Список номеров сброшен к значениям из скрипта.');
};
return p;
}
function status(text) {
panel();
const uiPlate = getUiPlate();
const textValue = String(text || '');
document.getElementById('fuelQrStatus').textContent =
'=== FUEL QR reviewed ===\n' +
`Готово: ${DONE.size}\n` +
`Пропущено: ${SKIPPED.size}\n` +
`Номеров в списке: ${PLATES.length}\n` +
`Текущий номер: ${currentPlate()}\n` +
`Источник номера: ${uiPlate ? 'поле приложения' : 'список PLATES'}\n` +
`Сессия MAX: ${sessionLeft() > 0 ? sessionLeft() + ' сек.' : 'нет'}\n` +
`Хард-режим: ${hardModeEnabled ? (isHardModeActive() ? 'активен' : 'готов') : 'выключен'}\n` +
`Следующий опрос: ${pollMs} мс\n\n` +
textValue;
if (textValue !== lastStatusText || Date.now() - lastStatusLogAt > 30000) {
lastStatusText = textValue;
lastStatusLogAt = Date.now();
log(textValue);
}
}
async function api(path, body = null, timeoutMs = 12000) {
const controller = new AbortController();
const tid = setTimeout(() => controller.abort(), timeoutMs);
try {
debug('request:start', { path, method: body ? 'POST' : 'GET', body: safeJson(body), timeoutMs });
const r = await fetch('/fuel/qr' + path, {
method: body ? 'POST' : 'GET',
credentials: 'include',
cache: 'no-store',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : null,
signal: controller.signal
});
const text = await r.text();
let json;
try {
json = JSON.parse(text);
} catch {
warn('request:invalid-json', { path, status: r.status, text: text.slice(0, 300) });
const err = new ApiError(`Не JSON ${r.status}: ${text.slice(0, 120)}`, {}, r.status);
err.path = path;
throw err;
}
debug('request:response', {
path,
httpStatus: r.status,
status: json.status,
requestId: json.request_id,
data: safeJson(json.data),
message: json.message
});
if (!r.ok || json.status !== 'ok') {
if (!(path === '/session/status' && r.status === 401)) {
warn('request:api-error', {
path,
httpStatus: r.status,
requestId: json.request_id,
message: json.message,
data: safeJson(json.data || json)
});
}
const err = new ApiError(json.message || `HTTP ${r.status}`, json.data || json, r.status);
err.path = path;
throw err;
}
return json.data || {};
} catch (e) {
if (e.name === 'AbortError') {
warn('request:timeout', { path, timeoutMs });
throw new Error('Таймаут запроса');
}
if (!(path === '/session/status' && e.status === 401)) {
warn('request:failed', { path, message: e.message || String(e), code: e.code, status: e.status });
}
throw e;
} finally {
clearTimeout(tid);
}
}
function addUniqueFuel(list, item) {
if (!item || list.some(x => String(x.id) === String(item.id))) return;
list.push(item);
}
function chooseFuels(types) {
debug('chooseFuels:available', safeJson(types));
const ordered = [];
for (const preferred of FUEL_PRIORITY) {
const codes = preferred.codes || [preferred.code];
const byCode = types.find(x => codes.includes(String(x.code || '').toLowerCase()));
if (byCode) {
addUniqueFuel(ordered, byCode);
}
}
for (const preferred of FUEL_PRIORITY) {
const byId = types.find(x => String(x.id) === String(preferred.id));
if (byId) {
addUniqueFuel(ordered, byId);
}
}
for (const preferred of FUEL_PRIORITY) {
const byTitle = types.find(x => preferred.title.test(String(x.title || '')));
if (byTitle) {
addUniqueFuel(ordered, byTitle);
}
}
if (!ordered.length) {
warn('chooseFuels:not-found', safeJson(types));
} else {
debug('chooseFuels:ordered', safeJson(ordered));
}
return ordered;
}
function chooseFuel(types) {
const fuels = chooseFuels(types);
if (!fuels.length) return null;
const selected = fuels[fuelAttemptIndex % fuels.length];
debug('chooseFuel:selected-round-robin', { index: fuelAttemptIndex, selected: safeJson(selected) });
return selected;
}
function nextPollFromWait(wait) {
const raw = wait && (wait.poll_after_ms || wait.pollAfterMs);
const ms = parseInt(raw, 10);
if (Number.isFinite(ms) && ms >= 500 && ms <= 60000) return ms;
return DEFAULT_POLL_MS;
}
function isRegistrationPaused(data) {
return !!(
data
&& (
data.block_reason === 'registration_not_open'
|| data.registration_state === 'not_open'
|| data.error_code === 'registration_not_open'
|| data.errorCode === 'registration_not_open'
)
);
}
function scheduleRegistrationWait(data, fallbackMessage) {
const wait = data && data.wait ? data.wait : null;
const waitMs = nextPollFromWait(wait);
status((wait && wait.message) || fallbackMessage || 'QR registration is currently paused. Waiting...');
schedule(waitMs);
}
function isHardModeActive() {
return hardModeEnabled && Date.now() < hardModeUntil;
}
function hardDelay(ms) {
return isHardModeActive() ? Math.min(ms, HARD_POLL_MS) : ms;
}
function requestNotificationPermission() {
if (!hardModeEnabled || !('Notification' in window) || Notification.permission !== 'default') return;
try {
Notification.requestPermission().catch(function () {});
} catch (e) {
// Notification support differs across browsers/webviews.
}
}
function hardBeep() {
try {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (!AudioCtx) return;
const ctx = new AudioCtx();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.frequency.value = 880;
gain.gain.value = 0.08;
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
window.setTimeout(function () {
osc.stop();
ctx.close();
}, 180);
} catch (e) {
// Audio can be blocked until a user gesture.
}
}
function hardNotify(targetFuel) {
if (!hardModeEnabled) return;
if (Date.now() - lastHardAlertAt < HARD_WINDOW_MS) return;
lastHardAlertAt = Date.now();
hardBeep();
if ('Notification' in window && Notification.permission === 'granted') {
try {
new Notification('Fuel QR: топливо появилось', {
body: `${targetFuel.code || targetFuel.id}: ${targetFuel.title || ''}`,
silent: false
});
} catch (e) {
// Some embedded contexts block notifications.
}
}
}
function activateHardMode(targetFuel) {
if (!hardModeEnabled) return;
hardModeUntil = Date.now() + HARD_WINDOW_MS;
hardNotify(targetFuel);
debug('hard-mode:active', { until: hardModeUntil, targetFuel: safeJson(targetFuel) });
}
function schedule(ms = pollMs) {
clearTimeout(timer);
pollMs = Math.max(500, Math.min(ms, MAX_POLL_MS));
debug('schedule', { ms: pollMs, stopped, paused, busy });
if (!stopped) timer = setTimeout(mainLoop, pollMs);
}
function backoff() {
return Math.min(Math.round(pollMs * BACKOFF), MAX_POLL_MS);
}
async function mainLoop() {
debug('mainLoop:tick', {
stopped,
paused,
busy,
plate: currentPlate(),
sessionLeft: sessionLeft(),
done: Array.from(DONE),
skipped: Array.from(SKIPPED)
});
if (stopped) return;
if (paused) {
schedule(DEFAULT_POLL_MS);
return;
}
if (busy) {
schedule(300);
return;
}
if (!await ensureSession()) {
status('Жду штатную MAX-сессию. Сначала поделитесь телефоном в приложении.');
schedule(DEFAULT_POLL_MS);
return;
}
if (!hasTargetsLeft()) {
status('Номера обработаны. Работа завершена.');
stopped = true;
return;
}
if (DONE.has(currentPlate()) || SKIPPED.has(currentPlate())) {
moveToNextTarget();
schedule(100);
return;
}
busy = true;
try {
const fuelData = await api('/fuel-types', null, 7000);
const types = fuelData.fuel_types || [];
debug('fuel-types:parsed', { count: types.length, wait: safeJson(fuelData.wait), registrationState: fuelData.registration_state });
if (!types.length) {
fuelAttemptIndex = 0;
hardModeUntil = 0;
const waitMs = nextPollFromWait(fuelData.wait);
status('Топлива пока нет. Жду следующий серверный интервал.');
schedule(waitMs);
return;
}
pollMs = DEFAULT_POLL_MS;
const targetFuel = chooseFuel(types);
if (!targetFuel) {
status('Топливо есть, но нужного типа нет.\nДоступно:\n' + types.map(x => `${x.id}: ${x.title}`).join('\n'));
schedule(DEFAULT_POLL_MS);
return;
}
activateHardMode(targetFuel);
const p = currentPlate();
debug('plate:selected', { plate: p, targetFuel: safeJson(targetFuel) });
status(`Есть топливо: ${targetFuel.id}: ${targetFuel.title}\nПроверяю номер ${p}...`);
const check = await api('/plate/check', {
car_plate: p,
plate_format_confirmed: plateFormatConfirmed(p)
}, 10000);
log('plate/check:', check);
debug('plate/check:state', safeJson(check));
if ((check.state === 'active' || check.state === 'active_own') && check.ticket) {
processTicket(check.ticket, p, 'QR уже был активен');
return;
}
if (check.state === 'active_other') {
status(`${p}: активный QR есть у другой сессии. Пропускаю.`);
SKIPPED.add(p);
moveToNextTarget();
schedule(100);
return;
}
if (check.state === 'blocked') {
if (isRegistrationPaused(check)) {
scheduleRegistrationWait(check, check.message);
return;
}
status(`${p} заблокирован.\n${check.message || check.next_create_at || ''}`);
SKIPPED.add(p);
moveToNextTarget();
schedule(100);
return;
}
if (check.state !== 'can_create') {
status(`${p}: state=${check.state}. Жду дальше.`);
schedule(hardDelay(DEFAULT_POLL_MS));
return;
}
status(`Создаю QR для ${p}\nТопливо: ${targetFuel.title}`);
const create = await api('/create', {
car_plate: p,
fuel_type_id: String(targetFuel.id),
plate_format_confirmed: plateFormatConfirmed(p)
}, 20000);
log('create:', create);
debug('create:result', safeJson(create));
if (create.ticket && create.ticket.deeplink) {
processTicket(create.ticket, p, 'QR создан');
return;
}
if ((create.state === 'active' || create.state === 'active_own') && create.ticket) {
processTicket(create.ticket, p, 'QR уже активен');
return;
}
if (create.state === 'active_other') {
status(`${p}: активный QR появился у другой сессии. Пропускаю номер.`);
SKIPPED.add(p);
moveToNextTarget();
schedule(100);
return;
}
status('create ответил без ticket. Продолжаю мониторинг.');
schedule(hardDelay(DEFAULT_POLL_MS));
} catch (e) {
warn('mainLoop:error', { message: e.message || String(e), code: e.code, status: e.status, data: safeJson(e.data) });
if (e.sessionExpired) {
status('Сессия истекла. Нужно заново пройти MAX-доступ.');
schedule(DEFAULT_POLL_MS);
} else if (e.code === 'active_qr_exists') {
status('По номеру уже есть активный QR. Проверьте вручную кнопкой "QR по номеру".');
schedule(DEFAULT_POLL_MS);
} else if (isRegistrationPaused(e.data)) {
scheduleRegistrationWait(e.data, e.message);
} else if (/fuel_unavailable|registration_not_open/.test(e.code || '')) {
status(e.message || 'Топливо стало недоступно. Продолжаю мониторинг.');
schedule(hardDelay(DEFAULT_POLL_MS));
} else if (e.path === '/create') {
fuelAttemptIndex += 1;
status(`Создание QR не прошло: ${e.message || e}. Сразу пробую снова.`);
schedule(CREATE_FAIL_COOLDOWN_MS);
} else {
status(`Ошибка: ${e.message || e}`);
schedule(backoff());
}
} finally {
busy = false;
}
}
async function checkCurrentPlateQr() {
try {
if (!await ensureSession()) {
status('Нет сессии. Сначала поделитесь телефоном в MAX.');
return;
}
const p = currentPlate();
status(`Проверяю QR только по номеру:\n${p}`);
const check = await api('/plate/check', {
car_plate: p,
plate_format_confirmed: plateFormatConfirmed(p)
}, 10000);
log('manual plate/check:', check);
if ((check.state === 'active' || check.state === 'active_own') && check.ticket) {
processTicket(check.ticket, p, 'QR найден по номеру');
return;
}
status(`QR по номеру не найден.\nstate=${check.state}`);
} catch (e) {
status(`Ошибка проверки номера:\n${e.message || e}`);
}
}
function processTicket(ticket, plate, reason) {
DONE.add(plate);
log(`${reason}: ${plate}`, ticket);
debug('ticket:processed', { plate, reason, ticket: safeJson(ticket) });
status(`${reason} для ${plate}\n${ticket.deeplink}`);
try {
window.open(ticket.deeplink, '_blank');
} catch (e) {
log('window.open failed:', e);
}
drawQr(ticket, plate, DONE.size);
if (getUiPlate()) {
status('QR для номера из поля получен. Готово.');
stopped = true;
clearTimeout(timer);
return;
}
if (hasTargetsLeft()) {
moveToNextTarget();
schedule(100);
} else {
status('Все доступные QR получены/найдены. Готово.');
stopped = true;
clearTimeout(timer);
}
}
function drawQr(ticket, plate, order) {
const old = document.getElementById('fuelQrImg_' + plate);
if (old) old.remove();
const wrap = document.createElement('div');
wrap.id = 'fuelQrImg_' + plate;
wrap.style.cssText = [
'position:fixed',
'z-index:2147483647',
`top:${180 + (order - 1) * 270}px`,
'right:15px',
'width:230px',
'background:white',
'color:#000',
'padding:8px',
'border:4px solid #00ff66',
'border-radius:10px',
'font:12px sans-serif',
'text-align:center',
'box-shadow:0 0 15px rgba(0,255,102,.5)'
].join(';');
const title = document.createElement('div');
title.textContent = plate;
title.style.cssText = 'font-weight:bold;margin-bottom:6px;';
wrap.appendChild(title);
const img = new Image();
img.alt = `QR ${plate}`;
img.style.cssText = 'width:220px;height:220px;display:block;';
if (ticket.qr_png_base64) {
img.src = 'data:image/png;base64,' + ticket.qr_png_base64;
} else if (window.QRCode && typeof window.QRCode.toDataURL === 'function') {
window.QRCode.toDataURL(ticket.deeplink, { width: 220 }).then(src => {
img.src = src;
}).catch(() => {
title.textContent = `${plate} - QR render error`;
});
} else {
title.textContent = `${plate} - QR renderer missing`;
}
wrap.appendChild(img);
document.body.appendChild(wrap);
}
function start() {
stopped = false;
paused = false;
busy = false;
index = 0;
pollMs = DEFAULT_POLL_MS;
hardModeUntil = 0;
requestNotificationPermission();
const plateList = document.getElementById('fuelPlateList');
if (plateList && savePlates(plateList.value.split(/\s+/))) {
plateList.value = PLATES.join('\n');
}
DONE.clear();
SKIPPED.clear();
document.querySelectorAll('[id^="fuelQrImg_"]').forEach(el => el.remove());
clearTimeout(timer);
status('Мониторинг запущен.\nЕсли номер введен в поле, использую его. Иначе беру PLATES.');
schedule(100);
}
function stop() {
stopped = true;
paused = false;
busy = false;
hardModeUntil = 0;
clearTimeout(timer);
status('Остановлено.');
}
panel();
status('Готов. Reviewed-версия сверена с HTML/API.');
setTimeout(start, 2000);
})();