Перейти к содержанию

Brave Search и Argon2id PoW

Brave Search — единственный веб-источник, используемый resolver'ом. Запросы к нему выполняются с ограничением site:genius.com, чтобы результаты концентрировались на страницах с текстами песен с предсказуемым форматом заголовков. Иногда Brave возвращает HTTP 429 с Argon2id proof-of-work challenge — resolver решает его локально и повторяет запрос.

Источник: src-tauri/src/resolver/sources/brave.rs


Формирование запроса

Resolver добавляет фильтр по сайту к каждому запросу:

const LYRICS_SITES: &[&str] = &["genius.com"];

let sites_clause = LYRICS_SITES
    .iter()
    .map(|s| format!("site:{s}"))
    .collect::<Vec<_>>()
    .join(" OR ");
let q = format!("{query} ({sites_clause})");
// → "нон стоп молли (site:genius.com)"

Зачем ещё и клиентская фильтрация? Brave соблюдает оператор site:genius.com нестрого — при малом числе совпадений в индексе он всё равно подмешивает результаты с YouTube, Apple Music, Last.fm и из блогов. Поэтому после получения результатов применяется дополнительная проверка:

if !title.to_lowercase().contains("genius") {
    continue; // отбросить результаты не с Genius
}

Любой результат, в заголовке страницы которого нет слова "genius", молча отбрасывается — вне зависимости от того, что было в URL от Brave.


Обычный запрос

GET https://search.brave.com/search?q=<query>&source=web
Accept: application/json
User-Agent: Mozilla/5.0 (Macintosh; ...) Chrome/122.0.0.0 Safari/537.36

User-Agent совпадает с тем, который использовался в оригинальном Python-прототипе, установившем ожидаемую форму запроса. Его изменение грозит иным поведением rate-limit.

При успехе (HTTP 200) ответ представляет собой сырой HTML. Замечание об обработке Content-Type: заголовок Content-Type от Brave иногда не содержит charset=utf-8, из-за чего метод .text() в reqwest откатывается на latin-1 и искажает каждую пару кириллических байт. Для обхода этой проблемы код читает сырые байты и декодирует явно:

let bytes = res.bytes().await?;
let html = String::from_utf8_lossy(&bytes); // lossy: некорректные байты → U+FFFD

Извлечение заголовков

HTML-парсер ищет spans с классом search-snippet-title с помощью регулярного выражения:

const TITLE_PAT: &str =
    r#"(?s)<(?:div|span|a)[^>]*class="[^"]*search-snippet-title[^"]*"[^>]*>([^<]{3,200})</"#;

Здесь намеренно не используется настоящий HTML-парсер — это нестрогое регулярное выражение, поскольку Brave периодически меняет имена классов. Группа захвата извлекает сырой внутренний текст элемента заголовка сниппета.


Разбор заголовков

Каждый извлечённый заголовок передаётся в parse_artist_title. Функция последовательно пробует три вида паттернов:

1. Страницы альбомов

"Пошлая Молли - Незваный гость Lyrics and Tracklist | Genius"
→ (artist="Пошлая Молли", title="Незваный гость", kind=Album)
make(r"^(.+?)\s*[-–—]\s*(.+?)\s+Lyrics\s+and\s+Tracklist\s*\|\s*Genius\s*$"),

Паттерны альбомов проверяются первыми, поскольку их более специфичный суффикс не позволяет им сматчиться как треки.

2. Страницы треков (7 паттернов)

"Пошлая Молли - Нон стоп Lyrics | Genius"
→ (artist="Пошлая Молли", title="Нон стоп", kind=Track)

"текст песни 1.Kla$ - Сукины дети | ..."
→ (artist="1.Kla$", title="Сукины дети", kind=Track)

Некоторые паттерны имеют переставленный порядок групп захвата (bool = false) — для конструкций вида "song lyrics by Artist", где группа 1 — это название, а группа 2 — исполнитель:

(make(r"^(.+?)\s*[-–—]\s*song\s+and\s+lyrics\s+by\s+(.+?)(?:\s*[|\-–—].*)?$"), false),

3. Страницы исполнителей

"Пошлая Молли Lyrics, Songs, and Albums | Genius"
→ (artist="Пошлая Молли", title="", kind=Artist)

"Тексты песен Земфира | ..."
→ (artist="Земфира", title="", kind=Artist)

Страницы исполнителей несут пустой title и впоследствии приводят к Intent::Artist.

Фильтры отклонения

После разбора каждый candidate проверяется:

if junk.is_match(artist) || junk.is_match(title) { continue; }
if artist.chars().count() > 60 || title.chars().count() > 80 { continue; }
if artist.contains('|') || title.contains('|') { continue; }

Регулярное выражение для мусора сопоставляет отдельные слова вроде "lyrics", "paroles", "letras", "тексты", "перевод". Они появляются в плохо структурированных заголовках, где регулярное выражение захватило суффикс в группу artist/title.

Проверка на | ловит случаи, когда регулярное выражение захватило суффикс "| Genius" в группу захвата (например, title="Radiohead | Genius").

Аккаунты-переводчики также явно отклоняются:

let artist_lc = artist.to_lowercase();
if artist_lc.contains("translations") || artist_lc.contains("перевод") { continue; }

Аккаунты переводчиков на Genius ("Genius Russian Translations") публикуют страницы в форме альбомов, заголовок которых совпадает с паттерном альбома, однако "исполнителем" является переводчик, а не группа. Их исключение позволяет победить настоящей странице-хабу исполнителя из той же выдачи.


Декодирование HTML-сущностей

html_unescape декодирует стандартные сущности (&amp;, &quot;, &#39;, &#NNNN;). Ключевая деталь реализации: функция итерирует по char, а не по байтам:

// Правильно: итерация по char сохраняет многобайтовые кодовые точки UTF-8 целыми
let mut chars = s.char_indices();
while let Some((pos, ch)) = chars.next() { ... }

Ранняя версия на основе байтов передавала b[i] as char для байтов, не являющихся &, что искажало кириллические символы (каждый из которых занимает 2 байта в UTF-8) в мусорные Latin-1 codepoints. Версия на основе char обрабатывает каждую Unicode code point как единое целое.


429 и PoW challenge

Когда Brave подозревает бота, он возвращает HTTP 429 с Content-Type: application/json и телом challenge:

{
  "tokens": ["abc123...", "def456..."],
  "zero_count": 4,
  "hash_function_params": {
    "iterations": 2,
    "memory_size": 19456,
    "parallelism": 1,
    "hash_length": 32
  },
  "solution_limit": 5000,
  "set_token": "eyJ..."
}

Challenge требует найти для каждого token 16-байтовую случайную соль, чей Argon2id-хэш начинается с zero_count ведущих нулевых ниблов (шестнадцатеричных символов).

Решение

Решатель выполняется в блокирующем потоке через tokio::task::spawn_blocking:

async fn solve_pow(challenge: Challenge) -> Option<OwnedSolution> {
    tokio::task::spawn_blocking(move || solve_pow_sync(&challenge))
        .await.ok().flatten()
}

solve_pow_sync перебирает Argon2id для каждого token:

for _ in 0..limit {
    rng.fill_bytes(&mut salt_bytes);
    // Brave ожидает UTF-8-байты HEX-СТРОКИ соли в качестве входных данных для соли Argon2 —
    // не сырые 16 байт. Это соответствует Python-прототипу.
    let salt_hex = hex::encode(salt_bytes);
    argon.hash_password_into(tok.as_bytes(), salt_hex.as_bytes(), &mut digest)?;
    let digest_hex = hex::encode(&digest);
    if digest_hex.bytes().take(zeros).all(|b| b == b'0') {
        found = Some(salt_hex);
        break;
    }
}

Формат входных данных для Argon2 salt

Brave использует hex-строку соли в качестве входных данных для Argon2 salt, а не сырые байты. hash_password_into(token_bytes, salt_hex_utf8_bytes, ...). Это неочевидно и было обнаружено путём сопоставления с Python-прототипом, корректная работа которого была подтверждена.

Решатель имеет жёсткий временной бюджет: POW_MAX_MS = 3000. Если какой-либо token не удаётся решить в рамках бюджета, solve_pow_sync возвращает None, и resolver отказывается от Brave, не блокируя выполнение бесконечно.

Отправка решения

client
    .post("https://search.brave.com/api/captcha/pow?brave=0")
    .header("Content-Type", "application/json")
    .json(&solution)  // { set_token, solutions: {token: salt_hex}, taken_time }
    .send()
    .await?;
// После POST поиск повторяется — Brave устанавливает cookie на стороне сервера

Цикл повторных попыток

Resolver делает до 3 попыток. Каждая итерация может получить свежий 429 с новым challenge, который решается независимо:

for _ in 0..3 {
    let res = client.get(base_url).query(...).send().await?;
    if res.status().is_success() {
        // разобрать HTML и вернуть
    }
    if res.status() != StatusCode::TOO_MANY_REQUESTS {
        return Err(format!("brave: http {}", res.status()));
    }
    // 429: декодировать challenge, решить, отправить решение POST, повторить
    let challenge: Challenge = res.json().await?;
    let solution = solve_pow(challenge).await.ok_or("PoW solve failed")?;
    client.post(".../pow").json(&solution).send().await?;
}

Таймауты

Константа Значение Назначение
BRAVE_TIMEOUT 4000 мс Лимит реального времени на одну попытку (включая время решения PoW)
POW_MAX_MS 3000 мс Жёсткий бюджет для перебора Argon2id
connect_timeout 5 с Установка TCP-соединения
timeout (reqwest) 12 с Полный цикл запрос/ответ

BRAVE_TIMEOUT оборачивает весь future lookup() в tokio::time::timeout. Если Brave медленно отвечает или PoW слишком сложный, resolver сдаётся и возвращает intent Raw, не заставляя пользователя ждать.