diff --git a/browserless_login_esia.js b/browserless_login_esia.js new file mode 100644 index 00000000..76f6104d --- /dev/null +++ b/browserless_login_esia.js @@ -0,0 +1,518 @@ +// Авторизация на 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', + }, + }; +} \ No newline at end of file