Alexey Suvorov dev blog

Мой разработческий блог

Archive for Январь 2011

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

6 комментариев

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

Touareg

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

Общие методы

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

module WebUtil =
    let LoadPage (x:WebRequest) =
        use resp = x.GetResponse()
        let rs = resp.GetResponseStream()
        let rr = new StreamReader(rs,System.Text.Encoding.GetEncoding(1251))
        rr.ReadToEnd()

    let LoadPageByUrl (x:string) =
        let request = WebRequest.Create(x)
        LoadPage request

    let SentByMail (recepinet: string) (subj:string) (content: string) =
        let client = new SmtpClient()
        client.DeliveryMethod <- SmtpDeliveryMethod.Network
        use message = new MailMessage()
        message.To.Add(recepinet)
        message.Body <- content
        message.Subject <- subj
        message.IsBodyHtml <- true 
        client.Send(message)
    (*
    <system.net>
    <mailSettings>
      <smtp from="YourMail@gmail.com">
        <network host="smtp.gmail.com" port="587" enableSsl="true" 
                 password="$ecretPa$$w0rd" defaultCredentials="false" 
                 userName="YourMail@gmail.com" />
      </smtp>
    </mailSettings>
    </system.net>
    *)

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

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

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

module CarParser =
    [<DataContract>]
    type Car = 
        {
            [<field: DataMember(Name="Year") >]
            Year: int;
            [<field: DataMember(Name="Price") >]
            Price: int;
            [<field: DataMember(Name="Model") >]
            Model: string;
            [<field: DataMember(Name="Engine") >]
            Engine: string;
            [<field: DataMember(Name="Url") >]
            Url: string;
        }

    [<DataContract>]
    type CarRequest =
        {
            [<field: DataMember(Name="Email") >]
            Email:string;
            [<field: DataMember(Name="RequestUrl") >]
            RequestUrl: string;
            [<field: DataMember(Name="Cars") >]
            Cars: Car list;
        }

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

open System;
open System.IO;
open System.Xml;
open System.Runtime.Serialization;
open System.Text.RegularExpressions;

module SerializationUtils = 
    let SerializeToFile (req:'T) (fileName: string) =
      let xmlSerializer = DataContractSerializer(typeof<'T>); 
      use fs = File.OpenWrite(fileName)
      xmlSerializer.WriteObject(fs, req)
  
    //T' will be calculated automatically
    let Deserialize<'T> (fileName:string) =
        let xmlSerializer = DataContractSerializer(typeof<'T>); 
        use fs = File.OpenRead(fileName)
        xmlSerializer.ReadObject(fs) :?> 'T

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

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

    
let private ParseCar (cnt: HtmlNode) =
    let columns = cnt.SelectNodes("td") |> Seq.toList
    let model = columns.[0].InnerText
    let txt = columns.[1].InnerText
    let price = txt |> (fun x -> Regex.Replace(x,"\\W",System.String.Empty)) |> Int32.Parse
    let url = columns.[0].ChildNodes 
            |> Seq.find (fun x -> x.Name.ToLower() = "a")
            |> (fun x-> x.Attributes) 
            |> Seq.find (fun x -> x.Name = "href")
            |> (fun x -> x.Value)
    let year = columns.[2].InnerText |> Int32.Parse
    let engine = columns.[3].InnerText
    let c: Car = { Year = year; Price = price; Model = model; Url = url; Engine = engine; }
    c

let private ParsePage (node: HtmlNode) (parseCar: HtmlNode -> Car) =
    node.SelectNodes("//div[@id='cars_sale']/table[@class='list']/descendant::tr[not(@class='header first')]")
    |> Seq.map parseCar

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

let private ParseCarNode x = ParsePage x ParseCar

let private GetCars (cntnt:string) (pars: HtmlNode -> seq<Car>) =
    let doc = new HtmlDocument()
    doc.LoadHtml(cntnt)
    pars doc.DocumentNode

let CreateCarRequest mail url =
    let cars = GetCars (LoadPageByUrl url) ParseCarNode
    { Email = mail; RequestUrl = url; Cars = cars |> List.ofSeq }

let UpdateCarList (oldRequest: CarRequest) =
    let newCars = GetCars (LoadPageByUrl oldRequest.RequestUrl) ParseCarNode
    let isContains y = Seq.tryFind (fun x -> x = y)
    let diff = newCars |> Seq.filter (fun x -> (oldRequest.Cars |> isContains x) = None)
    let res = { oldRequest with Cars = newCars |> List.ofSeq }
    //и результаты запроса и разница с предыдущим, очень приятный синтаксис
    (res,diff)

Итоги

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

PS: Осталось написать веб интерфейс и попросить с подписавшихся donation на оплату sms gateway, если авто.ру не забанит меня раньше 🙂

Written by alexeysuvorov

22.01.2011 at 11:11 пп

Опубликовано в .net, F#

Mole IDisposable.Dispose in HttpWebResponse

5 комментариев

Тестируя код использующий HttpWebRequest внутри себя с помощью Moles столкнулся с таким сообщением: WebResponse.System.IDisposable.Dispose() was not moled.

WebResponse.System.IDisposable.Dispose() was not moled.

Сам код:

[TestMethod]
[HostType("Moles")]
public void TestWebRequestMole() {
    MHttpWebResponse resp1Mole = new MHttpWebResponse();
    MHttpWebRequest.AllInstances.GetResponse = x => resp1Mole;
    using (var file1 = File.OpenRead("1.html")) {
        resp1Mole.GetResponseStream = () => file1;
        var request = HttpWebRequest.Create("http://valid.url");
        using(var response = request.GetResponse())
        {
            using (var reader = new StreamReader(response.GetResponseStream())) {
                var content = reader.ReadToEnd();
                Assert.IsNotNull(content);
                //check content
            }
        }
    }
}

HttpWebRequest при вызове GetResponse возвращает HttpWebResponse, который сам не имплементирует IDisposable напрямую, более того базовый WebResponse не содержит Mole свойства с именем Dispose потому что реализует IDisposble явно, зато у WebResponse есть другое свойство — SystemIDisposableDispose. Moles использует полную сигнатуру метода при реализации интерфейса явно. Код который работает:

[TestMethod]
[HostType("Moles")]
public void TestWebRequestMole() {
    MHttpWebResponse resp1Mole = new MHttpWebResponse();

    MWebResponse.AllInstances.SystemIDisposableDispose = (x) => { };

    MHttpWebRequest.AllInstances.GetResponse = x => resp1Mole;
    using (var file1 = File.OpenRead("1.html")) {
        resp1Mole.GetResponseStream = () => file1;
        var request = HttpWebRequest.Create("http://valid.url");
        using(var response = request.GetResponse())
        {
            using (var reader = new StreamReader(response.GetResponseStream())) {
                var content = reader.ReadToEnd();
                Assert.IsNotNull(content);
                //check content
            }
        }
    }
}

Written by alexeysuvorov

22.01.2011 at 3:14 пп

Опубликовано в .net, C#, moles, tests

Две вещи которых мне сразу не хватает в C# после F#

with one comment

Я не считаю себя превередливым человеком, но есть вещи в которых очень сложно себе отказать. Я думаю любой кто хотя бы пару дней посидел за f# со мной согласится.
|> и seq.map:

namespace Common.LangExt
{
    using System;
    using System.Collections.Generic;

    public static class λExtension
    {
        public static U ApplyFunction<T, U>(this T t, Func<T, U> f) { return f(t); }
        public static void ApplyFunction<T>(this T t, Action<T> f) { f(t); }

        public static IEnumerable<T> ForEach<U, T>(this IEnumerable<U> t, Func<U, T> f)
        {
            foreach (U x in t) yield return f(x);
        }
        public static void ForEach<U>(this IEnumerable<U> t, Action<U> f)
        {
            foreach (U x in t) f(x);
        }
    }
}

Для тех кто не брезгует символами, которых нет на клавиатуре — своё пространство имён

namespace Common.LangExt.Light
{
    usingCommon.LangExt;
    using System;
    using System.Collections.Generic;

    public static class λExtensionLight
    {
        public static U à<T, U>(this T t, Func<T, U> f)
        {
            return t.ApplyFunction(f);
        }
        public static void à<T>(this T t, Action<T> f)
        {
            t.ApplyFunction(f);
        }

        public static IEnumerable<T> ƒ<U, T>(this IEnumerable<U> t, Func<U, T> f)
        {
            return t.ForEach(f);
        }
        public static void ƒ<U>(this IEnumerable<U> t, Action<U> f)
        {
            t.ForEach(f);
        }
    }
}

Written by alexeysuvorov

14.01.2011 at 3:41 дп

Опубликовано в .net, C#, F#