Рассматривая новый выпуск xkcd, я взглянул на свою свежеприобретённую электрокнигу Sony PRS-650, и немедленно подумал — хочу смотреть комиксы на ней! Xkcd как раз чёрно-белые и, обычно, небольшие по размеру. Слегка погуглив, нашел лишь сборник картинок на TPB, и скрипт на bash, который должен делать PDF. Решил слегка поразмяться в программировании и сделать граббер комиксов на любимом C#.Можно было бы обойтись консольным приложением, но, для наглядности, сделал простой интерфейс на 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
