readability_explained/explained.ru.txt

238 lines
19 KiB
Text

Точка входа: Readability.parse() строка 2679
1. Ищем картинки без сорца, ищем noscript с <img> внутри (либо div>div>...>img, если во всех контейнерах больше нет других детей и нет текста), и если этот носкрипт идёт после тега картинки, заменяем картинку на ту, что из носкрипта, с сохранением атрибутов оригинальной
2. Вытаскиваем все <script type="application/ld+json">, убираем CDATA-маркеры по регулярке /^\s*<!\[CDATA\[|\]\]>\s*$/g, проверяем схему JSON-LD и сохраняем себе метаданные страницы в отдельный объект
3. Вот теперь убираем все <script> и <noscript>
** _prepDocument()
4. Убираем все <style>
5. Меняем двойные переносы <br><br> на врап в абзацы <p>
6. Меняем <font> на <span>
**
7. Вытаскиваем метаданные из <meta> с учётом ранее полученного JSON-LD, если он был (данные из него приоритетнее, <meta> только дополняет)
** _grabArticle() -- по сути главный алгоритм
*** Часть 1
Проход по всему дереву DOM, начиная с <html>
let elementsToScore = [];
Функция получения следующего элемента:
1. если есть дети, то переходим к первому из них
2. если есть соседи, то переходим к следующему в дереве
3. иначе поднимаемся наверх к родителям, пока есть куда подниматься (то есть node.parentNode != null) и пока мы последние в соседях (то есть пункт 2 уже выполнялся для этих родительских элементов)
8. Если у элемента:
style.display!=none &&
style.visibility!=hidden &&
нет атрибута hidden &&
(aria-hidden!="true" || есть класс .fallback-image)
то он видимый для пользователя, не трогаем,
иначе удаляем и переходим к следующему (см. функцию перехода)
9. Если у элемента одновременно атрибуты aria-modal=true и role=dialog, то он невидимый, удаляем и переходим
10. Если в метаданных нет автора и мы ещё не встречали и не парсили блок с автором статьи (англ. byline), то проверяем для текущего элемента атрибуты:
rel=author ||
itemprop содержит подстроку "author"
либо
класс или айди матчится по регулярке
/byline|author|dateline|writtenby|p-author/i
и при этом длина текста внутри элемента должна быть меньше 100 знаков (0 > textContent > 100)
тогда этот элемент очень похож на блок с автором статьи, его textContent без лишних пробелов по краям мы сохраняем себе, а сам элемент удаляем из дерева и делаем переход к следующему
11. Если ещё ни разу не встречали <h1> или <h2>, который дублирует заголовок статьи из метаданных, и текущий элемент как раз H1 или H2, то сравниваем его с заголовком, и при совпадении более 75% считаем, что уже встретили на странице дублирующий заголовок, больше не проверяем это условие, а текущий элемент удаляем и делаем переход
Алгоритм проверки совпадения текста: переводим всё в lowercase, разбиваем обе строки textA и textB на массив токенов tokensA и tokensB по регулярке /\W+/g, фильтруя пустые строки (другой вариант, работающий идентично, и не возвращающий пустых строк: находим все вхождения регулярки /\w+/g, то есть match/findall вместо split и \w вместо \W); при пустых массивах токенов сразу возвращаем 0%; ищем уникальные токены textB, то есть которых нет в textA (но Set здесь не нужен, повторяющиеся уникальные токены мы тоже сохраняем, по крайней мере если в точности повторять алгоритм ридабилити); делим сумму длин уникальных токенов textB на сумму длин всех токенов textB, это будет расстоянием; возвращаем (1 - расстояние), то есть 0.25 => 0.75
12. Если класс или айди матчится по регулярке
/-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote/i
и при этом класс и айди НЕ матчатся по регулярке
/and|article|body|column|content|main|shadow/i
и при этом элемент находится не внутри <table> и не внутри <code> (при проверке родителей поднимаемся наверх максимум три раза),
и при этом элемент сам не <body> и не <a>,
тогда это нежелательный элемент, удаляем и переходим к следующему
13. Если атрибут role один из:
menu, menubar, complementary, navigation, alert, alertdialog, dialog,
тогда это нежелательный элемент, удаляем и переходим к следующему
14. Если это <div>, <section>, <header> или <h1-6>, у которого внутри нет никакого текста (кроме пробелов; textContent.trim().length == 0), у которого нет детей либо только <br> и <hr>, то это элемент без контента, уверенно удаляем и делаем переход
15. Если это <p>, <section>, <h2-6>, <td> или <pre>, то добавляем элемент в массив elementsToScore -- их мы точно будем проверять на полезность, это уже не выглядит как мусор в отличие от проверяемых выше условий
16. Если это <div>:
16.1. Весь фразовый контент* собираем в блоки параграфов
Например, эта разметка:
<div>
<span>Hello</span>
World
<b>!!</b>
<a href="#phrasing">abc</a>
<div>Блок 1</div>
<a href="#with-block">
<div>Блок 2</div>
</a>
<canvas></canvas>
n<sup>2</sup> = n * n
</div>
Станет:
<div>
<p>
<span>Hello</span>
World
<b>!!</b>
<a href="#phrasing">abc</a>
</p>
<div>Блок 1</div>
<a href="#with-block">
<div>Блок 2</div>
</a>
<canvas></canvas>
<p>
n<sup>2</sup> = n * n
</p>
</div>
*Phrasing content включает в себя:
- текстовые ноды
- элементы <a>, <del>, <ins> в случае, если они содержат в себе только фразовый контент
- элементы: abbr, audio, b, bdo, br, button, cite, code, data, datalist, dfn, em, embed, i, img, input, kbd, label, mark, math, meter, object, output, progress, q, ruby, samp, select, small, span, strong, sub, sup, textarea, time, var, wbr
Детали реализации см. в строках 1149-1169
Вкратце: цикл по детям div-а:
- если встретили фразовый контент и нет активного параграфа (p==null, то есть либо мы в самом начале, либо после нефразового элемента) и этот элемент/текстнода не пустой и не из пробелов, то создаём параграф в переменной p, вставляем туда этот фразовый контент, а в DOM заменяем элемент на параграф (таким образом враппаем контент)
- если встретили фразовый контент при уже созданном активном параграфе, то просто добавляем его в параграф, без проверок на пустоту-пробелы (хотя полностью пустые можно и пропускать)
- если встретили НЕфразовый элемент при активном параграфе, во-первых, очищаем созданный параграф от пробельных-пустых элементов в конце, во-вторых, убираем активный параграф из временной переменной -- следующий фразовый контент нужно будет добавлять в новый параграф, а не в этот же; текущий параграф уже есть в DOM, мы его не потеряем никак, делаем p=null
16.2. Если div содержит только один параграф и больше ничего, даже нет текстовых нод, то проверяем, чтобы плотность гиперссылок в нём была меньше 25%, и тогда заменяем этот <div> на <p> внутри него (анвраппаем/развёртываем параграф), добавляем параграф в массив elementsToScore
Вычисление плотности гиперссылок: для каждого <a> считаем длину текста и, если это ссылка на #якорь на странице, домножаем длину на 0.3, иначе оставляем как есть (домножаем на 1); делим сумму длин ссылок на длину всего текста
16.3. Если div не прошёл условие 16.2 с <p> и плотностью ссылок, проверяем другой вариант -- если блок не содержит в себе элементов:
div, p, blockquote, dl, img, ol, ul, pre, table,
то есть семантически все его дети -- инлайн-элементы, а не блочные,
то меняем элементу тег с DIV на P и добавляем его в массив elementsToScore
17. Переход к следующему элементу
***
*** Часть 2
Скоринг (оценка) найденных полезных элементов
Цикл по elementsToScore
let candidates = [];
18. Если у элемента нет родителя, либо родитель -- не элемент, то пропускаем; не понимаю смысла этой проверки, мы в любом случае искали внутри body, то есть родитель будет; а нода не является элементом и при этом может быть родителем (содержать элементы) только когда она -- документ; ну ладно
19. Если длина текста элемента меньше 25, то пропускаем
20. Ищем предков элемента (родителя, затем родителя родителя) максимум до 5 уровня, записываем в массив ancestors
21. Если в ancestors оказалось 0 элементов, пропускаем; аналогично -- не понимаю смысла проверки, если уже из пункта 18 мы точно знаем, что родитель есть
let contentScore = 1;
// Инициализируем переменную с баллами полезности элемента, базовое значение -- единица
22. Ищем количество запятых в тексте + 1 и прибавляем к contentScore
Примечание: выше сказано про +1 для полного соответствия этого описания с алгоритмом -- там делают .split по запятым и прибавляют длину массива к contentScore, а если вместо сплита матчить строку по запятым (искать вхождения), то результат будет на единицу меньше
Примечание: запятые бывают разные, в ридабилити применяют регулярку:
/\u002C|\u060C|\uFE50|\uFE10|\uFE11|\u2E41|\u2E34|\u2E32|\uFF0C/g
23. За каждые 100 символов текста прибавляем по баллу, максимум 3 раза (100-199 => 1; 200-299 => 2; 300-inf => 3; 400 => 3; 100500 => 3)
24. Цикл по массиву ancestors (пункт 20), содержащему предков элемента -- сначала родителя, потом родителя родителя, и так до 5-й "глубины":
24.1. Если текущая нода -- не элемент, либо у неё нет родителя, либо её родитель -- не элемент, то пропускаем
Теперь нам нужно записывать баллы "полезности" каждого из ancestors. Они НЕ равны contentScore элемента вне цикла пункта 24, мы их сейчас будем считать отдельно. В ридабилити баллы элемента сохраняются в поле DOM-объекта ноды .readability.contentScore (см. ниже в 24.2)
24.2. Если у текущего элемента есть поле .readability, то переходим сразу к 24.3, иначе элемент необходимо "проинициализировать":
node.readability = { contentScore: 0 };
Посмотрим на тег элемента и добавим к .readability.contentScore за него баллы:
div => +5
pre, td, blockquote => +3
А за эти теги убавим баллы:
address, ol, ul, dl, dd, dt, li, form => -3
h1-6, th => -5
Посмотрим на класс.
Если он матчится по регулярке
/-ad-|hidden|^hid$| hid$| hid |^hid |banner|combx|comment|com-|contact|footer|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|widget/i
то -25 баллов к node.readability.contentScore.
А если он матчится по регулярке
/article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story/i
то +25 баллов к .readability.contentScore.
Аналогично делаем для айди: матч по первой регулярке отнимает 25, матч по второй прибавляет 25.
24.3. Теперь вспоминаем про contentScore для элемента вне цикла пункта 24, для которого мы искали предков. Баллы полезности этого элемента частично получают и его предки.
Если текущий ancestor (мы ещё в цикле 24, напоминаю) -- это родитель элемента вне цикла, то есть индекс в массиве ancestors у него = 0 (первый найденный предок), то делитель = 1, и к баллам полезности предка node.readability.contentScore мы прибавляем баллы полезности элемента вне цикла contentScore (делённые на 1, то есть родителю достаются все баллы).
Если текущий ancestor -- это родитель родителя элемента вне цикла, то есть его индекс в массиве ancestors = 1 (второй найденный предок), то делитель = 2, и к его баллам полезности .readability.contentScore мы прибавляем половину contentScore элемента вне цикла.
Если уровень/глубина предка >= 2, то есть он выше родителя родителя, считаем делитель так: level * 3, где level -- это индекс в массиве ancestors (от 0 до 4, так как мы брали максимум 5 предков, точнее, от 2 до 4, так как значения делителей для индексов 0 и 1 отдельно уже указаны выше), прибавляем к баллам полезности предка .readability.contentScore баллы полезности элемента вне цикла contentScore / делитель.
С кодом понятнее, думаю:
let scoreDivider;
// level -- индекс в ancestors
if (level === 0) {
scoreDivider = 1;
} else if (level === 1) {
scoreDivider = 2;
} else {
scoreDivider = level * 3;
}
ancestor.readability.contentScore += contentScore / scoreDivider;
Обратите внимание, contentScore мы явно не записывали элементу вне цикла 24, а только передавали эти баллы его родителю, а также частично другим предкам.
***
*** Часть 3
***
**