Задача была прикладная: мы переносим интернет‑магазины на свой движок и хотели понять, сколько в зоне .ru магазинов на каждой платформе и как их находить. Готовые сервисы вроде BuiltWith продают выгрузки по подписке, но нам нужен был воспроизводимый обход, который можно гонять когда угодно и по своим правилам.

Написали свой пробер, обошли 53 305 доменов и получили распределение платформ. Заодно собрали коллекцию ловушек, на каждой из которых успели ошибиться. Про них и статья — цифры без описания граблей мало чего стоят.

Что считаем футпринтом

Определение CMS по странице — это поиск строк, которые платформа неизбежно оставляет в разметке. У нас девять наборов:

FOOTPRINTS = {
    "insales":     ["insales-cdn.com", "cdn.insales-cdn.com", "static.insales-cdn.com"],
    "bitrix":      ["/bitrix/js/main/core", "/bitrix/templates/",
                    "/bitrix/components/", "BX.message("],
    "woocommerce": ["wp-content/plugins/woocommerce", "woocommerce-page", "wc-add-to-cart"],
    "opencart":    ["catalog/view/theme", "Powered By OpenCart", "ocStore"],
    "cs-cart":     ["Powered by CS-Cart", "/design/themes/responsive", "CS-Cart"],
    "shop-script": ["/wa-data/", "Powered by Webasyst", "Shop-Script"],
    "advantshop":  ["advantshop"],
    "shopify":     ["cdn.shopify.com", "Shopify.theme"],
    "tilda":       ["tildacdn.com", "tilda.cc"],
}

Принцип отбора строк: брать то, что платформа кладёт в разметку по своей механике. Всё, что владелец сайта может убрать руками, идёт вторым эшелоном. /bitrix/js/main/core — это путь к ядру, он есть, пока сайт работает. А Powered by из футера снимается за пять минут, поэтому такие строки идут вторыми, для добора.

Обход — асинхронный, с двумя независимыми потолками: --concurrency 16 ограничивает число запросов в полёте, --delay 0.2 держит минимальный интервал между старта́ми. Второе важнее: именно delay задаёт реальную скорость (5 запросов в секунду), а concurrency только не даёт очереди распухнуть. Читаем не больше 300 КБ с страницы — футпринт всегда в первых килобайтах, дальше только трафик.

Результат

53 305 доменов, 77 522 HTTP‑запроса. Срез из Tranco и Majestic по зоне .ru.

Сначала честный знаменатель, потому что без него проценты врут:

Статус

Доменов

Доля

ok — страница получена

35 463

66,5%

timeout

6 511

12,2%

http_4xx

3 979

7,5%

dns — домен не разрешается

3 768

7,1%

conn — соединение не установилось

1 599

3,0%

tls — ошибка сертификата

1 211

2,3%

http_5xx

567

1,1%

прочее

207

0,4%

Треть доменов из ранкингов просто не отвечает. Мёртвые, припаркованные, за клаудфларой, с развалившимся TLS. Это первое, что стоит знать, если вы собираетесь считать доли платформ «по зоне.ru»: ваша выборка — не зона, а её отвечающая часть.

Из 35 463 ответивших футпринт нашёлся у 7 073 (13,3% от всех, 19,9% от ответивших):

Платформа

Доменов

1С‑Битрикс

4 600

Tilda

1 195

WooCommerce

730

OpenCart / ocStore

310

Shop‑Script (Webasyst)

161

CS‑Cart

59

InSales

30

AdvantShop

21

Shopify

1

Сумма по строкам — 7 107, а доменов 7 073, и разница в 34 попадания раскладывается не так, как хочется арифметике: несколько футпринтов дали 32 домена — у 30 по две платформы, у двух по три. Три платформы сразу — это inetlinks.ru (CS‑Cart + OpenCart + Shop‑Script) и digitalkassa.ru (AdvantShop + CS‑Cart + Shop‑Script); оба продают и обслуживают сами эти движки, поэтому их названия рассыпаны по разметке. Остальное — лендинг на Tilda и магазин на движке под одним доменом либо остатки старой платформы.

Если ваш код пишет платформу в одно поле, такие случаи молча теряются. И считать домены как «сумма попаданий» тоже нельзя: я на этом успел ошибиться на два домена, пока не пересчитал по записям.

Битрикс с большим отрывом первый, и это не новость. Интереснее хвост: Shopify в зоне .ru — один домен. Не «мало», а буквально один на 53 тысячи.

Ловушка 1: футпринт платформы ≠ работающий магазин

Самая дорогая ошибка. wp-content/plugins/woocommerce в исходнике означает, что WooCommerce установлен. Он не означает, что перед вами магазин.

Хуже: wp-content/uploads — это просто медиатека WordPress, она есть у любого блога. Если ослабить футпринт до wp-content, WooCommerce “найдётся” у половины интернета.

У нас был живой случай. Домен gtdv.ru пробер отдал как WooCommerce. При проверке: заголовок x-powered-by: Nuxt, фронт на Vue, wp-login.php и /wp-json/wc/v3 отдают 404. WordPress там когда‑то стоял и остался раздавать картинки из wp-content/uploads, а сам сайт давно переписан. Мы это поймали только потому, что дальше проверяли платформу отдельно и по исходнику.

Сначала это выглядело как единичный курьёз. Потом мы проверили руками ещё несколько сотен доменов — открывали исходник и подтверждали платформу живым признаком — и получили измеренную долю ошибки. Она оказалась неравномерной по платформам, и это само по себе полезнее общей цифры:

Платформа

Проверено вручную

Платформа не подтвердилась

Доля

WooCommerce

188

10

5,3%

InSales

137

4

2,9%

OpenCart

179

1

0,6%

Всего

504

15

3,0%

Про выборку сразу: домены Woo и OpenCart взяты из этого же прохода, а InSales — из отдельной выгрузки по футпринту, потому что в ранкинговом срезе их всего 30. Складывать их в одну долю можно только как оценку ошибки метода. Свойством зоны .ru это число не является.

У WooCommerce ошибка на порядок чаще, и причина в семи случаях из десяти одна и та же: CSS‑классы woocommerce- от темы оформления. Тема ставит их независимо от того, установлен ли плагин. В этих семи случаях в исходнике не было ни wp-content/plugins/woocommerce, ни рабочего роута — только классы: wp-json/wp/v2/product отдавал 404, /shop/ тоже. То есть футпринт, который выглядит специфичным для магазина, на деле принадлежит шаблону.

У OpenCart и InSales так не выходит, потому что их признаки механические: OCSESSID в куках и index.php?route= — это работа движка, а insales-cdn.com в URL картинок — работа их CDN. Ни то, ни другое не остаётся от одной лишь темы. Отсюда практический вывод: надёжность футпринта зависит не от того, насколько строку трудно подделать, а от того, кладёт ли её ядро платформы или оформление. Если строку может поставить тема — считайте её вторичной, чем бы она ни выглядела.

Оставшиеся три ошибки по Woo — другой природы: заброшенный WordPress под переписанным фронтом (gtdv.ru), WordPress вообще без WooCommerce, и домен, который редиректит на другой сайт. По InSales и OpenCart картина похожая поштучно: два сайта на Tilda и один на WordPress, ошибочно зачтённые как InSales; домен, перепроданный под онлайн‑казино и сохранивший старую разметку; и статичный лендинг‑сателлит без CMS, зачтённый как OpenCart (akb-v-spb.ru: ни catalog/view, ни route=, ни OCSESSID — только счётчики и перелинковка на восемь своих же доменов).

Надёжнее спросить у самой платформы, через её живой API:

# WooCommerce активен, а не просто «когда-то ставили WordPress»
import json, urllib.request

def woo_alive(domain):
    try:
        with urllib.request.urlopen(f"https://{domain}/wp-json/", timeout=10) as r:
            ns = json.load(r).get("namespaces", [])
        return any(n.startswith("wc/") for n in ns)     # wc/v3, wc/store
    except Exception:
        return False

Плюс признаки, которые появляются только у работающей витрины: класс woocommerce-page на странице каталога, ссылки ?add-to-cart=<id>, meta generator с версией. Один из наших магазинов определился именно по namespace wc/v3 в /wp-json/ — в разметке главной футпринтов не было вовсе, каталог рисовался скриптом.

Отдельно: мы прогоняли найденные домены вторым проходом, который ищет на сайте цену и корзину. Из доменов с футпринтом платформы реальными магазинами оказалась примерно треть:

Срез

Прогнано

Магазин (цена + корзина)

Каталог без цен

Не магазин

Битрикс, верх ранкингов

2 355

882 (37,5%)

131

1 342

WooCommerce

480

160 (33,3%)

55

265

Что оказалось в отбраковке — по платформам заметно разное.

У Битрикса это институции и крупные организации: банк, энергосбыт, патентное бюро, палата, ассоциация телерадиовещателей, учебный центр, медлаборатория, городская больница, музей, центр качества образования, сетевая компания. Плюс заводы с сайтом‑визиткой без продаж и франчайзи самого Битрикса.

У WooCommerce отбраковка другая по природе: цифровые товары с курсами (PDF‑выкройки вместе с мастер‑классами), изделия на заказ без цен, сайты производителей, продающих через дилеров, и просто блоги. WordPress берут те, кто продаёт что‑то нестандартное — или не продаёт вовсе.

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

Ловушка 2: бюджет запросов и гейт на страницы контактов

Нам нужны были ещё и контактные адреса. Наивный подход — забирать /contacts/, /kontakty/, /about/ у каждого домена. Считаем: 35 463 ответивших × 3 страницы ≈ 106 тысяч дополнительных запросов, то есть обход раздувается втрое.

Мы поставили гейт: страницы контактов забираются только если на главной адреса не нашлось И платформа определена. Второе условие ключевое — если футпринта нет, домен нам не интересен, и тратить на него запросы бессмысленно.

items = extract_emails(body, page_url, "homepage")
if (not items and self.cfg.email_pages and not self.cfg.offline_cache
        and base.get("status") == "ok" and base.get("platforms")):
    self.counts["l1_domains"] += 1
    got, used = await self.level1(base["domain"], page_url, body)
    items = merge_emails(items, got)

Что дал гейт на реальном прогоне. Без адреса на главной остались 19 325 доменов; из них платформа определена у 2 545. То есть уровень 1 отработал по 2 545 доменам и стоил 5 285 запросов вместо ~58 000 — экономия 91%.

Внутри уровня 1 ещё одна мелочь, которая экономит половину его бюджета: останавливаемся на первой странице, где нашёлся адрес. Остальные две не запрашиваем. И неудачный запрос (404, таймаут) считаем расходом бюджета, но не ошибкой домена — иначе один битый /contacts/ выкидывает живой магазин.

Итого адреса нашлись у 17 391 домена (16 138 с главной, 1 253 со страниц контактов), принято 30 803 адреса.

Ловушка 3: в темах платформ живут плейсхолдеры

Регулярка на email соберёт вам мусор, который выглядит как настоящий контакт. Реальные находки из нашего прогона:

Отдельный класс — адрес есть в разметке, но не в видимом тексте. У одного домена контакт нашёлся только внутри скрытого мета‑тега верификации каталога (cataloxy-verification), на странице его не видно. Формально строка на странице присутствует, и любая программная сверка её подтвердит. Такой адрес брать нельзя — он не предназначен для входящей почты.

Ещё: mailto: на иконке в футере без видимого текста. Технически адрес есть, для человека его на странице нет.

Ловушка 4: домены‑редиректы прячут магазины

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

У нас так вышло с stabiline.ru: он редиректит на nizkovoltka.ru — работающий магазин электротехники с 2000+ наименований и складами в четырёх регионах. Самого nizkovoltka.ru в выгрузке нет вообще: в ранкинги он не попал и виден только через редирект с другого домена. Мы нашли его случайно, при ручной проверке.

Мораль: если пишете такой обход, сохраняйте final_url отдельно от запрошенного домена и сверяйте их. У нас поле есть, но сопоставлением мы занялись поздно. Сколько магазинов спрятано за редиректами — не знаем, и это честный пробел в наших данных.

Главное ограничение: ранкинги смещают выборку

Это про то, чего цифрами выше не видно.

Tranco и Majestic ранжируют домены по трафику и ссылочному профилю. Значит любой срез из них это выборка популярных сайтов. Всех остальных в ней нет. Для платформы, чьи магазины сидят ниже топ-1M, обход по ранкингам её просто не увидит.

У нас есть замер, который это показывает численно. Мы гоняли тот же пробер по зоне .kz: 2 634 домена, 214 попаданий, из них InSales — 58. Сравнили с ручными выгрузками BuiltWith по той же зоне: пробер добавил ровно 1 новый домен InSales из 58. Остальные 57 уже были в выгрузках. И наоборот — 66 из 69 доменов из выгрузок в ранкингах отсутствовали вовсе.

Отсюда и InSales - 30 в таблице по .ru. Это не значит, что в России 30 магазинов на InSales. Это значит, что выше топ-1M их 30, а основная масса живёт ниже и обходом по ранкингам не достаётся.

Вывод, который стоит забрать: обход по ранкингам и выгрузки по футпринту — не конкуренты, а разные инструменты. Ранкинги хороши для платформ, которые стоят у крупных сайтов (Битрикс), и слепы к платформам массового малого магазина. Чтобы копать ниже топ-1M, нужен другой сид: логи Certificate Transparency (crt.sh), отраслевые каталоги, площадки‑агрегаторы.

Побочное наблюдение про 1С

Отдельно и с оговоркой: это не статистика по популяции, а результат ручной проверки — мы открывали страницы и смотрели исходник глазами. Выборка неслучайная: это домены, которые прошли предыдущие фильтры, то есть смещённая в сторону работающих магазинов часть.

По вручную проверенным доменам явный признак обмена с 1С (упоминание 1С/УТ/выгрузки, модуль обмена в исходнике, оптовый кабинет с ценами по договору) распределился так:

Платформа

Проверено вручную

Признак 1С

Доля

1С‑Битрикс

240

174

72,5%

WooCommerce

188

0

0%

InSales

137

0

0%

OpenCart

179

2

1,1%

Снаружи 1С видна практически только на Битриксе. На трёх других платформах — 2 случая на 504 домена. Оговорка обязательна: это признак, видимый снаружи. Факта наличия учётной системы он не доказывает. Магазин вполне может выгружаться из 1С через закрытый API, и на страницах этого не будет.

Но и в таком виде наблюдение полезно, потому что асимметрия слишком велика, чтобы быть артефактом критерия. Похоже, дело в том, что связка с 1С на Битриксе делается штатным модулем обмена от самого вендора и оставляет следы в разметке, а на остальных платформах её ставят сторонним плагином или пишут интеграцию мимо витрины.

И грабля в самом критерии, чисто механическая: поиск подстроки «1с» ловит артикулы. У поставщика трубопроводной арматуры сработало на коде товара 11с10фт (Broen Ballomax) — пришлось снимать вручную. Признак из двух символов будет находить мусор в любой номенклатуре: нужен либо контекст вокруг вхождения, либо словарь исключений.

Оба случая на OpenCart, кстати, нашлись по побочным следам, прямых упоминаний там не было: у одного магазина картинки лежали в /image/cache/catalog/1c/ с GUID‑именами (так выгружается номенклатура), у другого скрипт темы декодировал описания «если 1С выгрузила их». Автоматически такое не найти.

Зато у восьми Woo‑доменов из 188 обнаружился плагин обмена с другой системой: amoCRM (трижды), Битрикс24 (трижды), МойСклад и RetailCRM — видно по путям плагинов в исходнике (woocommerce-amocrm-integration, woocommerce-bitrix24-integration, woo-retailcrm). То есть учёт есть, но не 1С, а облачный.

Важная деталь про критерий: у WooCommerce артикулы (SKU) есть у всех товаров по умолчанию, поэтому «есть артикулы и остатки» про интеграцию с 1С не говорит ничего. На Битриксе этот признак работает, на Woo — нет. Мы на этом ошиблись: сначала зачли оптовый кабинет с ценами после логина как признак 1С, потом сняли — кабинет означает какую‑то учётную систему, но у Woo это обычно облако.

И ещё, из той же ручной проверки: 2 сайта из 188 на WordPress оказались взломаны — казино‑спам в блоге свежими датами и десятки исходящих ссылок на игорные домены. Выборка маленькая, обобщать не буду, но если вы обходите WordPress‑сайты, добавьте проверку на доорвей‑контент: это одновременно и мусор в данных, и признак, что почтовая настройка домена может быть не под контролем владельца.

Что мы не мерили

Чтобы не создавать ложного впечатления полноты:

  • Не мерили динамику. Один прогон, один срез по времени. Сколько магазинов переезжает с платформы на платформу — по этим данным сказать нельзя.

  • Не считали долю рынка. У нас доли внутри отвечающей части ранкингового среза, и это не то же самое, что доли по зоне.

  • Не обходили ниже топ-1M. См. раздел про смещение — это главный пробел.

  • Не разбирали 28 390 доменов, которые ответили, но футпринтов не дали. Там и самописные движки, и headless, и платформы, для которых мы не завели наборы, и сайты, отдающие пустую оболочку под JS.

  • Не проверяли robots.txt в этом прогоне (respect_robots: false), обход шёл с User‑Agent с контактом и лимитом 5 запросов в секунду на весь прогон. Если повторяете — решайте это сами, у вас может быть другая политика.

И отдельно про то, кому вся эта методика не подходит. Если задача собрать одну нишу, ранкинговый срез её не даст: внутри ниши в нём окажется только верхушка, а весь длинный хвост останется за бортом. Тогда сид берётся из отраслевого каталога или из поисковой выдачи по запросам, и пробер идёт уже по этому списку. Ловушки ниже по тексту останутся в силе, а вот распределение платформ придётся считать заново — оно будет другим.

Если хотите повторить

Порядок, который у нас в итоге сложился:

  1. Взять сид (ранкинги — только если целевая платформа стоит у крупных сайтов).

  2. Обойти главные, писать в JSONL всё: статус, http_status, final_url, запрошенные URL, найденные футпринты. Отдельным полем — ошибку.

  3. Не выбрасывать неответивших: треть выборки в них, и по распределению статусов видно качество сида.

  4. Платформу подтверждать живым признаком: API‑namespace, класс на странице каталога, add-to-cart. Наличие файлов само по себе не значит ничего.

  5. Второй проход — «а магазин ли это»: цена плюс корзина. Готовьтесь потерять две трети.

  6. Адреса добирать со страниц контактов только под гейтом, и с чёрным списком плейсхолдеров.

  7. Сверять final_url с запрошенным доменом — за редиректами прячутся сайты, которых в сиде нет.

Обход 53 тысяч доменов занял одну ночь и уложился в 77,5 тысяч запросов. Самой дорогой частью оказалась не сеть, а понимание того, что найденный футпринт и работающий магазин — разные вещи.