Статья написана в пользовательском блоге — редакция Партнеркина не вносит изменения в текст. Вся орфография, пунктуация и содержание сохранены
14.10.2026 0 115

Как я автоматизировал линкбилдинг: база на 130 000 площадок, парсер и сотни размещений в неделю

Привет, меня Баст зовут. Для кого-то я Ганский, для кого-то друг, для кого-то коллега, для кого-то никто. В общем, веду канал Журнал изменений в тг про SEO, полезный софт, фреймворки и автоматизацию. Годы опыта и сотни личных сайтов сформировали некоторый бэкраугд, которым хочется поделиться. 

Условно бесплатные ссылки.

В линкбилдинге больше всего времени съедает не само размещение ссылки. Основная рутина начинается задолго до этого. Нужно найти площадку, проверить, живая ли она, понять, на каком движке работает, есть ли там регистрация, индексируется ли пользовательский контент, не заброшен ли сайт и имеет ли вообще смысл тратить на него время. Часть этой работы раньше делалась руками, часть отдавалась подрядчикам. 

За годы работы с сеошкой в разных амплуа у меня накопились списки доноров, результаты удачных и неудачных размещений и куча других повторяющихся паттернов.
Стало очевидно, что все это можно перестать хранить в виде разрозненных таблиц, заметках или в закрепленных сообщениях и превратить в систему. Из этого и вырос linkops - инструмент для почти автоматического крауда. Нет, это не сервис, это ряд скриптов, набор небольших модулей, каждый из которых отвечает за конкретную задачу.

Накопленные данные скормлены ИИшкам, с просьбой как-то систематизировать все и упорядочить. В одной из заметок нахожу статью про Common Crawl* и говорю Клоду обратить на нее внимание. Спустя пару минут диалога мы решаем использовать их данные как источник потенциальных площадок размещения. ИИшка пишет простой скрипт, мы селим его в облаке запускаем и забываем на пару дней. Позже по логам видим что скрипт лег через 6 часов после запуска, но сотни тысяч сайтов собрал. Дальше скрипты начали добавляться с большим энтузиазмом.

*Common Crawl - некоммерческая организация которая регулярно сканирует глобальную сеть и бесплатно предоставляет всем желающим огромный архив собранных веб-страниц.

Структура

Дальше будет ряд технически нюансов, которые закрытый ииагент пропустит либо откажется делать с нуля из-за своей политки безопасности. 

sources.py      поиск площадок
classify.py     определение движка
scout.py        поиск регистрации
gate.py         анализ препятствий
queue_gen.py    создание заданий
runner.py       выполнение очереди
place.py        одно размещение
anchors.py      выбор анкоров
content.py      пул материалов
record.py       учет результатов
placements.py   история размещений

Мне нравится именно такой подход. Чем уже зона ответственности у каждого модуля, тем проще понять, где что сломалось. Универсальный скрипт, который сам все делает, звучит красиво ровно до первого запуска. Например быстро выяснилась, что поиск надо сузить. В первый запуск скрипта в базе оказалось пол интернета.
Характерные следы движка можно найти прямо по структуре URL. Путь и параметры ссылки разбираем отдельно, для точности. Нас интересовали сайты с полем/страницей регистрации в разных вариантах. 

path_part, _, query_part = f["value"].partition("?")
if query_part:
needle = query_part.split("=")[-1]
clauses.append("(url_path LIKE ? AND url_query LIKE ?)")
params += ["%" + path_part + "%", "%" + needle + "%"]
else:
clauses.append("url_path LIKE ?")
params.append("%" + path_part + "%")

Специфичный адрес регистрации при широком поиске легко превращается в плохой шаблон. Раздельная проверка пути и query-параметров заметно снижает шум. После нескольких итераций база выросла примерно до 130 тысяч полезных строк. Это не 130 тысяч хороших доноров. Это 130 тысяч причин подумать нужна мне эта ссылка. 

Фильтрация данных

Следующая проблема: понять что вообще найдено. Куча сайтов на  Вордпрессе, бустрапе, дрюпале, mybb, smf, джанга и ряд самопистных историй, которые падали в одну группу. Определять движок по одному признаку ненадежно, поэтому прошу систему смотреть сразу на несколько сигналов: generator, HTML и cookies. 

for name, sig in PLATFORMS.items():
score, ev = 0.0, []
if sig.get("generator") and re.search(sig["generator"], gen, re.I):
score += 0.6
ev.append(f"generator~{name}")
for pat in sig.get("html", []):
if re.search(pat, head, re.I):
score += 0.25
ev.append(f"html~{pat}")
for pat in sig.get("cookies", []):
if re.search(pat, cookie_blob, re.I):
score += 0.2
ev.append(f"cookie~{pat}")

Здесь score эвристическая оценка, а не статистическая вероятность. Вместе с результатом сохраняются признаки, на основании которых движок был определен. Если классификатор ошибся, видно почему. Можно поправить конкретное правило, а не гадать, что произошло внутри. После классификации начинается уже нормальная фильтрация. Проверяется доступность сайта, язык, активность, DR, наличие пользовательского контента, регистрация и история предыдущих проверок. Отдельные scout.py, gate.py и gate_scan.py отвечают за формы регистрации и сохраняют вердикт еще до того, как площадка попадет в очередь на регистрацию. В результате runner.py не занимается разведкой на месте. К моменту запуска у него уже есть подготовленная задача и понимание, с чем предстоит работать. 

Безопасность

Проверки идут асинхронно, но число одновременных запросов ограничено обычным семафором. Важный кусок, без которого мне заблокировали сервак за подозрительно бурную деятельность. После восстановления сервака результат каждой проверки сохраняется в базу. Если процесс остановился на восьмой тысяче сайтов, не нужно повторять уже сделанное. Данные с сервера идут ко мне в гит, в закрытый репозиторий. Такие вещи кажутся мелочью, пока однажды не приходится ждать повторный прогон несколько часов.

sem = asyncio.Semaphore(concurrency)
async def one(c, host, hint):
nonlocal done
async with sem:
pf = await L.preflight(c, host, hint_url=hint)
L.save_preflight(conn, pf)
done += 1
 

Само размещение начинается только после отбора. queue_next.py ранжирует площадки, queue_gen.py собирает задания, runner.py берет их в работу, а place.py исполняет конкретный процесс. Система сделала 346 размещений написанных на ходу. Почти везде с одинаковым типом анкора. Технически задача была выполнена. С практической точки зрения я автоматизировал не самое хорошее решение. После правок контент и анкоры разъехались в отдельные модули. Агенту отдается список наших сайтов, отдельно готовится пул материалов, а anchors.py уже решает, какой вариант ссылки использовать в конкретном задании. Это один из тех случаев, когда автоматизация сначала создает проблему в масштабе, а затем точечно корректирует. 

Антиботы

Есть более неприятная часть. Найти подходящую площадку мало, на ней еще нужно создать аккаунт. Причем к этому моменту система уже знает движок сайта, поэтому нет смысла каждый раз исследовать форму с нуля. Под разные платформы лежат свои рецепты: регистрация, подтверждение аккаунта, вход и публикация. Общие операции вынесены в слой registration/. На одной площадке после регистрации нужно перейти по ссылке из письма, на другой ввести код, третья отправляет аккаунт на ручную модерацию. Где-то есть дополнительный вопрос, где-то картинка, где-то все вместе, потому что администратор форума в 2012 году серьезно относился к безопасности. Но все равно, 9 из 10 сайтов с капчей обходятся  скриптом и бесплатным тарифом Gemini.

async def solve_image(image_b64: str, timeout: int = 120, **hints) -> str | None:
return (
await solve_image_vlm(image_b64, **hints)
or await solve_image_2captcha(image_b64, timeout, **hints)


Сначала ответ пытается получить мультимодальная модель. Если результата нет, используется внешний сервис. Для остальной системы принципиальной разницы между ними нет: рецепт регистрации отправил изображение и получил строку обратно. Чем меньше конкретный рецепт знает о внутренних деталях остальных модулей, тем проще потом что-то менять без очередного ремонта всей конструкции. Почта потребовала примерно такого же подхода. Для работы у меня два пула ящиков, Hotmail и Yahoo. Система берет свободный адрес, регистрирует аккаунт, а дальше уже сама ждет подтверждение через API. Письмо пришло, из него вытаскивается ссылка или код, процесс продолжается. Не пришло, задача остается в соответствующем состоянии и не записывается в успешные. 

ver = accounts.verifier_for(acct["email"])
link = ver and ver.find_confirmation_link(
link_keywords=[_bare(host)], timeout=150)
if link:
print(f"[{host}] активация: {link[:90]}")
await page.goto(link, wait_until="domcontentloaded", timeout=60000)
await page.wait_for_timeout(3000)
after = (await _content(page)).lower()
if any(m in after for m in APPROVAL_MARKERS):
print(f"[{host}] активирован, но ждёт одобрения админа")
_mark(host, acct, "PENDING_APPROVAL", "активирован, ждёт администратора")
return False
if await login(page, base, acct):
return True

Здесь уже хорошо видно, зачем вообще понадобились состояния. Форма отправлена еще не значит, что аккаунт существует. Ссылка из письма открыта еще не значит, что можно войти. Аккаунт может быть активирован, но ждать администратора. На десятке сайтов такие вещи можно держать в голове. На сотнях начинает напрягать. Отдельная история появилась с контрольными вопросами. На phpBB администратор может поставить собственный текстовый вопрос, причем иногда он меняется при каждой загрузке формы. Система сначала ищет ответ среди известных вариантов, а если его нет, передает вопрос модели:

hit = qa.answer_question(question, host, "phpBB") or None
if hit is None:
from registration import captcha as CAP
hit = await CAP.solve_text(question)
print(f"[{host}] вопрос человеку: {question[:70]!r} -> {hit!r}")
if not hit:
print(f"[{host}] на этот вопрос ответа нет")
return "no_answer"
await page.fill(sel, hit)

Мне нравится не столько участие модели, сколько обработка неудачного ответа. Если ответа нет, система ничего не придумывает и не списывает площадку целиком. Она возвращает no_answer. Форму можно открыть заново, подключить агента поумнее и получить другой результат за пару минут. При этом аккаунт еще не создан и почтовый ящик не израсходован.

 

На этом регистрационные проблемы не заканчиваются. Для первичного анализа площадки хватает обычных HTTP-запросов, но дальше нужен полноценный браузер. Логин, активация, редакторы, различные состояния страниц. В результате рядом в облаке появились пара браузеров, proxy.py, fingerprints.py, page_state.py и несколько сценариев повторной проверки. Задача здесь не в том, чтобы сделать браузер максимально хитрым. Скорее наоборот, хотелось убрать случайность. Если система уже работает с конкретной площадкой, нет особого смысла каждый раз приходить туда с совершенно новым состоянием.

Для прокси используется простой детерминированный расчет:

def port_for(host: str) -> int:
"""Рандомный сервис с резидент прокси раздаёт выходной IP по номеру порта: 00001-00100.
Один и тот же донор всегда идёт через один IP, разные не толкаются."""
return 10001 + (sum(map(ord, host)) % 100)
def conf(port) -> dict:
"""Прокси в формате Playwright/Camoufox."""
return {
"server": f"http://{HOST}:{port}",
"username": USER,
"password": PASS
}

Домен получает один стартовый порт. Это не гарантирует вечный постоянный IP, здесь многое зависит от провайдера, но хотя бы сама система перестает выбирать маршрут случайным образом.С отпечатком браузера похожая логика. Для нового хоста он создается один раз и сохраняется:

p = path_for(host)
if p.exists():
return json.loads(p.read_text())
from browserforge.fingerprints import FingerprintGenerator
fp = FingerprintGenerator(browser="firefox").generate()
data = _to_dict(fp)
p.write_text(json.dumps(data, ensure_ascii=False, indent=1))
return data

Camoufox работает на Firefox, поэтому и профиль генерируется под Firefox. При следующем обращении к тому же хосту система берет сохраненный вариант. Никакого сложного решения здесь нет. Просто еще одно место, где предсказуемость оказалась полезнее попытки каждый раз сделать что-нибудь умное.

Параллельно появился page_state.py и повторные браузерные проверки. Раньше странный ответ площадки помечался как мусор. Теперь можно отличить реально мертвую страницу от временной ошибки, необходимости повторного входа или ситуации, когда первоначальной проверки просто оказалось недостаточно. На базе в сотни тысяч записей даже небольшой процент ложных отказов превращается в тысячи потерянных кандидатов, поэтому этот слой оказался вполне оправданным.

Часть ошибок оказывается прозаичнее всей этой инфраструктуры. Например, SCEditor. На некоторых форумах ссылку можно добавить в подпись профиля. Скрипт находил поле, записывал текст, визуально все выглядело нормально, задача отмечалась выполненной. Только после сохранения подпись оказывалась пустой. Причина в том, что SCEditor хранит собственное состояние. Изменить один textarea оказалось недостаточно. Пришлось обновлять и обычное поле, и значение внутри редактора:

async def _fill_signature(page, text: str) -> bool:
sel = await _find_field(page)
if not sel:
return False
await page.evaluate(
"""([s, t]) => {
const el = document.querySelector(s);
el.value = t;
el.dispatchEvent(new Event('input', {bubbles: true}));
try {
if (sceditor && sceditor.instance(el)) {
sceditor.instance(el).val(t);
}
} catch (e) {}
}""",
[sel, text]
)
return True

 

Не было момента, когда я сел и заранее придумал всю архитектуру. Сначала появился поиск площадок. Реальные результаты показали ошибки. Появилась оценка движков. Массовые проверки потребовали ограничить параллельность. Часть размещений показали проблему анкоров. Регистрация потянула за собой почту и состояния аккаунтов. Браузерные сценарии добавили прокси, сохраненные отпечатки и повторные проверки. А потом какой-нибудь старый форум спокойно ломает весь сценарий одним лишним полем.

Сейчас скрипты продолжают дорабатываться, но система уже дает сотни размещений в неделю разного качества. Ahrefs подключен через API и участвует в фильтрации площадок. Например, для части сценариев можно задать минимальный DR и отдельно смотреть, передает ли страница вес. Это не делает оценку объективной, но добавляет еще один сигнал к общей картине.

Это не публичный сервис. Части кода можно использовать для экспериментов со своей иишкой, ее моральные капризы не должны активироваться. Свою версию в скором времени покажу в канале, ну а тебе этих кусков будет достаточно что бы навайбкодить что-то свое.

Как вам статья?
Похожие статьи