Парсинг сайтов с использованием С#

Добавлено: 19/11/2019 10:05 |  Обновлено: 19/11/2019 11:25 |  Добавил: nick |  Просмотры: 13732 Комментарии: 1
Вводная часть
В этом материале вы познакомитесь с основами парсинга сайтов с использованием языка программирования С#.
Парсинг HTML сводится к извлечению искомой порции данных из общего набора данных. Существует несколько способов решить задачу поиска извлекаемой порции.

1. Поиск текста с помощью встроенных в класс String методов
Самый быстрый и понятный для новичков в программировании на С# способ.

Представим, что нам нужен только заголовок страницы, который лежит в теге <title>. HTML код будет выглядеть таким образом:
<title>
Искомый текст
</title>
Задача сводится к поиска позиции фраз "<title>" и "</title>", и вырезанию текста между ними.
var txtHTML = @"<title>Page title</title>";
var txtPrefix = @"<title>";
var txtSuffix = @"</title>";
var txtPrefixPosition = txtHTML.IndexOf(txtPrefix, StringComparison.OrdinalIgnoreCase);
var txtSuffixPosition = txtHTML.IndexOf(txtSuffix, txtPrefixPosition + txtPrefix.Length, StringComparison.OrdinalIgnoreCase);

var txtTitle = txtHTML.Substring(
    txtPrefixPosition + txtPrefix.Length,
    txtSuffixPosition - txtPrefixPosition - txtPrefix.Length
);
Такой код хорошо оформить в виде функции:
public static String FindText(string source, string prefix, string suffix)
{
    var prefixPosition = source.IndexOf(prefix, StringComparison.OrdinalIgnoreCase);
    var suffixPosition = source.IndexOf(suffix, prefixPosition + prefix.Length, StringComparison.OrdinalIgnoreCase);

    if ((prefixPosition >= 0) && (suffixPosition >= 0) && (suffixPosition > prefixPosition) && ((prefixPosition + prefix.Length) <= suffixPosition))
    {
        return source.Substring(
                        prefixPosition + prefix.Length,
                        suffixPosition - prefixPosition - prefix.Length
            );
    }
    else
    {
        return String.Empty;
    }
}
Использовать такой код можно так:
var txtTitle = FindText(txtHTML,@"<title>",@"</title>");
Такой способ хорошо подходит для одиночного поиска и когда искомый текст окружен уникальными последовательностями (в данном случае - тегами <title> и </title>).

2. Поиск текста с помощью регулярных выражений
Предыдущий способ плохо работает, если prefix и suffix имеют сложный вид, или вообще неизвестны, но известна структура искомого текста.

Например, нам нужны все ссылки со страницы. Тогда поиск можно организовать так:
using System.Text.RegularExpressions;
using System.Linq;
//...

//Регулярное выражение для поиска A тега
var regexpATag = new Regex(@"<a[^<>]*>[^<]*<\/a>", RegexOptions.IgnoreCase | RegexOptions.Multiline);            
//Регулярное выражение для поиска href свойства
var regexpHref = new Regex(@"href\s*=\s*[""'](.*?)[""']", RegexOptions.IgnoreCase | RegexOptions.Multiline);    

var matches = regexpATag.Matches(txtHTML);
var links = new List<string>();
foreach (Match match in matches)
{
    var link = regexpHref.Match(match.Value);
    if (link.Success) links.Add(link.Groups[1].Value);
};
//LINQ запрос на сортировку и уникализацию
links = links.Distinct().OrderBy(el => el).ToList();
В результате выполнения кода в переменной links будет список уникальных ссылок, отсортированных в алфавитном порядке.

Подобный способ подразумевает умение составлять регулярные выражения. Протестировать ваши регулярные выражения удобно на сайте regex101.com.

3. Использование библиотеки HtmlAgilityPack (https://html-agility-pack.net/)
Следующая степень удобства - использование сторонних библиотек, например HtmlAgilityPack. Данная библиотека умеет строить DOM дерево по HTML коду. При этом сам код нужно получить заранее:
using System.Net;
using System.IO;
//...
/// <summary>
/// Возвращает текст страницы по адресу url
/// </summary>
/// <param name="url">Адрес страницы</param>
/// <returns>Возвращает текст страницы по адресу url</returns>
public static string GetPage(string url)
{
    var result = String.Empty;
    var request = (HttpWebRequest)WebRequest.Create(url);
    var response = (HttpWebResponse)request.GetResponse();
 
    if (response.StatusCode == HttpStatusCode.OK)
    {
        var responseStream = response.GetResponseStream();
        if (responseStream != null)
        {
            StreamReader streamReader;
            if (response.CharacterSet != null)
                streamReader = new StreamReader(responseStream, Encoding.GetEncoding(response.CharacterSet));
            else
                streamReader = new StreamReader(responseStream);
            result = streamReader.ReadToEnd();
            streamReader.Close();
        }
        response.Close();
    }
    return result;
}
Тогда получить страницу можно так (для примера - каталог вакансий):
var txtHTML = GetPage(@"https://joblab.ru/search.php?r=vac&srregion=100&maxThread=100&submit=1");
var doc = new HtmlDocument();   // Создание документа
doc.LoadHtml(txtHTML);          // Загрузка кода в документ
Анализируя код, нужно понять:

1) По какому принципу формируются URL страниц со списком вакансий (их обычно несколько).

https://joblab.ru/search.php?r=vac&srregion=100&maxThread=100&submit=1&page=2
https://joblab.ru/search.php?r=vac&srregion=100&maxThread=100&submit=1&page=3 ...
var baseURL = @"https://joblab.ru/search.php?r=vac&srregion=100&pred=30&maxThread=100&submit=1";
catalogPages.AddRange(
    Enumerable
    .Range(2, Convert.ToInt32(lastPageNumber) - 2)
    .Select(el=> $"{baseURL}&page={el}")
);
2) Как узнать номер последней подобной страницы?

(Клик правой кнопкой по элементу в Chrome - просмотреть код) Все кнопки навигации имеют класс "pager", притом последняя из таких кнопок - содержит номер последней страницы
var lastPageNumber = doc.DocumentNode.SelectNodes("//*[@class='pager']").Last().InnerText;
3) Какого вида ссылки на страницы с данными?

Сылки начинаются с "/vac":
var vacancyPages = new List<string>();
foreach (var catalogPage in catalogPages) {
    txtHTML = GetPage(catalogPage); // Получение кода страницы
    doc.LoadHtml(txtHTML);          // Загрузка кода в документ

    vacancyPages.AddRange(                                    //Добавить к vacancyPages список ссылок
         doc.DocumentNode
             //Выбрать все ссылки (тут используется XPath запрос)
             .SelectNodes("//a")
             //Преобразовать список ссылкок в список их href
             .Select(el => el.Attributes["href"].Value)
             //Фильтр - оставить только ссылки, содержащие "/vac"
             .Where(el=>el.Contains("/vac"))
             //Преобразовать относительные ссылки в абсолютные
             .Select(el=> "https://joblab.ru"+el)
             //Преобразование в список
             .ToList()
        );
}
4) Открыв страницу с вакансией, подобным образом ищем интересующие поля - название, телефон, email, описания и т.п.

Чтобы не делать множество запросов к серверу, можно получить код страницы однократно, и сохранить его. //Получение HTML кода страниц с товарами
Dictionary<string, string> pages = new Dictionary<string, string>();
foreach (var productPage in productPages)
{
    txtHTML = GetPage(productPage); // Получение кода страницы
    doc.LoadHtml(txtHTML);          // Загрузка кода в документ
    pages[productPage] = txtHTML;
}
Тогда код парсера будет такой:
//Парсинг данных
foreach (var page in pages) {
    doc.LoadHtml(page.Value);          // Загрузка кода в документ

    //ссылка на профиль компании
    var company = "https://joblab.ru" + doc.DocumentNode
     .SelectNodes("//a") //Выбрать все ссылки из документа(тут используется XPath запрос)
     .Select(el => el.Attributes["href"].Value)//Взять только href свойство
     .Where(el => el.Contains("/e"))//Отфильтровать по признаку "содержит /e"
     .FirstOrDefault();//Взять первый

    //Название организации
    var companyName = HttpUtility.HtmlDecode ( doc.DocumentNode
     .SelectNodes("//a")//Выбрать все ссылки из документа
     .Where(el => el.Attributes["href"].Value.Contains("/e"))//Отфильтровать по признаку "содержит /e"
     .Select(el=>el.InnerText)//Взять только текст внутри тегов
     .Aggregate("",(acc,val)=>acc+=val.ToString())//Выдать весь текст
     );

    //Название вакансии
    var vacancy = FindText(page.Value, "<h1>", "</h1>");//Тут проще всего использовать поиск текста
    
    //регион или город
    var city = FindText(page.Value, @"graytext"">Город</p></td><td><p><b>", "</b>").ToString();//Тут проще всего использовать поиск текста
    
    // и т.д.
}
После парсинга данные сохраняются любым удобным способом. Чаще всего это csv файл. Ниже представлен простейший код для формирования небольшого по размеру csv файла (для больших файлов желательно использовать StreamWriter) в каждом значении кавычки заменяются на двойные кавычки, переводы строк заменяются на пробел и значение обрамляется двойными кавычками. Каждая запись отделяется от другой переводом строки.
Dictionary<string, Tuple<string, string, string, string, string, string, string>> data = new Dictionary<string, Tuple<string, string, string, string, string, string, string>>();
Здесь ключ словаря - url, значение словаря - кортеж из 7 string(хотя тут можно применить и List<string> и массив строк и т.п.)
var csvData = "";
foreach(var row in data)
{
    csvData+=   "\"" + row.Key.Replace("\"", "\"\"").Replace('\n', ' ').Replace('\r', ' ') + "\";" +
        "\"" + row.Value.Item1.Replace("\"", "\"\"").Replace('\n', ' ').Replace('\r', ' ') + "\";" +
        "\"" + row.Value.Item2.Replace("\"", "\"\"").Replace('\n', ' ').Replace('\r', ' ') + "\";" +
        "\"" + row.Value.Item3.Replace("\"", "\"\"").Replace('\n', ' ').Replace('\r', ' ') + "\";" +
        "\"" + row.Value.Item4.Replace("\"", "\"\"").Replace('\n', ' ').Replace('\r', ' ') + "\";" +
        "\"" + row.Value.Item5.Replace("\"", "\"\"").Replace('\n', ' ').Replace('\r', ' ') + "\";" +
        "\"" + row.Value.Item6.Replace("\"", "\"\"").Replace('\n', ' ').Replace('\r', ' ') + "\";" +
        "\"" + row.Value.Item7.Replace("\"", "\"\"").Replace('\n', ' ').Replace('\r', ' ') + "\"\n";
}

System.IO.File.WriteAllText("out.csv",csvData,Encoding.UTF8);//запись в файл "out.csv" в кодировке UTF8
Все эти способы подойдут для парсинга большинства простых сайтов. Напоследок, подскажу как улучшить ваш парсер. Для более сложных случаев, когда содержимое страницы формируется динамически, вышеописанные способы не подойдут, и нужно полноценно эмулировать браузер, используя, например, библиотеку Selenium. Парсить сайт в один поток - довольно медленно. Поэтому распараллеливание парсинга ускорит процесс в разы. И интерфейс программы станет приятнее (не будет ощущения, что программа зависла, пока получает данные).

Многие сайты активно сопротивляются, если заподозрят вас в парсинге, поэтому есть смысл продумать использование прокси-серверов.

Автор материала Григорий Боев

Оставьте свой комментарий

Комментарии