Pull to refresh

Зачем выполнять рутинную работу, когда её можно поручить машине?

Programming *.NET *C# *
Sandbox
В очередной раз пересматривая «Железного Человека» вместе с другом, меня снова пропитывало желание стать супергероем создать свой железный костюм, ну или хотя бы Джарвиса. И вдруг меня посетила гениальная идея.

Лирическое отступление
Пару дней назад был матч СуперКубка Европы 2015 по футболу, который я включил лишь на 70 минуте. Да, я досмотрел оставшуюся часть игры, но все равно остался неприятный осадок из-за того, что 70 минут отличного футбола я смогу посмотреть лишь в записи (что, как вы понимаете, совсем не то), ведь я как-то напрочь забыл про то, что летом тоже идут официальные матчи. Поплакали и хватит.

Так вот, с недавнего времени я начал активно пользовался системным календарём на Mac, и подумал, а почему бы не добавить все игры Барсы в этот самый календарь?




Terminal Mac


Так-с, надо с чего-то начать. Ага, нужно понять, как создавать события в Календаре с помощью кода, т.е. нам нужно Mac Calendar API. Первым делом полез в терминал, и введя
Mac:~ dima$ whatis calendar

получаем несколько команд:
Mac:~ dima$ whatis calendar
cal(1), ncal(1)          - displays a calendar and the date of easter
calendar(1)              - reminder service
iwidgets_calendar(n), iwidgets::calendar(n) - Create and manipulate a monthly calendar
widget_calendar(n)       - widget::calendar Megawidget

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

Немного погуглив, я вышел на язык скрипта для Mac — AppleScript, в котором содержится какое-никакое API всех встроенных приложений в формате так называемых «словарей» (вполне возможно, что слишком неумело гуглил, поэтому ничего лучше AppleScript) не нашел.

Немного AppleScript


Открыв редактор скриптов и немного изучив встроенный «словарь» команд, периодически принося жертвоприношения божествам поиска используя гугл, я понял, что создать событие вполне возможно (правда пришлось «пободаться» с этим языком, особенно с датами):
set startDate to date "суббота, 25 августа 2015 г., 0:00:00"
set endDate to date "суббота, 25 августа 2015 г., 10:00:00"
tell application "Calendar"
	tell calendar "Развлечения, отдых"
		make new event with properties {description:"Test", summary:"Something", start date:startDate, allday event:true}
	end tell
end tell

Отлично, зафиксируем успех, перекусим и пойдем дальше.

Будем парсить!


Открываем Xamarin Studio, устанавливаем HtmlAgillityPack NuGet-пакет с помощью встроенного контекстного меню, и проверяем, появился ли он в Пакетах.








Не забываем подключить установленный пакет с помощью директивы using и начинаем писать код. Начнём с класса SingleMatch, в котором будем хранить все то, что мы напарсим. Создадим авто-реализуемые свойства, т.к. нам не нужна здесь логика выполнения, два конструктора (с параметрами и без) и переопределим стандартный для всех классов метод ToString().

SingleMatch.cs
using System;

namespace barca_matches_to_the_calendar
{
	/// <summary>
	/// Класс одного матча.
	/// </summary>
	public class SingleMatch
	{

		/// <summary>
		/// Дата времени начала матча.
		/// </summary>
		/// <value>Время начала матча типа <see cref="DateTime"/>.</value>
		public DateTime StartTime
		{
			get;
			set;
		}

		/// <summary>
		/// Название турнира.
		/// </summary>
		/// <value>Название турнира.</value>
		public string Tournament
		{
			get;
			set;
		}

		/// <summary>
		/// Наименование ФК-соперника.
		/// </summary>
		/// <value>Наименование ФК - соперника.</value>
		public string Rival
		{
			get;
			set;
		}

		/// <summary>
		/// Место матча (в гостях/дома).
		/// </summary>
		/// <value>Место ("В гостях"/"Дома").</value>
		public string Place
		{
			get;
			set;
		}

		/// <summary>
		/// Создает объект класса SingleMatch
		///  <see cref="barca_matches_to_the_calendar.SingleMatch"/> class.
		/// </summary>
		public SingleMatch()
		{
			StartTime = new DateTime();
			Tournament = null;
			Rival = null;
			Place = null;
		}


		/// <summary>
		/// Создаёт объект класса SingleMatch
		/// <see cref="barca_matches_to_the_calendar.SingleMatch"/> class.
		/// </summary>
		/// <param name="startTime">Время начала матча.</param>
		/// <param name="tournament">Турнир.</param>
		/// <param name="rival">Соперник.</param>
		/// <param name="place">Место (в гостях/дома).</param>
		public SingleMatch(DateTime startTime,
		                   string tournament,
		                   string rival,
		                   string place)
		{
			StartTime = startTime;
			Tournament = tournament;
			Rival = rival;
			Place = place;
		}


		/// <summary>
		/// Возращает a <see cref="System.String"/>, которая является текстовым
		/// представлением  <see cref="barca_matches_to_the_calendar.SingleMatch"/>.
		/// </summary>
		/// <returns>
		/// Возращает a <see cref="System.String"/>, которая является текстовым
		/// представлением  <see cref="barca_matches_to_the_calendar.SingleMatch"/>.
		/// </returns>
		public override string ToString()
		{
			return string.Format("Время начала матча={0}, турнир={1}, соперник={2}, место={3}",
				StartTime, Tournament, Rival, Place);
		}
	}
}


Т.к. у нас не один матч, а несколько, мы будем хранить их в списке. Но раз уж мы сохраняем календарь одного выбранного ФК, вынесем его название в отдельное свойство и создадим еще один класс для хранения всех матчей.

Matches.cs
using System.Collections.Generic;

namespace barca_matches_to_the_calendar
{
	public class Matches
	{
		/// <summary>
		/// Название ФК.
		/// </summary>
		/// <value>Название ФК.</value>
		public string NameFC
		{
			get;
			set;
		}

		/// <summary>
		/// Лист матчей команды
		/// </summary>
		/// <value>Лист матчей</value>
		public List<SingleMatch> ListMatches
		{
			get;
			set;
		}

		/// <summary>
		/// Initializes a new instance of the <see cref="barca_matches_to_the_calendar.Matches"/> class.
		/// </summary>
		public Matches()
		{
			ListMatches = new List<SingleMatch>();
			NameFC = null;
		}

		/// <summary>
		/// Initializes a new instance of the <see cref="barca_matches_to_the_calendar.Matches"/> class.
		/// </summary>
		/// <param name="nameFC">Название ФК.</param>
		public Matches(string nameFC)
		{
			ListMatches = new List<SingleMatch>();
			NameFC = nameFC;
		}

		/// <summary>
		/// Возращает a <see cref="System.String"/>, которая является текстовым
		/// представлением  <see cref="barca_matches_to_the_calendar.Matches"/>.
		/// </summary>
		/// <returns>
		/// Возращает a <see cref="System.String"/>, которая является текстовым
		/// представлением  <see cref="barca_matches_to_the_calendar.Matches"/>.
		/// </returns>
		public override string ToString()
		{
			return string.Format("Матчи ФК  \"{0}\", количество {1}", NameFC, ListMatches);
		}
	}
}


Ну и собственно основной класс Program.cs, в котором мы парсим указанный сайт и сохраняем все в текстовый файл, из которого мы перенесём все события в календарь с помощью AppleScript.

Program.cs
using System;
using HtmlAgilityPack;
using System.Collections.Generic;
using System.Linq;

namespace barca_matches_to_the_calendar
{
	class MainClass
	{
		public static void Main(string[] args)
		{
			// Адрес сайта, откуда будем парсить данные.
			string WebAddress = @"http://www.sports.ru/barcelona/calendar/";

			// Создаём экземляры классов веб-страницы и веб-документа
			HtmlWeb WebGet = new HtmlWeb();
			// Загружаем html-документ с указанного сайта.
			HtmlDocument htmlDoc = WebGet.Load(WebAddress);

			// Сюда будем сохранять матчи
			Matches MatchesFC = new Matches();

			// Парсим название клуба (удаляя символ возрата каретки)
			MatchesFC.NameFC = htmlDoc.DocumentNode.
				SelectSingleNode(".//*[@class='titleh2']").
				FirstChild.InnerText.Replace("\r\n", "");
			
			
			// Находим в этом документе таблицу с датами матчей с помощью XPath-выражений.
			HtmlNode Table = htmlDoc.DocumentNode.SelectSingleNode(".//*[@class='stat-table']/tbody");
			// Из полученной таблицы выделяем все элементы-строки с тегом "tr".
			IEnumerable<HtmlNode> rows = Table.Descendants().Where(x => x.Name == "tr");

			foreach (var row in rows)
			{
				// Создаём коллекцию из ячеек каждой строки.
				HtmlNodeCollection cells = row.ChildNodes;
				// Создаём экземпляр класса SingleMatch, чтобы затем добавить его в лист.
				SingleMatch match = new SingleMatch();

				// Парсим дату, предварительно убирая из строки символ черточки "|",
				// иначе наш метод TryParse не сможет правильно обработать.
				DateTime time;
				DateTime.TryParse(cells[1].InnerText.Replace("|", " "), out time);
				match.StartTime = time;

				// Остальные поля просто заполняем, зная нужный нам индекс.
				match.Tournament = cells[3].InnerText;
				// В ячейке "Соперник" нужно удалить символ неразрывного пробела ("&nbsp")
				match.Rival = cells[5].InnerText.Replace(" ", "");
				match.Place = cells[6].InnerText;

				// Добавляем одиночный матч в лист матчей.
				MatchesFC.ListMatches.Add(match);
			}

			// Сохраняем полученный результат.
			foreach (SingleMatch match in MatchesFC)
			{
				// Переменная для адреса файла в котором мы сохраняем матчи в текстовом формате
				// (считая от рабочей директории проекта)
				string path = @"matches.txt";

				// Записываем матчи
				using (StreamWriter file = new StreamWriter(path, true))
				{
					file.WriteLine(match);
				}
			}

			Console.WriteLine("Все матчи записаны в файл успешно!");
		}
	}
}


Окей, сайт запарсили, все матчи лежат в файле в удобно-читаемом формате, возвращаемся к AppleScript.

(Где-то здесь я подумал, а нет ли где-нибудь на сайте или в приложении уже готовой кнопочки для импорта всего календаря. Скачал приложение сайта на телефон, и нашел там добавление напоминания об одном матче, но не о всех сразу. Да и даже если бы обнаружил импорт всего календаря игр сразу, вряд ли остановился, ведь это не путь Джедая!).

Новое — хорошо забытое старое


С помощью Finder открываем файл, парсим его и передаём в функцию создания события.
Что из из этого вышло
tell application "Finder"
	--Вызываем окно, в котором выберем наш файл и разобьём его по параграфам (т.е. по символам новой строки)
	set Matches to paragraphs of (read (choose file with prompt "Выберите файл, в котором сохранено расписание матчей"))
	repeat with Match in Matches
		if length of Match is greater than 0 then
			--Парсим текст с помощью разделителей '=', ',' и символа новой строки '\n'
			set AppleScript's text item delimiters to {"=", ",", ASCII character 13}
			--Создаём событие и передаём туда данные
			my CreateEvent(text item 2 of Match, text item 4 of Match, text item 6 of Match, text item 8 of Match, text item 10 of Match)
		end if
	end repeat
end tell

on CreateEvent(textDate, tournament, fc, rival, place)
	--Здесь очень кривой парсинг даты из текста
	set startDate to the current date
	
	set the day of startDate to (text 1 thru 2 of textDate)
	set the month of startDate to (text 4 thru 5 of textDate)
	set the year of startDate to (text 7 thru 10 of textDate)
	
	set the hours of startDate to (text 12 thru 13 of textDate)
	set the minutes of startDate to (text 15 thru 16 of textDate)
	set the seconds of startDate to (text 18 thru 19 of textDate)
	-- Конец очень кривого парсинга даты из текста
	set endDate to (startDate + 2 * hours)
	tell application "Calendar"
		create calendar with name "Футбол"
		tell calendar "Футбол"
			--Создаём событие с переданными параметрами
			make new event with properties {description:"Матч" & fc & " : " & rival & ". " & place, summary:"Матч", start date:startDate, end date:endDate}
		end tell
	end tell
end CreateEvent


Но где-то посередине этого быдлокодинга написания этого скрипта, я вдруг обнаружил (на этом моменте читатели, которые смотрели на все эти сложности с вопросом «Зачем так сложно?» вздохнули с облегчением), что есть такой формат календарей — ".ics", который проглатывает и Mac Calendar, и Google Calendar, и даже Outlook, и, следовательно, наверняка есть какая-нибудь библиотека на C#, которая позволяет сохранять события в этот самый ".ics".

Еще чуть-чуть и наш костыльный велосипед готов


Установив и подключив библиотеку DDay.iCal, немного изменим наш класс Program.cs:
Program.cs
using System;
using HtmlAgilityPack;
using System.Collections.Generic;
using System.Linq;
using DDay.iCal;
using DDay.iCal.Serialization.iCalendar;
using System.Security.Cryptography;

namespace barca_matches_to_the_calendar
{
	class MainClass
	{
		public static void Main(string[] args)
		{
			// Адрес сайта, откуда будем парсить данные.
			string WebAddress = @"http://www.sports.ru/barcelona/calendar/";

			// Создаём экземляры классов веб-страницы и веб-документа
			HtmlWeb WebGet = new HtmlWeb();
			// Загружаем html-документ с указанного сайта.
			HtmlDocument htmlDoc = WebGet.Load(WebAddress);

			// Сюда будем сохранять матчи
			Matches MatchesFC = new Matches();

			// Парсим название клуба (удаляя символ возрата каретки)
			MatchesFC.NameFC = htmlDoc.DocumentNode.
				SelectSingleNode(".//*[@class='titleH1']").
				FirstChild.InnerText.Replace("\r\n", "");
			
			
			// Находим в этом документе таблицу с датами матчей с помощью XPath-выражений.
			HtmlNode Table = htmlDoc.DocumentNode.SelectSingleNode(".//*[@class='stat-table']/tbody");
			// Из полученной таблицы выделяем все элементы-строки с тегом "tr".
			IEnumerable<HtmlNode> rows = Table.Descendants().Where(x => x.Name == "tr");

			foreach (var row in rows)
			{
				// Создаём коллекцию из ячеек каждой строки.
				HtmlNodeCollection cells = row.ChildNodes;
				// Создаём экземпляр класса SingleMatch, чтобы затем добавить его в лист.
				SingleMatch match = new SingleMatch();

				// Парсим дату, предварительно убирая из строки символ черточки "|",
				// иначе наш метод TryParse не сможет правильно обработать.
				DateTime time;
				DateTime.TryParse(cells[1].InnerText.Replace("|", " "), out time);
				match.StartTime = time;

				// Остальные поля просто заполняем, зная нужный нам индекс.
				match.Tournament = cells[3].InnerText;
				// В ячейке "Соперник" нужно удалить символ неразрывного пробела ("&nbsp")
				match.Rival = cells[5].InnerText.Replace(" ", "");
				match.Place = cells[6].InnerText;

				// Добавляем одиночный матч в лист матчей.
				MatchesFC.ListMatches.Add(match);
			}

			// Создаём календарь, в который будем сохранять матчи.
			iCalendar CalForMatches = new iCalendar
			{
				Method = "PUBLISH",
				Version = "2.0"
			};
			// Эти настройки нужны для календаря Mac, чтобы он был неотличим от 
			// оригинального календаря (т.е. созданного внутри Mac Calendar)
			CalForMatches.AddProperty("CALSCALE", "GREGORIAN");
			CalForMatches.AddProperty("X-WR-CALNAME", "Mатчи ФК " + MatchesFC.NameFC);
			CalForMatches.AddProperty("X-WR-TIMEZONE", "Europe/Moscow");
			CalForMatches.AddLocalTimeZone();

			// Сохраняем полученный результат.
			foreach (SingleMatch match in MatchesFC.ListMatches)
			{
				Event newMatch = CalForMatches.Create<Event>();

				newMatch.DTStart = new iCalDateTime(match.StartTime);
				newMatch.Duration = new TimeSpan(2, 30, 0);
				newMatch.Summary = string.Format("{0} : {1}", MatchesFC.NameFC, match.Rival);
				newMatch.Description = string.Format("{0}. {1} : {2}, {3}",
					match.Tournament, MatchesFC.NameFC, match.Rival, match.Place);
				
				// Добавим напоминание к матчам, чтобы не забыть о них
				Alarm alarm = new Alarm();
				alarm.Trigger = new Trigger(TimeSpan.FromMinutes(-10));
				alarm.Description = "Напоминание о событии";
				alarm.AddProperty("ACTION", "DISPLAY");
				newMatch.Alarms.Add(alarm);
			}

			// Сериализуем наш календарь.
			iCalendarSerializer serializer = new iCalendarSerializer();
			serializer.Serialize(CalForMatches, MatchesFC.NameFC + ".ics");
			Console.WriteLine("Календарь матчей сохранён успешно" + Environment.NewLine);

			return;
		}
	}
}


Здесь отдельного внимания заслуживает строка
alarm.AddProperty("ACTION", "DISPLAY");

Изначально здесь было
alarm.Action = AlarmAction.Display;
из-за которой все напоминания не хотели импортироваться в календарь лишь потому что слово «Display» записывалось не заглавными буквами, а как обычное слово и календарь мака не смог распознать этот чудовищный криптокод.

Ноги на педали на костыли и поехали!


Итак, получилось, календарь игр сохранён. Создаем новый календарь iCloud (чтобы оповещения приходили на все iустройства), скрещиваем пальцы и импортируем файл календаря в наш новый календарь iCloud:






Гип-гип-ура, осталось выбрать подходящий цвет (здесь проблем возникнуть не должно), и мы получили график игр с напоминаниями. Надеюсь, теперь я буду реже пропускать игры.

Исходники:


гитхаб

Используемые ресурсы:


AppleScript
xpath-выражения
Примеры использования DDay.iCal
Примеры использования HtmlAgillityPack

P.S. Многие из Вас могут задаться вопросом:«Смысл писать свое, если уже есть готовое?»
Ответ: Мне еще ни разу не доводилось ничего парсить, а тут удобная и интересная возможность попробовать что-то новое.
Tags:
Hubs:
Total votes 20: ↑17 and ↓3 +14
Views 21K
Comments Comments 7