readability_explained/explained.ru.txt

257 lines
20 KiB
Text
Raw Normal View History

2024-12-24 15:46:39 +04:00
Точка входа: 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
2024-12-24 19:38:25 +04:00
17. Переход к следующему элементу
2024-12-24 15:46:39 +04:00
***
*** Часть 2
Скоринг (оценка) найденных полезных элементов
2024-12-24 21:34:08 +04:00
Цикл по elementsToScore
2024-12-24 15:46:39 +04:00
let candidates = [];
2024-12-24 21:34:08 +04:00
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. Если текущая нода -- не элемент, либо у неё нет родителя, либо её родитель -- не элемент, то пропускаем
2024-12-25 12:08:03 +04:00
Теперь нам нужно записывать баллы "полезности" каждого из 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.
2024-12-25 12:13:12 +04:00
Добавляем элемент в массив candidates.
2024-12-25 12:08:03 +04:00
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;
2024-12-25 12:13:12 +04:00
Обратите внимание, contentScore мы явно не записывали элементу вне цикла 24, а только передавали эти баллы его родителю, а также частично другим предкам, и только предков добавляли в кандидаты, а не сам элемент.
2024-12-24 15:46:39 +04:00
***
*** Часть 3
Выбор кандидатов с наилучшим баллом
По умолчанию берём 5, настраивается параметром nbTopCandidates
2024-12-24 15:46:39 +04:00
25. Для каждого из элементов массива candidates вычисляем плотность гиперссылок по алгоритму из пункта 16.2, домножаем балл полезности node.readability.contentScore на 1-плотностьСсылок (0.25 => 0.75)
26. Берём максимум 5 элементов из массива candidates с самыми высокими баллами, сохраняем в массив topCandidates в порядке от лучшего к худшему
В ридабилити эта часть реализована одним циклом по candidates, где сначала выполняется пункт 25, затем topCandidates наполняется по алгоритму типа сортировки вставками -- если текущий элемент больше какого-то ранее добавленного в массив, то вставляем текущий перед ним, и если после этого длина массива стала больше 5, удаляем последний элемент; см. строки 1259-1289.
***
*** Часть 4
Коррекция результата выбора кандидата
let topCandidate = topCandidates[0];
let neededToCreateTopCandidate = false;
27.
2024-12-24 15:46:39 +04:00
***
**