Files
crm.clientright.ru/browserless_login_esia.js
Fedor fd2e7cfb07 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)
2026-02-04 10:52:19 +03:00

518 lines
20 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.

// Авторизация на 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',
},
};
}