Рассматривая новый выпуск xkcd, я взглянул на свою свежеприобретённую электрокнигу Sony PRS-650, и немедленно подумал — хочу смотреть комиксы на ней! Xkcd как раз чёрно-белые и, обычно, небольшие по размеру. Слегка погуглив, нашел лишь сборник картинок на TPB, и скрипт на bash, который должен делать PDF. Решил слегка поразмяться в программировании и сделать граббер комиксов на любимом C#.
Можно было бы обойтись консольным приложением, но, для наглядности, сделал простой интерфейс на WPF.
Полный разбор кода будет излишним, поэтому объясню основные моменты. Рекомендую сразу открыть/скачать полный код приложения с Google Code.
На xkcd комиксы удобно лежат по адресам вида xkcd.com/n, где n=1…
Первая мысль была выдирать нужное из кода страницы, но обнаружилось, что можно получить всю информацию в JSON по адресу вида xkcd.com{0}/info.0.json
Для JSON в .NET существует DataContractJsonSerializer
Создаём соответствующий DataContract:
… и используем:
По адресу xkcd.com/info.0.json можно получить последний комикс, и, взяв его номер из поля num, узнать их общее количество.
Осталось выкачать саму картинку, тут всё просто:
… где comicInfo — это наши данные из JSON, а ToBytes() — простой extension-метод, который считывает данные из потока в массив.
Для представления комикса (комик-стрип, или как его правильно называть в единственном числе?) используется класс Comic. Чтобы валидировать полученные байты картинки (а скачать мы могли что-нибудь не то, сервер мог вернуть ошибку, и т.п.) конструктор класса сделан приватным, и добавлен фабричный метод Create, который вернёт null в случае ошибки декодирования. Для декодирования используется BitmapImage, который, в случае успеха, будет использован как thumbnail для предпросмотра результата:
Собрав всё воедино, получим метод для закачки комик-стрипа по его номеру:
Таким образом, у нас есть количество комиксов и метод для получения стрипа по индексу.
Буду использовать Task Parallel Library, так как давно собирался попробовать, а повода не было. На первый взгляд всё просто, в цикле вместо прямого вызова GetComicByIndex(i) делаем var task = Task.Factory.StartNew(() => GetComicByIndex(i)). Записываем все запущенные задачи в массив tasks и делаем Task.WaitAll(tasks), после чего получаем результаты каждой задачи из task.Result. Но такой подход не позволит нам отслеживать прогресс и показывать уже загруженные стрипы пользователю. Для решения этой проблемы будем использовать WaitAny и yield return, чтобы возвращать результат каждой задачи сразу по завершении:
Здесь метод GetTask возвращает задачу GetComicByIndex(i), плюс обработка ошибок и кеширование (это выходит за рамки данной статьи). WaitAnyAndPop — extension метод, который ждёт завершения одной из задач, удаляет её из списка и возвращает:
Теперь в коде ViewModel (архитектурные вопросы в этой статье я не рассматриваю, но MVVM (Model-View-ViewModel) — это стандарт де-факто для WPF приложений, а код для выкачивания, экспорта и других вещей, разумеется, разбит по соответствующим классам) мы можем в фоновом потоке итерировать по результату метода GetComics и показывать пользователю стрипы по мере поступления:
В XAML коде нам остаётся лишь сделать Binding на нашу ObservableCollection, и подготовить соответствующий DataTemplate, чтобы наблюдать процесс загрузки и сами комиксы, с альт-текстом в Tooltip:
PDF выбран по причине популярности и хорошей поддержки в электрокнигах Sony. Для работы с PDF в .NET есть удобная библиотека c открытым кодом iTextSharp (вам понадобится скачать её отдельно, чтобы собрать проект). Тут всё достаточно бесхитростно. Опуская exception handling, подгон размера картинки и шрифты, получим следующее:
Получилось вот такое приложение, которое, кроме экспорта в PDF, позволяет достаточно удобно просматривать комиксы:
Как выглядит результат на книге можно увидеть на первой картинке статьи.
Кеширование загруженных данных между запусками приложения (сделано с использованием IsolatedStorage).
Поддержка других вебкомиксов (C этой целью я заранее выделил интерфейс IGrabber, и вынес часть функциональности в TaskParallelGrabber. Пока писал статью, добавил грабберы для WhatTheDuck и Cyanide & Happiness).
Код приложения (С#): Google Code
Работа с PDF на .NET: iTextSharp
Комиксы: xkcd
UPD:
Спасибо XHunter, что залил результирующий PDF и скомпилированную программу!
UPD2:
Я просто оставлю здесь ссылку на хорошую «ответную» статью, в которой подробно раскрывается тема выкачивания комиксов средствами WCF: http://darren-brown.com/?p=37
Можно было бы обойтись консольным приложением, но, для наглядности, сделал простой интерфейс на WPF.
Полный разбор кода будет излишним, поэтому объясню основные моменты. Рекомендую сразу открыть/скачать полный код приложения с Google Code.
1. Получаем картинки, названия и alt-текст с сайта
На xkcd комиксы удобно лежат по адресам вида xkcd.com/n, где n=1…
Первая мысль была выдирать нужное из кода страницы, но обнаружилось, что можно получить всю информацию в JSON по адресу вида xkcd.com{0}/info.0.json
Для JSON в .NET существует DataContractJsonSerializer
Создаём соответствующий DataContract:
[DataContract]
public class XkcdComic
{
#region Public properties and indexers
[DataMember]
public string img { get; set; }
[DataMember]
public string title { get; set; }
[DataMember]
public string month { get; set; }
[DataMember]
public string num { get; set; }
[DataMember]
public string link { get; set; }
[DataMember]
public string year { get; set; }
[DataMember]
public string news { get; set; }
[DataMember]
public string safe_title { get; set; }
[DataMember]
public string transcript { get; set; }
[DataMember]
public string day { get; set; }
[DataMember]
public string alt { get; set; }
#endregion
}
… и используем:
private static XkcdComic GetComic(string url)
{
var stream = new WebClient().OpenRead(url);
if (stream == null) return null;
var serializer = new DataContractJsonSerializer(typeof (XkcdComic));
return serializer.ReadObject(stream) as XkcdComic;
}
По адресу xkcd.com/info.0.json можно получить последний комикс, и, взяв его номер из поля num, узнать их общее количество.
Осталось выкачать саму картинку, тут всё просто:
var imageBytes = WebRequest.Create(comicInfo.img).GetResponse().GetResponseStream().ToBytes();
… где comicInfo — это наши данные из JSON, а ToBytes() — простой extension-метод, который считывает данные из потока в массив.
Для представления комикса (комик-стрип, или как его правильно называть в единственном числе?) используется класс Comic. Чтобы валидировать полученные байты картинки (а скачать мы могли что-нибудь не то, сервер мог вернуть ошибку, и т.п.) конструктор класса сделан приватным, и добавлен фабричный метод Create, который вернёт null в случае ошибки декодирования. Для декодирования используется BitmapImage, который, в случае успеха, будет использован как thumbnail для предпросмотра результата:
public static Comic Create(byte[] imageBytes)
{
try
{
// Validate image bytes by trying to create a Thumbnail.
return new Comic {ImageBytes = imageBytes};
}
catch
{
// Failure, cannot decode bytes
return null;
}
}
public byte[] ImageBytes
{
get { return _imageBytes; }
private set
{
_imageBytes = value;
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.DecodePixelHeight = 100; // Do not store whole picture
bmp.StreamSource = new MemoryStream(_imageBytes);
bmp.EndInit();
bmp.Freeze();
Thumbnail = bmp;
}
}
Собрав всё воедино, получим метод для закачки комик-стрипа по его номеру:
protected override Comic GetComicByIndex(int index)
{
// Download comic JSON
var comicInfo = GetComic(string.Format(UrlFormatString, index + 1));
if (comicInfo == null) return null;
// Download picture
var imageStream = WebRequest.Create(comicInfo.img).GetResponse().GetResponseStream().ToMemoryStream();
var comic = Comic.Create(imageStream.GetBuffer());
if (comic == null) return null;
comic.Description = comicInfo.alt;
comic.Url = comicInfo.link;
comic.Index = index + 1;
comic.Title = comicInfo.title;
// Auto-rotate for best fit
var t = comic.Thumbnail;
if (t.Width > t.Height)
{
comic.RotationDegrees = 90;
}
return comic;
}
Таким образом, у нас есть количество комиксов и метод для получения стрипа по индексу.
Распараллеливание закачек
Буду использовать Task Parallel Library, так как давно собирался попробовать, а повода не было. На первый взгляд всё просто, в цикле вместо прямого вызова GetComicByIndex(i) делаем var task = Task.Factory.StartNew(() => GetComicByIndex(i)). Записываем все запущенные задачи в массив tasks и делаем Task.WaitAll(tasks), после чего получаем результаты каждой задачи из task.Result. Но такой подход не позволит нам отслеживать прогресс и показывать уже загруженные стрипы пользователю. Для решения этой проблемы будем использовать WaitAny и yield return, чтобы возвращать результат каждой задачи сразу по завершении:
public IEnumerable<Comic> GetComics()
{
var count = GetCount();
var tasks = Enumerable.Range(0, count).Select(GetTask).ToList();
while (tasks.Count > 0) // Iterate until all tasks complete
{
var task = tasks.WaitAnyAndPop();
if (task.Result != null) yield return task.Result;
}
}
Здесь метод GetTask возвращает задачу GetComicByIndex(i), плюс обработка ошибок и кеширование (это выходит за рамки данной статьи). WaitAnyAndPop — extension метод, который ждёт завершения одной из задач, удаляет её из списка и возвращает:
WaitAnyAndPop — extension метод, который ждёт завершения одной из задач, удаляет её из списка и возвращает:
public static Task<T> WaitAnyAndPop<T>(this List<Task<T>> taskList)
{
var array = taskList.ToArray();
var task = array[Task.WaitAny(array)];
taskList.Remove(task);
return task;
}
Теперь в коде ViewModel (архитектурные вопросы в этой статье я не рассматриваю, но MVVM (Model-View-ViewModel) — это стандарт де-факто для WPF приложений, а код для выкачивания, экспорта и других вещей, разумеется, разбит по соответствующим классам) мы можем в фоновом потоке итерировать по результату метода GetComics и показывать пользователю стрипы по мере поступления:
private readonly Dispatcher _dispatcher;
private readonly ObservableCollection<Comic> _comics = new ObservableCollection<Comic>();
private void StartGrabbing()
{
_dispatcher = Dispatcher.CurrentDispatcher; // ObservableCollection modifications should be performed on the UI thread
ThreadPool.QueueUserWorkItem(o => DoGrabbing());
}
private void DoGrabbing()
{
var grabber = new XkcdGrabber();
foreach (var comic in grabber.GetComics())
{
var c = comic;
_dispatcher.Invoke((Action) (() => Comics.Add( c )), DispatcherPriority.ApplicationIdle);
}
}
2. Отображаем комиксы в WPF
В XAML коде нам остаётся лишь сделать Binding на нашу ObservableCollection, и подготовить соответствующий DataTemplate, чтобы наблюдать процесс загрузки и сами комиксы, с альт-текстом в Tooltip:
<ListView ItemsSource="{Binding Comics}" ScrollViewer.VerticalScrollBarVisibility="Disabled"
x:Name="list" Margin="5,0,5,0"
ScrollViewer.HorizontalScrollBarVisibility="Visible" Grid.Row="1">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderBrush="Gray" CornerRadius="5" Padding="5" Margin="5" BorderThickness="1">
<StackPanel Orientation="Vertical">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Index}" FontWeight="Bold" />
<TextBlock Text="{Binding Title}" FontWeight="Bold" Margin="10,0,0,0" />
</StackPanel>
<Image Source="{Binding Thumbnail}" ToolTip="{Binding Description}"
Height="{Binding Thumbnail.PixelHeight}" Width="{Binding Thumbnail.PixelWidth}" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ListView>
3. Создаём PDF-книгу
PDF выбран по причине популярности и хорошей поддержки в электрокнигах Sony. Для работы с PDF в .NET есть удобная библиотека c открытым кодом iTextSharp (вам понадобится скачать её отдельно, чтобы собрать проект). Тут всё достаточно бесхитростно. Опуская exception handling, подгон размера картинки и шрифты, получим следующее:
var document = new Document(PageSize.LETTER);
var wri = PdfWriter.GetInstance(document, new FileStream(fileName, FileMode.Create));
document.Open();
foreach (var comic in comics.OrderBy(c => c.Index).ToList())
{
var image = Image.GetInstance(new MemoryStream(comic.ImageBytes));
var title = new Paragraph(comic.Index + ". " + comic.Title, titleFont);
title.SetAlignment("Center");
document.Add(title);
document.Add(image);
document.Add(new Phrase(comic.Description, altFont));
document.Add(Chunk.NEXTPAGE);
}
document.Close();
Результаты
Получилось вот такое приложение, которое, кроме экспорта в PDF, позволяет достаточно удобно просматривать комиксы:
Как выглядит результат на книге можно увидеть на первой картинке статьи.
Что осталось за рамками статьи
Кеширование загруженных данных между запусками приложения (сделано с использованием IsolatedStorage).
Поддержка других вебкомиксов (C этой целью я заранее выделил интерфейс IGrabber, и вынес часть функциональности в TaskParallelGrabber. Пока писал статью, добавил грабберы для WhatTheDuck и Cyanide & Happiness).
Ссылки
Код приложения (С#): Google Code
Работа с PDF на .NET: iTextSharp
Комиксы: xkcd
UPD:
Спасибо XHunter, что залил результирующий PDF и скомпилированную программу!
UPD2:
Я просто оставлю здесь ссылку на хорошую «ответную» статью, в которой подробно раскрывается тема выкачивания комиксов средствами WCF: http://darren-brown.com/?p=37