feat(n8n): авторизация ej.sudrf.ru через ЕСИА (Browserless)

- Скрипт browserless_login_esia.js для n8n: ГАС Правосудие → ЕСИА → до экрана SMS
- Галочка согласия (#iAgree), ожидание активации кнопки Войти, клик по button.esiaLogin
- Заполнение логина/пароля на ЕСИА: видимый input, keyboard.type + blur/change
- Куки через page.cookies() (совместимость с browserless /function)
- Вход: login/pass из body или fallback из n8n (JSON.stringify для спецсимволов)
- Возврат: status waiting_for_sms + cookies для второго шага (ввод SMS)
This commit is contained in:
Fedor
2026-02-04 10:52:19 +03:00
parent d7982931cd
commit fd2e7cfb07

518
browserless_login_esia.js Normal file
View File

@@ -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',
},
};
}