Простой web scraping на f#

imageДостаточно законный вопрос почему такая избитая тема как web scraping и почему f#. 1. на f# web scraping намного увлекательней чем на c#  2. хотелось попробовать насколько f# применим для разработки не демо примеров а что то реально делающих программ 3. У f# есть интерактивная консоль, что при ковырянии в недрах HTML становится просто спасением. Сегодня с помощью f# будем покупать VW Touareg.

Touareg


По моему самая оптимальная машина для суровой зимы и не менее суровых дорог. Предположим у нас миллион четыреста тысяч, большое желание и больше ничего нет. Есть ещё сайт auto.ru, но недавнее моё участие в покупке бу автомобиля выявило несколько недостатков: а. нужно постоянно заходить в правильный раздел б. нужно постоянно заполнять форму поиска, что особенно раздражает когда это нужно делать в дороге с мобильных устройств, по пути на осмотр очередного кандидата мы пользовались iPad и это было “не айс”, а с какого нибудь смартфона я бы точно застрелился выполнять все эти операции. Итого требования: программа обходит страницу с предложениями соответствующими запросу, ищет новые предложения и если находит, то посылает письмо с параметрами нового предложения(ий) и заодно список всех предложений, удовлетворяющих запросу, чтобы можно было сравнить визуально.

Общие методы


Auto.ru достаточно лояльно относится к сбору своего контента, так что извратов вроде эмуляции нажатия на кнопку и подсовывания cookies тут не будет, и всё что нас интересует можно получить по прямому url через GET. Так же письма мы будем слать через gmail, что потребует в конфиге настроек SMTP клиента указанных в комментариях

module WebUtil =<br/>
    let LoadPage (x:WebRequest) =<br/>
        use resp = x.GetResponse()<br/>
        let rs = resp.GetResponseStream()<br/>
        let rr = new StreamReader(rs,System.Text.Encoding.GetEncoding(1251))<br/>
        rr.ReadToEnd()<br/>
    let LoadPageByUrl (x:string) =<br/>
        let request = WebRequest.Create(x)<br/>
        LoadPage request<br/>
    let SentByMail (recepinet: string) (subj:string) (content: string) =<br/>
        let client = new SmtpClient()<br/>
        client.DeliveryMethod <- SmtpDeliveryMethod.Network<br/>
        use message = new MailMessage()<br/>
        message.To.Add(recepinet)<br/>
        message.Body <- content<br/>
        message.Subject <- subj<br/>
        message.IsBodyHtml <- true <br/>
        client.Send(message)<br/>
    (*
    <system.net>
    
      <smtp from="YourMail@gmail.com">
        <network host="smtp.gmail.com" port="587" enableSsl="true" 
                 password="$ecretPa$$w0rd" defaultCredentials="false" 
                 userName="YourMail@gmail.com" />
      

    

    </system.net>
    *)<br/>


Что здесь от f#? Функционально – ничего, стандартные методы платформы, но зато поразительно кратко, если бы не портянка сеттеров свойств MailMessage. Читаемость кода это очень персональное, но по моему мало что может сравниться с f# по читаемости.

Структуры данных


Т.к. нужно различать какие предложения были добавлены с момент последней проверки, результат предидущего запроса будет храниться в файле. Можно было бы правда хранить только дату последней проверки, но тогда было бы совсем не интересно и была бы упущена тема сереализации сложных объектов. Итак записи (records):

module CarParser =<br/>
    [<DataContract>]<br/>
    type Car = <br/>
        {<br/>
            [<field: DataMember(Name="Year") >]<br/>
            Year: int;<br/>
            [<field: DataMember(Name="Price") >]<br/>
            Price: int;<br/>
            [<field: DataMember(Name="Model") >]<br/>
            Model: string;<br/>
            [<field: DataMember(Name="Engine") >]<br/>
            Engine: string;<br/>
            [<field: DataMember(Name="Url") >]<br/>
            Url: string;<br/>
        }<br/>
    [<DataContract>]<br/>
    type CarRequest =<br/>
        {<br/>
            [<field: DataMember(Name="Email") >]<br/>
            Email:string;<br/>
            [<field: DataMember(Name="RequestUrl")>]<br/>
            RequestUrl: string;<br/>
            [<field: DataMember(Name="Cars") >]<br/>
            Cars: Car list;<br/>
        }<br/>



Почему дополнительные атрибуты? Дело в том, что стандартная сериализация в XML через XmlSerializer не работает, потому что у f# records нет конструктора без параметров, который является обязательным. В данном случае спасёт DataContractSerializer, методы для сериализации и десереализации в файл выглядят так:

open System;<br/>
open System.IO;<br/>
open System.Xml;<br/>
open System.Runtime.Serialization;<br/>
open System.Text.RegularExpressions;<br/>
module SerializationUtils = <br/>
    let SerializeToFile (req:'T) (fileName: string) =<br/>
      let xmlSerializer = DataContractSerializer(typeof<'
T>); <br/>
      use fs = File.OpenWrite(fileName)<br/>
      xmlSerializer.WriteObject(fs, req)<br/>
  <br/>
    //T' will be calculated automatically
    let Deserialize<'T> (fileName:string) =<br/>
        let xmlSerializer = DataContractSerializer(typeof<'
T>); <br/>
        use fs = File.OpenRead(fileName)<br/>
        xmlSerializer.ReadObject(fs) :?> 'T<br/>


Парсинг контента


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

Разбор контента
Использовав HTMLAgilityPack (по моему это действительно классно – любые .net библиотеки доступны из f#) получаем таблицу с предложениями и дальше разбор – просто дело техники. Опять таки на f# парсинг выглядит очень коротко и понятно, я точно знаю, что хотя бы часть следующего реального проекта по сбору и анализу контента я буду делать на f# потому что его намного проще читать.

let private ParseCar (cnt: HtmlNode) =<br/>
    let columns = cnt.SelectNodes("td") |> Seq.toList<br/>
    let model = columns.[0].InnerText<br/>
    let txt = columns.[1].InnerText<br/>
    let price = txt |> (fun x -> Regex.Replace(x,"\\W",System.String.Empty)) |> Int32.Parse<br/>
    let url = columns.[0].ChildNodes <br/>
            |> Seq.find (fun x -> x.Name.ToLower() = "a")<br/>
            |> (fun x-> x.Attributes) <br/>
            |> Seq.find (fun x -> x.Name = "href")<br/>
            |> (fun x -> x.Value)<br/>
    let year = columns.[2].InnerText |> Int32.Parse<br/>
    let engine = columns.[3].InnerText<br/>
    let c: Car = { Year = year; Price = price; Model = model; Url = url; Engine = engine; }<br/>
    c<br/>
let private ParsePage (node: HtmlNode) (parseCar: HtmlNode -> Car) =<br/>
    node.SelectNodes("//div[@id='cars_sale']/table[@class='list']/descendant::tr[not(@class='header first')]")<br/>
    |> Seq.map parseCar<br/>


И несколько методов для обобщения полученных данных в запросы интересных наверное только каррированием и инициализацией записи копированием из старой записи. f# сам переопределяет функции CompareTo, Equals и GetHashCode, поэтому сравнение записей в данном случае работает корректно и можно писать x = y.

let private ParseCarNode x = ParsePage x ParseCar<br/>
let private GetCars (cntnt:string) (pars: HtmlNode -> seq) =<br/>
    let doc = new HtmlDocument()<br/>
    doc.LoadHtml(cntnt)<br/>
    pars doc.DocumentNode<br/>
let CreateCarRequest mail url =<br/>
    let cars = GetCars (LoadPageByUrl url) ParseCarNode<br/>
    { Email = mail; RequestUrl = url; Cars = cars |> List.ofSeq }<br/>
let UpdateCarList (oldRequest: CarRequest) =<br/>
    let newCars = GetCars (LoadPageByUrl oldRequest.RequestUrl) ParseCarNode<br/>
    let isContains y = Seq.tryFind (fun x -> x = y)<br/>
    let diff = newCars |> Seq.filter (fun x -> (oldRequest.Cars |> isContains x) = None)<br/>
    let res = { oldRequest with Cars = newCars |> List.ofSeq }<br/>
    //и результаты запроса и разница с предыдущим, очень приятный синтаксис
    (res,diff)<br/>



Итоги


За бортом остались функции форматирования e-mail сообщений и функция которая собирает это всё вместе и запускает по таймеру, но их реализация очевидна. Тестирование можно делать на c#, в частности тестирование корректности нахождения новых машин было реализовано с помощью Moles и именно там всплыли грабли описанные в этом посте.
Основные достоинства: 200 строк кода. Всё вместе. Все 5 файлов. Процентов на 30 меньше чем средний файл на c# в программах которые несут хоть какую нибудь функциональную нагрузку, а не просто вызывают методы фреймворка с другим порядком аргументов. Читаемость кода. Скорость разработки, в программах собирающих конетент очень большим плюсом является возможность выполнить код без компиляции. По моему мнению f# – это более понятный и натуральный способ разработки программ, знакомые и рутинные задачи снова становятся интересными.Основные недостатки: основной недостаток – это конечно же кривые руки, потому что в программе нет ни логирования ни внятной обработки ошибок, что конечно же недопустимо (но правда тут мы просто машину покупаем а не программами торгуем).
В любом случае на f# можно и нужно писать real world программы и данная задача просто недостаточно сложна алгоритмически и логически чтобы показать все возможности и достоинства языка, но даже такие задачи получаются неплохо, а главное интересно и достаточно быстро, если не считать время на освоение языка.
PS: Осталось написать веб-интерфейс и попросить с подписавшихся donation на оплату sms gateway и начать рассылать сообщения, если авто.ру не забанит меня раньше :)
Поделиться публикацией

Похожие публикации

Комментарии 10

    +1
    Не забанит, я уверен.
    Неплохо расписал всё.
    +1
      +2
      use object initializer, Luke!

      use message = new MailMessage(Body = content, Subject = subj, IsBodyHtml = true)
      message.To.Add recepinet
        0
        Спасибо. Да, есть такие моменты (тот же if Seq.exists (fun x-> true) newCars then вместо Seq.IsEmpty), не буду врать, на F# я не очень давно :)
        0
        Seq.tryFind (fun x -> x = y)
        => Seq.tryFind ((=) y)

        Зачем постоянный |> там, где он явно избыточен?
        Неужели ``newCars |> List.ofSeq`` понятнее, чем ``List.ofSeq newCars``?
        ;)
          0
          А помоему в данной ситуации как раз это более читаемо, но кому как.
            0
            А вот за Seq.tryFind ((=) y) большое спасибо.
            Не видел раньше такой записи.
            +1
            [irony] А при чем тут Туарег в хаче-тюнинге? :)) [/irony]
              0
              Я к тому что данный экземпляр как раз не для нашей зимы и даже лета и вообще наших дорог :)
              Отсюда не видно, но не удивлюсь, если его еще на слики поставили — тогда он вообще в первой же луже забуксует :)
                0
                Согласен, свесы у него получаются совсем никакие, но выглядит он более представительно. Хачтюнинг это помоему 13" реплика на колёсах и синие писалки, а тут вполне себе оригинальный гольф переросток :)
              0
              Я с помощью F# недавно вытягивал статусы с сайта EMS для уведомления об обновлении статуса отправления. Правда, не стал запариваться с смс/емейлами, сделал настольную программу на WPF, которая сидит в трее.

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

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