Веб-скрейпинг и .Net

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


    Итак, собственно веб-скрейпинг в моём понимании – это перенос данных, выложенных в сети Интернет в виде HTML-страниц в некое хранилище. Хранилище может представлять собой как обычный текстовый файл, так и файл XML или же базу данных (БД). То есть на лицо обратный (реверсный) процесс – ведь веб-приложение обычно берёт данные как раз из БД.

    От теории к практике


    Для примера возьмём простой случай – разбор страницы сайта auto.ru. Перейдя по ссылке http://vin.auto.ru/resolve.html?vin=TMBBD41Z57B150932 мы увидим некоторую информацию, выводимую для Идентификационного номера TMBBD41Z57B150932 (марка, модель, модификация и т.д.). Представим себе, что нам необходимо вывести эту информацию в окно, например, Windows-приложения. Работа с БД в.Net широко описана, поэтому сосредотачиваться на этой проблеме мы не будем, займёмся сутью.
    Итак, создадим проект WinForms-приложения, бросим на форму один компонент TextBox с именем tbText, в котором будет прописан наш адрес (ссылка); кнопку btnStart, при нажатии на которую будет выполняться запрос по указанному адресу, а также ListBox lbConsole, куда выведем полученные данные. В реальном приложении ссылки тоже придётся брать из какого-то внешнего источника, но не забываем, что это – всего лишь пример.

    image

    Собственно с интерфейсом всё, теперь создадим метод, вызываемый в ответ на нажатие кнопки.

    В этом методе нам нужно проделать следующие вещи:
    1. Обратиться по адресу, указанному в нашем TextBox
    2. Получить страницу
    3. Выбрать из страницы необходимые данные
    4. Вывести данные на форму

    Обращаемся по адресу


    Для начала создадим переменную, в которой будет храниться полученная по запросу страница:

    1. string AutoResult = String.Empty;
    * This source code was highlighted with Source Code Highlighter.


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

    1. var autoRequest = (HttpWebRequest)WebRequest.Create(tbLink.Text);
    * This source code was highlighted with Source Code Highlighter.


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

    1. autoRequest.UserAgent = "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0)";
    2. autoRequest.Headers.Add("Accept-Language", "ru-Ru");
    3. autoRequest.Accept = "image/gif, image/jpeg, image/pjpeg, image/pjpeg, application/x-shockwave-flash, application/x-ms-application, application/x-ms-xbap, application/vnd.ms-xpsdocument, application/xaml+xml, application/vnd.ms-excel, application/vnd.ms-powerpoint, application/msword, */*";
    * This source code was highlighted with Source Code Highlighter.


    Также укажем, что использоваться будет метод GET.

    1. autoRequest.Method = "GET";
    * This source code was highlighted with Source Code Highlighter.


    Теперь выполняем запрос и переходим к следующему пункту –

    Получение страницы


    1. HttpWebResponse autoResponse = (HttpWebResponse)autoRequest.GetResponse();
    * This source code was highlighted with Source Code Highlighter.


    Собственно ответ сервера, а значит и сама страница теперь хранятся у нас в переменной autoResponse. Теперь нужно проанализировать этот ответ, если всё ОК, то можно представить страницу в виде строки:

    1. if (autoResponse.StatusCode == HttpStatusCode.OK)
    2. {
    3. using (Stream autoStream = autoResponse.GetResponseStream())
    4. {AutoResult = new StreamReader(autoStream, Encoding.GetEncoding("windows-1251")).ReadToEnd(); }
    5. }
    * This source code was highlighted with Source Code Highlighter.


    И если всё действительно ОК, то мы теперь имеем в переменной AutoResult строку такого же вида, которую мы можем посмотреть в браузере с помощью меню «Исходный код страницы». Ну, разве что в неотформатированном виде.

    Это всё, конечно, здорово. Но хотелось бы из этой мешанины тэгов выбрать именно то, что нам нужно. Здесь нам на помощь придут регулярные выражения, которые мы задействуем с помощью методов-расширителей. Методы-расширители, напомню, это такие статические методы статического класса, которые можно вызывать как метод объекта другого класса, если этот объект этого класса является первым параметром метода статического класса помеченным ключевым словом this. На примере проще. Если у нас есть метод StringWithEq класса StringOperations

    1. static class StringOperations
    2. {internal static string StringWithEq(this string s) {return string.Format("{0} = ", s);}}
    * This source code was highlighted with Source Code Highlighter.


    то мы можем использовать этот метод как привычным образом (1), так и как метод-расширитель (2):

    1. string test = "Test";
    2. (1) Console.Write(StringOperations.StringWithEq(test));
    3. (2) Console.Write(test.StringWithEq());
    * This source code was highlighted with Source Code Highlighter.


    Если посмотреть исходный код HTML-страницы в браузере, то можно заметить, что необходимые нам данные содержатся внутри тега , который нигде более не используется:

    <dl class="def-list md"><dt><strong>Идентификационный номер</strong></dt><dd>TMBBD41Z57B150932</dd><dt><strong>Марка</strong></dt><dd>SKODA</dd><dt><strong>Модель</strong></dt><dd>Octavia II (A5)</dd><dt><strong>Модификация</strong></dt><dd>Elegance</dd><dt><strong>Модельный год</strong></dt><dd>2007</dd><dt><strong>Тип кузова</strong></dt><dd>седан</dd><dt><strong>Количество дверей</strong></dt><dd>5-дверный</dd><dt><strong>Объем двигателя, куб.см.</strong></dt><dd>2000</dd><dt><strong>Описание двигателя</strong></dt><dd>150лс</dd><dt><strong>Серия двигателя</strong></dt><dd>BLR, BLX, BLY</dd><dt><strong>Система пассивной безопасности</strong></dt><dd>подушки безопасности водителя и переднего пассажира</dd><dt><strong>Сборочный завод</strong></dt><dd>Solomonovo</dd><dt><strong>Страна сборки</strong></dt><dd>Украина</dd><dt><strong>Страна происхождения</strong></dt><dd>Чехия</dd><dt><strong>Производитель</strong></dt><dd>Skoda Auto a.s.</dd><dt><strong>Серийный номер</strong></dt><dd>50932</dd><dt><strong>Контрольный символ</strong></dt><dd><span style='color: #FF0000;'>NOT OK!</span></dd></dl>Партнёр проекта - <a href="http://vinformer.su">vinformer.su</a></div>

    Поэтому воспользуемся этим, сначала извлечём данные из внутри этого тега, а затем разберем их и поместим, например, в объект класса Dictionary. После чего выведем полученные данные в ListBox lbConsole. Хотелось бы, чтобы конечный код выглядел, например, так:

    1. string BetweenDL = AutoResult.BetweenDL();
    2. Dictionary<string, string> d = BetweenDL.BetweenDTDD();
    3. foreach (var s in d)
    4. {
    5. lbConsole.Items.Add(string.Format("{0}={1}", s.Key, s.Value));
    6. }
    * This source code was highlighted with Source Code Highlighter.


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

    1. internal static string BetweenDL(this string dumpFile)
    2. {
    3. var _regex = new Regex(@"<dl[^>]*>(?<value>[\s\S]+?)</dl>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
    4. Match _match = _regex.Match(dumpFile);
    5. return _match.Success ? _match.Groups["value"].Value : string.Empty;
    6. }
    * This source code was highlighted with Source Code Highlighter.


    Далее с помощью ещё одного метода-расширителя выбираем необходимые данные и пишем их в объект класса Dictionary:

    1. internal static Dictionary<string, string> BetweenDTDD(this string dumpFile)
    2. {
    3. var _regex = new Regex(@"<dt[\s\S]+?strong>(?<valDT>[\s\S]+?)</strong></dt><dd[^>]*>(?<valDD>[\s\S]+?)</dd>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
    4. MatchCollection matches = _regex.Matches(dumpFile);
    5. Dictionary<string, string> d = new Dictionary<string, string>();
    6. foreach (Match match in matches)
    7. {
    8. GroupCollection groups = match.Groups;
    9. d.Add(groups["valDT"].Value, groups["valDD"].Value);
    10. }
    11. return d;
    12. }
    * This source code was highlighted with Source Code Highlighter.


    Далее в цикле foreach выводим полученные данные в ListBox.

    image

    Конечно, можно было использовать только второй метод расширитель, результат был бы тот же. В реальных же приложениях иногда удобнее выделить часть текста, содержащую необходимые данные, а затем заниматься её разбором. Можно внести другие усовершенствования и/или изменения в этот код, но надеюсь, что цели этой небольшой статьи я достиг – дал вам представление о том, что такое веб-скрейпинг.
    Поделиться публикацией
    Комментарии 48
      +6
      Плохо плохо использовать регулярные выражения для парсинга HTML.
      You cannot parse HTML using regular expressions!
        +4
        Действительно поэтичный комментарий, но следующий за ним ответ мне также по душе.
        И — да, я пользовался XML парсерами. И в данный момент вполне обхожусь регулярными выражениями.
        • НЛО прилетело и опубликовало эту надпись здесь
          • НЛО прилетело и опубликовало эту надпись здесь
              +1
              Это мой первый пост на Хабре и я ещё не настолько всем этим увлечён (да и не до конца разобрался в нюансах местной рейтинговой системы), чтобы остро переживать удары судьбы, являющиеся мне в виде красненьких циферок =)
              Ну а там жизнь покажет.
            0
            Chuck Norris can parse HTML with regex
            +1
            Хорошо хорошо. HTML бывает невалидным, бывает полезная для распознавания нужной области информация в комментариях, бывают обрубки HTML в скриптах, и много прочей страшной гадости.

            Классические регулярные выражения, конечно же, слабоваты по своим возможностям, так что я для этих целей использую регулярные выражения на основе PEG — как раз получается хороший баланс между полноценным парсером и узкоспециализированным регулярным выражением.
            0
            Рьяно плюсую камент. Сам раньше страдал таким, теперь вот одумался на старости лет, мэппинг начал использовать.
              0
              Пардон, я тут немного припозднился с вопросом но все же (тем более сейчас для меня эта тема актуальна)… А как поможет дата мапинг для мапинга Html на объекты?
              –1
              Если кто-то не может распарсить контент странички регулярками — значит он не знает регулярки!
              А насчет метода с XML — один незакрытый тег и всё к чертям.
                0
                Это понятно. да и на страничке можно такое напридумывать, что запаришься писать парсер))
                  0
                  Можете продемонстрировать как вы с этой страницы htmlagilitypack.codeplex.com/
                  выберите содержание #WikiContent?
                    +1
                    Элементарно, Ватсон!

                    Для начала посмотрим на HTML код… Визуально определяем что нужный нам контент начинается с уникальной последовательности символов:
                    <div id=«WikiContent» class=«WikiContent»>
                    А после него присутствует это:
                    </div>.*?<div id=«WikiInfo» class=«wikiSource WikiInfo»> (где .*? — это уже часть регулярки)

                    С помощью моего любимого regexp тестера проверяем регулярочку:

                    .*?<div id=«WikiContent» class=«WikiContent»>(?<content>.*?)</div>.*?<div id=«WikiInfo» class=«wikiSource WikiInfo»>.*

                    (можно было проверить по-разному, я решил использовать метод замены)

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

                      var content = document.QuerySelector("#WikiContent").InnerHtml;

                      Предположим что завтра пропал блок #WikiInfo и вместо него будет пяток безымянных дивов, в моем случае ничего менять не нужно, что будете делать вы?
                        +1
                        Так же как и вы, по диву с id, но регуляркой. (поверьте, у меня большой опыт в этом деле)

                        Хотя, если посмотреть на это с практической точки зрения:
                        На написание регулярки у меня ушло 2 минуты.
                        На парсинг всего сайта уйдет не более суток.
                        Завтра всё будет в моей базе ;) и мне будет всё-равно если что-то изменится.

                        Если серьезно — то вы правы на счет document.QuerySelector…
                        Так даже правильнее и красивее, но лишь в данном случае (с валидным HTML).

                          0
                          >Так же как и вы, по диву с id, но регуляркой.
                          Вот на это решение я и хотел посмотреть.

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

                          Есть множество библиотек устраняющие не валидность html.

                    0
                    >один незакрытый тег и всё к чертям.
                    Tidy, BeatifulSoup как раз для таких ситуаций придумали
                      0
                      Конечно, можно что только не использовать, но главное в нашем деле скорость…
                      Чем больше операций с громадными текстами, тем медленнее всё работает.
                      Я тоже как-то использовал и SgmlReader и xpath, но регулярки оказались самым быстрым методом парсинга (и не только HTML).
                        0
                        Что то мне подсказывает что в данном синхронном примере основное время занимает ожидание сокета. Но в целом регулярками пожалуй быстрее. Зависит от реализации библиотек регулярок и html парсеров больше.
                  +10
                  Куча каких-то регулярок, которые ещё и привязываются как расширение к строке. Не удобно же, число методов расширения будет плодиться со временем (если не из серии написал и забыл).

                  Мой вариант с использованием HtmlAgilityPack:

                  var autoRequest = WebRequest.Create(@"http://vin.auto.ru/resolve.html?vin=TMBBD41Z57B150932");
                  var autoResponse = (HttpWebResponse)autoRequest.GetResponse();
                  if (autoResponse.StatusCode == HttpStatusCode.OK)
                  {
                  	var doc = new HtmlDocument();
                  	doc.Load(autoResponse.GetResponseStream());
                  	var node = doc.DocumentNode.SelectSingleNode("//dl[@class=\"def-list md\"]");
                  
                  	var dt = node.SelectNodes(".//strong");
                  	var dd = node.SelectNodes(".//dd");
                  
                  	for(var i = 0;i<dt.Count;i++)
                  	{
                  		Console.WriteLine(dt[i].InnerText+" = "+ dd[i].InnerText);
                  	}
                  }


                  Кривость, конечно, но мне кажется, всё равно куда более читабельно, нежели
                  "<dt[\s\S]+?strong>(?<valDT>[\s\S]+?)</strong></dt><dd[^>]*>(?<valDD>[\s\S]+?)</dd>"
                    0
                    кстати mezastel хорошую же статью написал про совместное использование watin и HtmlAgilityPack habrahabr.ru/blogs/net/93958/
                      0
                      Да, я читал эту статью. К сожалению, в многопоточном варианте использование WatiN превращается в ад.
                        0
                        Ну не то что в ад, просто нужно понимать как их правильно готовить. А WatiN имеет смысл когда нужен реальный, живой браузер. Кстате, коллеги используют для этих целей WebKit — подробностей не знаю но многопоточность там наверняка получше.
                          0
                          Все равно Ад =) Пытались тут сценарий пользователя поставить на тестирование — прокувыркавшись 1.5 дня с ватином плюнул и взял WebAii — куда как лучшее изделие, как в плане документации так и поддержки. Телерик кстати купил контору — видать неспроста =).
                          Там тоже есть свои баги и особенности. Но оно хотя бы работает для сценариев чуть сложнее чем «открыть страничку-кликнуть кнопку», в отличии от watin, которого так и не удалось заставить загружать файлы в IE
                      +1
                      А с селекторами могло бы быть ещё немного читабельней.
                      code.google.com/p/fizzler/
                        +1
                        Как то так:

                        var doc = new HtmlDocument();
                        doc.Load(@«vin.auto.ru/resolve.html?vin=TMBBD41Z57B150932»);

                        0
                        >>Куча каких-то регулярок,
                        Не куча, а 2, а точнее — можно использовать 1, о чём и написано.
                        >>которые ещё и привязываются как расширение к строке. Не удобно же, число методов расширения будет плодиться со временем
                        Для данного конкретного примера расширителя всего 2 (повторюсь, можно использовать только 1) и плодиться там уже просто некуда.

                        Ваш подход также вполне рабочий, просто он другой, это, имхо, вопрос привычки, личного опыта и предпочтений.
                          –1
                          представьте себе ситуацию, вы написал 10 парсеров для 10 сайтов и каждый день их парсите. В один прекрасной момент, парсер для какого то сайта перестает работать. Причина кроется в том, что они изменили название класса, и теперь вместо стоит . Пользователь, который просматривает эту страницу, разницу не заметит, а ваш парсер перестанет работать. Вы конечно же исправите свою регулярку и все станет работать как прежде. Но пройдет некоторое время и опять перестанет работать по той же причине. После нескольких изменений вам все это надоест. И вы поймете, что ваш метод не очень то хороший.

                          В идеале скрипт сам должен определить зону, которая является контентом. Т.е. указал сайт и этого достаточно, скрипт сам находит блок, где находится контент и его забирает.
                          Но если это не получится, то хотя бы ваш парсер должен правильно работать когда произошли маленькие изменения в разметке html.
                            0
                            Все правильно, когда много сайтов и каждый день парсятся, то постоянно происходят какие-то изменения на сайте. И XPath перестает работать. Наилучший вариант — это автоматическое определение нужных блоков, например, по наличию текстового названия, ссылки, цены.
                            +1
                            Уж лучше напрямую делать запросы к vinformer.su, а то какой-то испорченный телефон получается.
                            Малоли, чего эти багоделы из авто.ру подмешивают в данные… ;-)
                              +5
                              Несколько советов:
                              1. Если все чтто вам надо от зароса — это поставить несколько хедеров и выдернуть строку, то не стоит стрелять из пушки по воробьям. Все это нагромождение реализуется гораздо проще через WebClient.
                              WebClient client = new WebClient();
                              client.Headers[...
                              string result = client.DownloadString(...url...);

                              * This source code was highlighted with Source Code Highlighter.


                              2. Если уж достаем тяжелую артиллерию в виде мануальной работы с request/response, то лучше тогда показать на том, что не сделать через WebClient, например, работу с куками.

                              3. По поводу парсинга, целиком согласен с steck — лучше использовать HtmlAgilityPack. Тогда с документом можно работать как с DOM-моделью, используя XPath и т.п. Регулярные выражения громоздки и более чувствительны к ошибкам.

                              4. А если еще и поведать о секретах… Например, что приложение будет падать с исключением при обращении к https-сайтам с невалидным сертификатом и нужно делать кастомный обработчик. Что все фреймворки вплоть до 3.5sp1 имеют ошибку в обработках кук вида ".domain.com" и это вылечено не будет никогда, только переходом на 4.0… в общем что-то меня понесло :)
                                0
                                В итоге статья типа «дал вам представление о том, что такое веб-скрейпинг», по-тихоньку превращается в мастер-класс =)
                                  +1
                                  Ну вот вам несколько ссылочек по теме: 1 2 3 4
                                +1
                                Более удобно работать с классом WebClient, а не WebRequest — msdn.microsoft.com/en-us/library/system.net.webclient.aspx
                                Ну и про парсинг HTML уже написали — вместо regexp можно попробовать ну хотя бы HtmlAgilityPack — проще будет.
                                  0
                                  Что-то прорвало Хабр на эту тему! Всем хочется WebRequest'ами поиграться. Хочется в очередной раз попиарить свои посты, но не буду. Только вот жаль, что информации новой — кот наплакал.

                                  Реквестирую пост про то, как реагировать на изменения на сайте. Если никто не напишет, сам напишу :|
                                    0
                                    Многим эта тема знакома и многим приходилось это делать.
                                    Насчет изменений на сайте — тут уж в большенстве случаев всё сводится к мониторингу логов и ручному изменению кода, чтобы быть up-to-date.
                                    У вас есть какой-то другой способ?

                                      0
                                      Ну мониторинг — это тоже важно, особенно когда проект вешается на саппорт и нужно реактивно фиксить скрейперы и биллить за работу :)
                                    +1
                                    xpath рулит со страшной силой.
                                      +4
                                      Какая жесть. Взять готовый парсер и его заюзать совесть не позволяет?

                                      Хотя-бы тотже питонячий BeautifulSoup/HTML5 который отлично жрется ironpython.
                                        0
                                        А где можно примеры посмотреть (с IronPython)?
                                        +1
                                        Не в тему но всетаки: совсем недавно делал то же самое. Инструмент Python + lxml — вообще никаких сложнойстей. Вся программа 10 строчек.
                                          0
                                          lxml не всегда корректно кодировку распознает. Приходится немножко с бубном поплясать.
                                          Но если один сайт и кодировка заранее известна — никаких проблем
                                          0
                                          Питонщикам очень рекомендую Scrapy scrapy.org

                                          Очень мощный движок для веб-скрепинга. Он на основе Twisted написан т.е. асинхронный, работает очень шустро, есть веб-интерфейс, telnet консоль удаленного управления, консоль для тестирования xpath и отладки… В общем если нужно серьезного паука писать — MustHave!!!
                                            0
                                            Уверен что .Netчики тоже смогут воспользоваться через IronPython.
                                            +1
                                            Офигеть. Теперь и реверсу БД придумали новое, современное название.

                                            Хотел бы заступиться за регулярки! Икспасы и прочие игрища с домом — это все прекрасно, но бесполезно, особенно если объем, кхм, «скрейпинга», не по авто.ру за два вечера.

                                            Во-первых, регулярки сильно шустрее и экономичнее, если есть опыт в составлении. Попробуйте в дотнете крутить постройкой ДОМа и «красивой» выборкой в писят потоков, и тут же попробуйте регулярки. Сами удостоверитесь.

                                            Во-вторых, приноровившись, оператор будет писать регулярки, которые будут вылетать (при изменениях источника) очень редко. С опытом вырабатываются три-пять стандартных паттернов/подходов, следуя которым вы избавите себя от большинства проблем с редизайнами источника.

                                            В-третьих, чаще встречаются проекты, у которых сложно выбрать плавающий элемент (а тем паче — группу элементов), используя ДОМ, но с регулярками все решается на раз-два.

                                            В-четвертых, есть источники, из которых в принципе не выдрать информацию через ДОМ, не применяя тут же регулярки или аналог сабстр. Я гарантирую это!

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

                                            Бага с куками ".domain.com" легко обходится алгоритмически. Даже гугля что-то там предлагает, но я предварительно самостоятельно ее выявил и пофиксил — там работы на секундочку.

                                            Я писал софт для datarama.biz, а там самые маньячные сборщики, которых я знаю или о которых когда-либо слышал. Могу сказать, что ни один проект из представленных (а представлено там очень ограниченное число законченных проектов) не занимал на все про все более 60-80 минут, начиная от создания проекта в парсере, и заканчивая экспортом базы и генерацией статистического отчета.

                                            Собственно, перед и во время написания этого вполне себе «универсального» парсера мы реально долго и муторно брейнштормили, распарсили кучу различной информации из разных источников (не только сайты), и хотите верьте, хотите — нет, но вкупе с грамотной моделью ПО, регулярки выдернут, во-первых, ВСЕ, во-вторых, намного быстрее. Необходимые данные уже будут лежать в 1-3NF базе к тому моменту, пока «красивые» одноразовые решения на XPath и прочих ДОМ-радостях будут стоять в очереди за оперативой.

                                            Простите за сумбурность :) Конечно же, мои мысли относятся только к тем случаям, когда собирать надо действительно много, из разных источников и в течение длительного периода времени. Если же вам только авто.ру единожды распарсить, то спору нет — ДОМ наше все! :D
                                              +1
                                              использовал, как и многие тут писали Html Agility Pack, когда то искал разные варианты outcoldman.livejournal.com/40291.html, но остановился на нем, так как просто и легко работает. Потом правда все таки реализовал идею только для своего сайта, паршу html страницу френд ленты LJ. Работает нормально. У каждого метода есть плюсы и минусы.

                                              Поздравляю с первой статьей на хабре! Если критикуют — это хорошо, главное что есть обсуждение.

                                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                              Самое читаемое