- Скрипт 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)
518 lines
20 KiB
JavaScript
518 lines
20 KiB
JavaScript
// Авторизация на 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',
|
||
},
|
||
};
|
||
} |