// Авторизация на ej.sudrf.ru через ЕСИА (Госуслуги) // n8n → HTTP Request → Browserless (Puppeteer) // // Вход: $credentials.login, $credentials.password // Выход: { status, cookies, screenshot, url, session_data } export default async function ({ page, context: browserContext }, input = {}) { // Варианты передачи логина/пароля: // 1) Предпочтительно: отдельными полями body запроса Browserless: // { code, login, pass } или { code, context: { login, pass } } // 2) Если тянете из предыдущей ноды прямо в поле code — используйте JSON.stringify, чтобы не ломать JS: // const login = {{ JSON.stringify($json.login) }}; // const pass = {{ JSON.stringify($json.pass) }}; // Эти строки можно включить в n8n (expression), если вы не передаёте login/pass отдельными полями: // eslint-disable-next-line no-unused-vars const __FALLBACK_LOGIN__ = {{ JSON.stringify($json.login ?? "") }}; // eslint-disable-next-line no-unused-vars const __FALLBACK_PASS__ = {{ JSON.stringify($json.pass ?? "") }}; const fallbackLogin = (typeof __FALLBACK_LOGIN__ !== 'undefined') ? __FALLBACK_LOGIN__ : ''; const fallbackPass = (typeof __FALLBACK_PASS__ !== 'undefined') ? __FALLBACK_PASS__ : ''; const login = String(input.login ?? input.context?.login ?? fallbackLogin ?? '').trim(); const password = String(input.pass ?? input.password ?? input.context?.pass ?? input.context?.password ?? fallbackPass ?? '').trim(); if (!login || !password) { throw new Error('Не переданы login/pass во входных данных Browserless'); } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const timeout = 45000; const loadDelay = 800; await page.setViewport({ width: 1280, height: 800 }); page.setDefaultTimeout(timeout); const makeError = async (error_type, error_message, extra = {}) => { const bodyText = await page.evaluate(() => document.body?.innerText || '').catch(() => ''); return { status: 'error', error_type, error_message, current_url: page.url(), page_text: bodyText.slice(0, 1500), screenshot: await page.screenshot({ encoding: 'base64', fullPage: true }), ...extra, }; }; const isEsiaUrl = (u) => (u || '').includes('esia.gosuslugi.ru') || (u || '').includes('gosuslugi.ru'); const waitEsiaOrNav = async (ms = 30000) => { await Promise.race([ page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: ms }).catch(() => {}), page .waitForFunction( () => location.href.includes('gosuslugi') || location.href.includes('esia.gosuslugi'), { timeout: ms } ) .catch(() => {}), ]); }; const clickByText = async (patterns) => { return page .evaluate((patterns) => { const norm = (s) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase(); const els = Array.from( document.querySelectorAll( 'a, button, [role="button"], input[type="button"], input[type="submit"]' ) ); const hit = els.find((el) => { const t = norm(el.textContent || el.value || ''); const href = (el.getAttribute?.('href') || '').toLowerCase(); return patterns.some((p) => t.includes(p) || href.includes(p)); }); if (hit) { hit.scrollIntoView({ block: 'center' }); hit.click(); return true; } return false; }, patterns.map((p) => p.toLowerCase())) .catch(() => false); }; const clickBySelector = async (selector) => { const el = await page.$(selector).catch(() => null); if (!el) return false; await el.scrollIntoViewIfNeeded?.().catch(() => {}); try { await el.click({ delay: 30 }); return true; } catch (_) {} try { const box = await el.boundingBox(); if (box) { await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { delay: 30 }); return true; } } catch (_) {} return false; }; // Принять пользовательское соглашение + нажать "Войти" на ej.sudrf.ru const acceptAgreementAndLogin = async () => { // На живой странице выяснили: // - чекбокс имеет id="iAgree" // - кнопка "Войти" имеет классы: btn btn-primary esia-login esiaLogin и type="submit" // - кнопка реально disabled до отметки чекбокса await page .waitForSelector('#iAgree', { visible: true, timeout: 20000 }) .catch(() => {}); // 1) Отмечаем чекбокс реальным кликом (именно он включает кнопку) const checkboxClicked = await page .evaluate(() => { const cb = document.querySelector('#iAgree'); if (!cb) return false; cb.scrollIntoView({ block: 'center' }); const label = cb.closest('label') || (cb.id ? document.querySelector(`label[for="${cb.id}"]`) : null); if (label) label.click(); else cb.click(); cb.dispatchEvent(new Event('input', { bubbles: true })); cb.dispatchEvent(new Event('change', { bubbles: true })); return true; }) .catch(() => false); if (!checkboxClicked) { return { checkboxChecked: false, loginClicked: false }; } // 2) Ждём, что чекбокс действительно стал checked const checkboxChecked = await page .waitForFunction(() => !!document.querySelector('#iAgree')?.checked, { timeout: 10000 }) .then(() => true) .catch(() => false); if (!checkboxChecked) { return { checkboxChecked: false, loginClicked: false }; } // 3) Ждём, что кнопка "Войти" стала enabled (disabled снят) const loginBtnSelector = 'button.esiaLogin, button.esia-login, button.btn.esiaLogin, button.btn.esia-login'; const loginBtnReady = await page .waitForFunction( (sel) => { const btn = document.querySelector(sel); if (!btn) return false; // disabled должен быть снят // @ts-ignore if (btn.disabled === true) return false; const rect = btn.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }, { timeout: 25000 }, loginBtnSelector ) .then(() => true) .catch(() => false); if (!loginBtnReady) { return { checkboxChecked: true, loginClicked: false, reason: 'login_button_still_disabled' }; } await sleep(500); // 4) Кликаем "Войти" и ждём перехода на ЕСИА const beforeUrl = page.url(); await Promise.all([ waitEsiaOrNav(25000), page .click(loginBtnSelector, { delay: 30 }) .catch(async () => { // фоллбек на DOM-click await page .evaluate((sel) => { const btn = document.querySelector(sel); if (btn) { // @ts-ignore btn.disabled = false; btn.removeAttribute?.('disabled'); // @ts-ignore btn.click(); } }, loginBtnSelector) .catch(() => {}); }), ]); const afterUrl = page.url(); const loginClicked = isEsiaUrl(afterUrl) || afterUrl !== beforeUrl; return { checkboxChecked: true, loginClicked, url: afterUrl }; }; // ——— 1) Открываем ej.sudrf.ru ——— await page.goto('https://ej.sudrf.ru/?fromOa=16RS0018', { waitUntil: 'domcontentloaded', timeout, }); await sleep(loadDelay); // Иногда редиректит сразу (редко), но обычно — нет let currentUrl = page.url(); if (!isEsiaUrl(currentUrl)) { // Подождать дорисовку страницы await page.waitForSelector('body', { timeout: 20000 }).catch(() => {}); await sleep(400); // ——— 2) Проверяем, на какой странице мы находимся ——— const pageType = await page .evaluate(() => { const t = (document.body?.innerText || '').toLowerCase(); if (t.includes('авторизация пользователя') && t.includes('есиа')) { return 'auth_page'; } if (t.includes('обращения') || t.includes('дела')) { return 'main_page'; } return 'unknown'; }) .catch(() => 'unknown'); if (pageType === 'main_page') { // ——— 2a) На главной странице — ищем кнопку "Вход" ——— let loginLinkClicked = await clickByText(['вход']); if (!loginLinkClicked) { // Пытаемся найти по селекторам loginLinkClicked = await page .evaluate(() => { const links = Array.from(document.querySelectorAll('a')); const loginLink = links.find((el) => { const text = (el.textContent || '').toLowerCase().trim(); return text === 'вход'; }); if (loginLink) { loginLink.scrollIntoView({ block: 'center' }); loginLink.click(); return true; } return false; }) .catch(() => false); } if (!loginLinkClicked) { return await makeError('main_page_login_not_found', 'Не удалось найти кнопку "Вход" на главной странице'); } // Ждём загрузки страницы авторизации с дополнительными проверками await Promise.race([ page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 15000 }), page.waitForSelector('input[type="checkbox"]', { timeout: 15000 }), page.waitForFunction(() => document.body?.innerText?.toLowerCase().includes('авторизация пользователя'), { timeout: 15000 }) ]).catch(() => {}); await sleep(loadDelay); } // ——— 2b) Теперь должны быть на странице авторизации — принимаем соглашение и жмём Войти ——— const pageLooksLikeAuth = await page .evaluate(() => { const t = (document.body?.innerText || '').toLowerCase(); return t.includes('авторизация пользователя') && t.includes('есиа'); }) .catch(() => false); if (pageLooksLikeAuth) { const { checkboxState, loginClicked } = await acceptAgreementAndLogin(); // Если кнопка не нажалась (например, disabled) — ещё раз попробуем клик по "Войти" через общий поиск if (!loginClicked) { // Иногда кнопка активируется с задержкой после клика по чекбоксу await sleep(600); const fallbackLoginClick = await clickByText(['войти']); if (!fallbackLoginClick) { return await makeError( 'esia_login_button_not_clicked', 'Не удалось нажать "Войти" после принятия соглашения', { checkboxState } ); } } // Ждём редирект на ЕСИА await waitEsiaOrNav(30000); await sleep(loadDelay); } else { return await makeError('auth_page_not_found', 'Не удалось попасть на страницу авторизации'); } } // ——— 4) Проверяем, что мы на ЕСИА ——— currentUrl = page.url(); if (!isEsiaUrl(currentUrl)) { return await makeError('esia_redirect_failed', 'Не произошел редирект на ЕСИА (после Войти)', { after_actions_url: currentUrl, }); } // ——— 5) Ввод логина на ЕСИА ——— await page .waitForSelector('input[type="text"], input[name="login"], input[name="username"]', { timeout: 20000, }) .catch(() => {}); // ЕСИА часто на React/контролируемых инпутах — простая установка el.value может не сработать. // Поэтому делаем "живой" ввод через клавиатуру + проверяем, что значение реально попало в input.value. const normalizePhone = (v) => String(v || '').trim().replace(/^\+/, ''); const loginToType = normalizePhone(login); const fillInput = async (selectors, value, debugKey) => { // 1) Выбираем ВИДИМЫЙ инпут (у ESIA часто есть скрытые дубли) for (const sel of selectors) { const handles = await page.$$(sel).catch(() => []); for (const handle of handles) { try { const box = await handle.boundingBox(); if (!box || box.width < 5 || box.height < 5) continue; // запомним, какой селектор реально сработал (для отладки) if (debugKey) { debug[debugKey] = { selector: sel, box }; } await handle.focus(); // очистка await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control'); await page.keyboard.press('Backspace'); // ввод именно через elementHandle.type (иногда надежнее чем page.keyboard.type) await handle.type(String(value), { delay: 60 }); // blur + change (нужно ESIA, иначе пишет "Заполните поле") await handle.evaluate((el) => { el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); el.blur(); }).catch(() => {}); await sleep(250); const ok = await handle .evaluate((el) => typeof el.value === 'string' && el.value.length > 0) .catch(() => false); if (ok) return true; } catch (_) {} } } // 2) Фоллбек: нативный setter + input/change (для React) const ok = await page .evaluate((sels, val) => { const isVisible = (el) => { const r = el.getBoundingClientRect(); return r.width > 5 && r.height > 5; }; const pick = () => { for (const s of sels) { const list = Array.from(document.querySelectorAll(s)); const visible = list.find(isVisible); if (visible) return visible; } return null; }; const el = pick(); if (!el) return false; el.focus(); const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; if (setter) { setter.call(el, ''); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); setter.call(el, String(val)); } else { // @ts-ignore el.value = String(val); } el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); el.blur(); // @ts-ignore return typeof el.value === 'string' && el.value.length > 0; }, selectors, value) .catch(() => false); return ok; }; const debug = {}; const loginFilled = await fillInput( [ 'input[name="login"]', 'input[name="username"]', 'input[type="tel"]', 'input[type="text"]', ], loginToType, 'loginInput' ); if (!loginFilled) { return await makeError('login_input_not_found', 'Не найдено поле логина на странице ЕСИА'); } await sleep(300); // ——— 6) Ввод пароля ——— await page.waitForSelector('input[type="password"]', { timeout: 20000 }).catch(() => {}); const passFilled = await fillInput(['input[type="password"]'], password, 'passwordInput'); if (!passFilled) { return await makeError('password_input_not_found', 'Не найдено поле пароля на странице ЕСИА'); } await sleep(300); // ——— 7) Submit ——— // маленькая пауза перед сабмитом, чтобы ESIA "съела" input/change await sleep(600); const submitted = await page .evaluate(() => { const norm = (s) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase(); const btn = document.querySelector('button[type="submit"]') || Array.from(document.querySelectorAll('button')).find((b) => norm(b.textContent).includes('войти') ) || Array.from(document.querySelectorAll('input[type="submit"]')).find(Boolean); if (btn) { btn.scrollIntoView({ block: 'center' }); btn.click(); return true; } return false; }) .catch(() => false); if (!submitted) { // fallback Enter await page.keyboard.press('Enter').catch(() => {}); } await sleep(500); // ——— 8) Ждём SMS-поля (или навигацию) ——— const otpSelector = 'input[inputmode="numeric"], input[type="tel"], input[autocomplete="one-time-code"], input[name="otp"], input[name*="code"], input[id*="otp"], input[id*="code"]'; await Promise.race([ page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {}), page .waitForFunction( (sel) => document.querySelectorAll(sel).length > 0, { timeout: 30000 }, otpSelector ) .catch(() => {}), ]); await sleep(loadDelay); const smsInputs = await page.$$(otpSelector).catch(() => []); if (!smsInputs || smsInputs.length === 0) { // Если мы всё ещё на /login/, скорее всего форма не приняла пароль/логин или показала валидацию const urlNow = page.url(); if (urlNow.includes('esia.gosuslugi.ru/login')) { return await makeError( 'login_failed', 'После нажатия «Войти» ЕСИА не перешла к SMS. Скорее всего, форма считает логин/пароль пустыми или произошла ошибка входа.', { debug, } ); } return await makeError( 'sms_page_not_found', 'Не найдены поля для ввода SMS кода (возможно, иной фактор подтверждения или ошибка входа)' ); } // ——— 9) Сохраняем куки + скрин ——— // В browserless /function это Puppeteer: куки берём с page const cookies = (typeof page.cookies === 'function') ? await page.cookies() : []; const screenshot = await page.screenshot({ encoding: 'base64', fullPage: true }); return { status: 'waiting_for_sms', message: '✅ Дошли до ввода SMS. Ожидание кода.', url: page.url(), cookies, screenshot, sms_inputs_count: smsInputs.length, session_data: { created_at: new Date().toISOString(), note: 'cookies передай во второй скрипт, чтобы продолжить сессию и ввести SMS', }, }; }