Вход с CSRF: сохранить состояние формы (TypeScript)
Войти через форму, которая дополнительно проверяет состояние запроса. Для этого сценарий отправляет форму в браузере, сохраняя её обычное поведение.
Выберите язык TypeScript, вставьте полный исходник из этой статьи и задайте параметры запуска ниже. Источник для всей подборки: ScrapingCourse. Разбор относится к указанной демонстрационной странице.
Настройки примера
{
"path": "/login/csrf",
"mode": "login",
"expected": 12
}
path выбирает страницу, mode выбирает ветку алгоритма, expected задаёт контрольное число записей для этого примера. Значение expected не является лимитом тарифа. Если набор данных источника меняется, сверяйте ожидаемое количество с ним.
Как работает алгоритм
- Открываем
/login/csrfв режимеlogin. Получение формы и её отправка происходят в одной браузерной сессии. - Находим демонстрационные реквизиты в подсказке страницы и проверяем, что значения не пустые.
- Заполняем только поля email и пароля. Скрытые поля формы остаются частью исходной страницы.
- Нажимаем её кнопку отправки. Браузер отправляет форму вместе с её скрытыми значениями и cookies; сценарий не собирает отдельный POST вручную.
- После отправки ждём заполненные названия товаров. Затем обрабатываем каталог тем же сборщиком карточек.
- Проверяем число уникальных товаров и освобождаем браузер в
finally.
Карточки обрабатывает collect(html). Сначала читается название. Пустое название пропускается, ссылка карточки становится ключом, а при её отсутствии используется имя. seen не даёт повторно сохранить один и тот же ключ. Это важно, когда очередное чтение HTML включает уже собранные товары.
На что обратить внимание
CSRF относится к согласованности формы и сессии. Поэтому здесь важно продолжать работу с тем же объектом страницы, а не заново открывать каталог после ввода пароля.
Особенности TypeScript
В TypeScript ctx: Akmelo.Context даёт подсказки методов SDK, Promise<void> описывает результат run, а Set<string> хранит строковые ключи. Эти типы не меняют порядок действий: сетевые и браузерные вызовы по-прежнему требуют await. collect(html: string): number возвращает число карточек текущей страницы.
Что получится
Для настроенного демонстрационного набора ожидаются 12 уникальных записей. Общая проверка в конце сравнивает фактическое число ключей с expected.
| Поле | Что содержит |
|---|---|
name |
Название товара после удаления пробелов по краям. |
price |
Цена, преобразованная из текста в число. |
image |
Адрес изображения из атрибута src карточки. |
Полный код сценария
Исходник общий для всей подборки. Для этого примера в нём выполняется ветка login, выбранная параметрами выше. Блок можно целиком скопировать в редактор TypeScript; реквизиты доступа и ключи сервисов в него добавлять не требуется.
async function run(ctx: Akmelo.Context): Promise<void> {
const origin = 'https://www.scrapingcourse.com';
const path = String(ctx.param('path', '/pagination'));
const mode = String(ctx.param('mode', 'http'));
const auto = ctx.param<boolean>('auto_captcha', true) !== false;
const seen = new Set<string>();
const collect = (html: string): number => {
const dom = ctx.dom(html);
for (const card of dom.qa('.product-item, li.product')) {
const name = card.q('.product-name, .woocommerce-loop-product__title')?.text().trim() ?? '';
const key = card.q('a')?.attr('href') ?? name;
if (!name || seen.has(key)) continue;
const price = ctx.text().money(card.q('.product-price, .price')?.text() ?? '');
const image = card.q('img')?.attr('src') ?? '';
seen.add(key);
ctx.emit({name, price, image}, key);
}
return dom.qa('.product-item, li.product').length;
};
if (mode === 'http') {
let url: string | null = origin + path;
const visited = new Set<string>();
while (url && visited.size < 50) {
if (visited.has(url)) throw new Error('Пагинация вернула уже обработанную страницу');
visited.add(url);
const response = await ctx.http().withoutCache().get(url, auto);
if (!response.ok()) throw new Error('Источник не открылся: ' + response.status);
const html = response.body;
collect(html);
const next = ctx.dom(html).attr('a[rel="next"], a.next.page-numbers', 'href');
url = next ? ctx.url().absolutize(url, next) : null;
}
if (url) throw new Error('Превышен защитный предел пагинации');
} else {
const page = await ctx.browser().open(origin + path, ['*/ajax/products*'], {}, auto);
try {
if (mode === 'login') {
const dom = ctx.dom(await page.content());
const hint = dom.qa('p').map(node => node.text()).find(text => text.includes('Demo Credentials:')) ?? '';
const password = hint.match(/Password:\s*([^\s|]+)/i)?.[1];
let email = hint.match(/Email:\s*([^\s|]+@[^\s|]+)/i)?.[1];
const encoded = dom.attr('[data-cfemail]', 'data-cfemail');
if (!email && encoded && encoded.length > 2) {
const key = parseInt(encoded.slice(0, 2), 16);
email = '';
for (let i = 2; i < encoded.length; i += 2) email += String.fromCharCode(parseInt(encoded.slice(i, i + 2), 16) ^ key);
}
if (!email || !password) throw new Error('Страница не содержит учебных реквизитов');
await page.type('#email', email);
await page.type('#password', password);
await page.click('button[type="submit"]');
}
if (mode === 'challenge') {
const text = (ctx.dom(await page.content()).text('body') ?? '').replace(/\s+/g, ' ');
if (!/You (?:successfully )?bypassed|successfully bypassed|Congratulations/i.test(text)) throw new Error('Защищённое содержимое не получено');
ctx.emit({name: path, passed: true}, path);
} else {
await page.waitFor('.product-name:not(:empty)', 15000);
if (mode === 'button') {
let complete = false;
for (let i = 0; i < 30; i++) {
const before = ctx.dom(await page.content());
collect(await page.content());
const button = before.q('#load-more-btn');
if (!button || /display:\s*none/.test(button.attr('style') ?? '')) { complete = true; break; }
const offset = button.attr('data-offset');
const count = before.qa('.product-item').length;
await page.click('#load-more-btn');
const response = await page.waitForResponse('*offset=' + offset, 15000) as Akmelo.Row | null;
if (!response || (Number(response.status) < 200 || Number(response.status) >= 300) || response.truncated || response.error) throw new Error('Не удалось загрузить следующую порцию');
if (String(response.body ?? '').trim() === '') { complete = true; break; }
await page.waitFor('.product-item:nth-child(' + (count + 1) + ')');
}
if (!complete) throw new Error('Превышен защитный предел загрузки');
}
if (mode === 'scroll') {
let stable = 0;
let previous = 0;
for (let i = 0; i < 40 && stable < 3; i++) {
await page.scroll(3, 400);
const count = collect(await page.content());
stable = count === previous ? stable + 1 : 0;
previous = count;
}
if (stable < 3) throw new Error('Лента продолжает загружаться: увеличьте предел прокрутки');
}
collect(await page.content());
}
} finally { await page.close(); }
}
const expected = Number(ctx.param('expected', 1));
if (mode !== 'challenge' && seen.size !== expected) throw new Error('Неполный результат: ' + seen.size + ' вместо ' + expected);
}
Куда посмотреть дальше
Общий алгоритм и параметры · HTTP-запросы · Действия браузера · Поиск элементов HTML