Archive for Январь 2011
Простой web scraping на f#
Достаточно законный вопрос почему такая избитая тема как web scraping и почему f#. 1 . на f# web scraping намного увлекательней чем на c# 2. хотелось попробовать насколько f# применим для разработки не демо примеров а что то реально делающих программ 3. У f# есть интерактивная консоль, что при ковырянии в недрах HTML становится просто спасением.
Touareg
К делу, будем покупать 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, если авто.ру не забанит меня раньше 🙂
Mole IDisposable.Dispose in HttpWebResponse
Тестируя код использующий HttpWebRequest внутри себя с помощью Moles столкнулся с таким сообщением: 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 } } } }
Две вещи которых мне сразу не хватает в C# после F#
Я не считаю себя превередливым человеком, но есть вещи в которых очень сложно себе отказать. Я думаю любой кто хотя бы пару дней посидел за 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); } } }