Как отделить сквозные ссылки от контекстных, не парся вёрстку

в 17:26, , рубрики: pagerank, python, seo, внутренние ссылки, граф ссылок, краулер, перелинковка, структура сайта

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

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

Ниже способ отделить одно от другого, не трогая разметку. Он занимает тридцать строк, работает на любом движке и один раз меня обманул. Про то, как именно обманул, тоже будет.

Почему не парсить nav и footer

Первое, что приходит в голову: выкинуть из HTML теги nav, header, footer и aside, а ссылки собирать только из того, что осталось.

На одном сайте это работает. На двух уже нет.

У меня было два сайта на сравнение: свой на WordPress с кастомной темой и клиентский, где лендинговая часть собрана в Webflow, а блог живёт на отдельном WordPress. Четыре разных шаблона, четыре разных способа сверстать меню. В одном оно лежало в nav, в другом в div с классом из пяти слов, в третьем мега-меню собиралось скриптом из пунктов, разложенных в несколько списков.

Селекторы пришлось бы подбирать руками под каждый шаблон и переподбирать после каждой правки темы. Метод, который надо чинить после смены вёрстки, не метод.

Наивный порог и почему он врёт

Вторая идея: раз сквозная ссылка стоит почти везде, достаточно выкинуть все цели, на которые ссылается больше, скажем, 60 процентов страниц.

Работает, пока сайт однородный. Я посчитал так и получил осмысленные числа для основного раздела. А потом посмотрел на английскую версию.

Английских страниц на сайте 29 из 149, это 19 процентов графа. Английское меню стоит ровно на этих 29 страницах и физически не может перевалить за порог в 60 процентов от всего графа. В результате каждая ссылка английского меню засчиталась как контекстная, и раздел показал 15,2 редакционной ссылки на страницу при реальных 1,76.

Порог, заданный от размера всего графа, не видит навигацию раздела, который меньше порога. Это не настройка, которую надо подкрутить. Это дефект самой идеи считать долю по графу целиком.

Метод: считать повторы, а не факт наличия

Ключевое наблюдение простое. Сквозная ссылка стоит на странице фиксированное число раз.

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

Отсюда алгоритм:

  1. Собрать граф с повторами. Не дедуплицировать ссылки внутри страницы: повторы и есть сигнал.

  2. Для каждой цели взять распределение числа повторов по страницам.

  3. Если цель встречается почти на всех страницах, взять модальное число повторов. Это вклад шаблона.

  4. Контекстными считать только повторы сверх модального числа.

Модальное число, а не минимальное и не среднее. Минимальное сломается на странице, где ссылку почему-то не вывели. Среднее поедет вверх от страниц с редакционными ссылками, и метод начнёт съедать сам себя.

Секции

Остаётся починить то, на чём я обжёгся. Порог надо считать не от всего графа, а от раздела, который верстается своим шаблоном.

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

Внутри раздела порог в 85 процентов работает надёжно: ссылка либо в шаблоне и стоит почти везде, либо редакционная и стоит на единицах страниц. Середины почти нет.

Почти. Про исключение будет в ограничениях.

Код

Вход: словарь graph вида url -> список исходящих ссылок с повторами. Функция sect относит URL к разделу.

from collections import Counter, defaultdict

def body_links(graph, sect, share=0.85):
    """Делит рёбра графа на шаблонные и контекстные.

    graph: {url: [целевой_url, ...]} с повторами внутри страницы
    sect:  url -> имя раздела (свой шаблон = свой раздел)
    share: доля страниц раздела, с которой цель считается шаблонной
    """
    groups = defaultdict(list)
    for url in graph:
        groups[sect(url)].append(url)

    # вклад шаблона: модальное число повторов внутри раздела
    chrome = defaultdict(dict)
    for name, pages in groups.items():
        seen = defaultdict(dict)
        for page in pages:
            for target, n in Counter(graph[page]).items():
                seen[target][page] = n
        for target, per_page in seen.items():
            if len(per_page) >= share * len(pages):
                chrome[name][target] = Counter(per_page.values()).most_common(1)[0][0]

    # контекстные рёбра: повторы сверх шаблона
    body = defaultdict(Counter)
    for page, out in graph.items():
        name = sect(page)
        for target, n in Counter(out).items():
            if target == page:
                continue
            extra = n - chrome[name].get(target, 0)
            if extra > 0:
                body[page][target] = extra

    return body, chrome

Дальше считаем входящие и исходящие по контекстному графу:

def degrees(body):
    incoming, outgoing = Counter(), Counter()
    for page, targets in body.items():
        for target in targets:
            incoming[target] += 1
            outgoing[page] += 1
    return incoming, outgoing

PageRank поверх полного графа

Контекстный граф отвечает на вопрос «что редакция считает важным». На вопрос «куда физически стекается вес» отвечает полный граф, вместе с меню и подвалом. Нужны оба.

def pagerank(graph, d=0.85, iterations=60):
    nodes = list(graph)
    index = {u: i for i, u in enumerate(nodes)}
    n = len(nodes)
    out = {u: sorted({v for v in graph[u] if v in index and v != u}) for u in nodes}
    rank = {u: 1.0 / n for u in nodes}

    for _ in range(iterations):
        dangling = sum(rank[u] for u in nodes if not out[u])
        fresh = {u: (1.0 - d) / n + d * dangling / n for u in nodes}
        for u in nodes:
            if not out[u]:
                continue
            share = d * rank[u] / len(out[u])
            for v in out[u]:
                fresh[v] += share
        rank = fresh
    return rank

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

Что показали замеры

Оба сайта обошёл 24 сентября. Цифры ниже получены описанным методом.

Плотность редакционных ссылок. Клиентский сайт: 13,8 ссылки из текста на страницу. Свой: 5,8. Разница в 2,4 раза, и почти вся она живёт в блоге клиента, где плотность 16,5 против 6,3 на лендинговой части.

Концентрация веса. У меня 51 адрес из шаблона держит 81,7 процента PageRank. У клиента таких адресов 23, и они держат те же 81,7. Разные движки, разный размер, одна и та же доля. Плоское распределение это не признак здоровья, это признак того, что весь вес забрало меню.

Куда уходит остаток. Самое неприятное открытие: на моём сайте три юридических документа из подвала держат 6,3 процента веса, а все 26 кейсов вместе 5,0. У страницы «Согласие на обработку персональных данных» PageRank почти совпал с главной: 0,02621 против 0,02626, разница меньше четверти процента. Ссылка в подвале на всех 149 страницах делает своё дело.

Второй уровень. 69 дочерних страниц услуг, средний PageRank 0,00168, в десять с лишним раз меньше корневых. У каждой ровно 2-3 контекстные входящие, и одна из них всегда HTML-карта сайта. Убрать карту, и половина второго уровня останется на одной ссылке.

Найденные сироты. У клиента четыре пустые страницы от шаблона Webflow. Из XML-карты сайта их аккуратно вычистили, а из подвала забыли. Держат 14,2 процента внутреннего веса.

Ни одна из этих находок не видна на полном графе и не видна глазами при просмотре сайта.

Ограничения

Боковые блоки живут на границе. У клиента блок рубрик стоит на 55 страницах блога из 66, это 83 процента. При пороге 85 он проходит как контекстный и даёт рубрикам по 55 «редакционных» входящих, хотя это обычный сайдбар. Порог придётся подбирать под сайт, и это честнее, чем делать вид, что число универсальное. Признак подозрительный простой: если у группы целей входящие совпадают с точностью до единицы, это шаблон, а не редакция.

Динамические блоки. Похожие записи, «читайте также», списки последних постов. Формально это шаблон, фактически содержимое меняется от страницы к странице, поэтому модальное число повторов их не ловит. Для SEO они ближе к редакционным ссылкам, так что в моём случае это скорее плюс, но знать об этом надо.

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

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

Итог

Тридцать строк кода вместо подбора селекторов под каждый шаблон. Метод переживает смену темы, работает одинаково на Webflow и WordPress и не требует ничего, кроме графа ссылок с повторами.

Главное, что он показывает: структура сайта и распределение веса внутри него это две разные вещи. Сайт может выглядеть логично, иметь аккуратное меню и правильную вложенность адресов, а вес при этом будет стекать в подвал и в карту сайта.

Проверяется за вечер. Ломает несколько убеждений.

Автор: Neurounit

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js