Парсинг BEncode на JavaScript. Просмотр торрент-файлов в Firefox

в 22:36, , рубрики: ajax, bencode, Firefox, javascript, p2p, Peer-to-Peer, rutracker.org, torrent, xmlhttprequest, метки: , , , , , , , ,

I. Зачем

Есть несколько способов просматривать торрент-файлы: в торрент-клиенте, в BEncode Editor, в файловых менеджерах с плагинами, возможно в сетевых сервисах (но это немножко стрёмно).

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

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

II. Стратегия

Для нашей задачи можно написать расширение к браузеру, но это сопряжено с целым рядом дополнительных трудностей. Поэтому мы воспользуемся упрощённым способом.

Расширение Custom Buttons позволяет создавать кнопки с произвольным кодом. Ещё лучше то, что код этот выполняется в контексте браузера, имеет доступ к тем же компонентам и интерфейсам, что и расширения, и даже может создавать элементы графического интерфейса произвольной сложности. Поэтому мы просто создадим новую кнопку и наполним её кодом (для всего понадобится двести строк) Весь следующий код нужно вставлять во вкладку инициализации новосозданной кнопки, чтобы он выполнялся при каждом запуске браузера, раз и на всю сессию определяя нужное поведение кнопки. А можно и не вставлять: расширение добавляет в браузер протокол custombutton://, и в конце статьи я дам ссылку, просто нажав на которую можно создать готовую кнопку с кодом (останется только перенести её из палитры инструментов в удобное место).

III. Тактика

1. Пользовательский интерфейс

var btn = this;
var imgMain = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAdVBMVEX////////////////////////////////XzeWjisHx7vaKaq5nNpV8VqSDYaqtmMiTdbS2pM/MwN6YfLjBstd7VqRuQZnk3u7y7/eKa6+SdbR1S56CX6jXzuabgLv49/uhh7+JaK39/P3e1ury7vbLv97g2es6Rn8YAAAAB3RSTlMAYMAg0PDzqTbVzAAAAAFiS0dEAIgFHUgAAAAJcEhZcwAAAEgAAABIAEbJaz4AAACcSURBVBjTXY/bAoIgEEQRsWERUykDMu1m/f8ntqC+dJ6WYfYyQjCFBCMLsVIqaGI0VJnflaltpjZVUpRp7EZjFPcj/R/bLntQCKm56IE2e7QUIGsJ7tSeaUhVEi5wgw/OR2uvWegQvR9zy+ogWDi7CzyUMO4CD+W1EdQj7mvTYT7cJkx0nO9qPf2B8JxdwOtQbuHeC5bPdwv3F/8HCk4KcI8+awQAAAAldEVYdGRhdGU6Y3JlYXRlADIwMTItMDYtMjZUMjE6MDk6MDQrMDQ6MDD1mOHZAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDEyLTA2LTI2VDIxOjA5OjA0KzA0OjAwhMVZZQAAAABJRU5ErkJggg==";
var imgThrobber = "data:image/gif;base64,R0lGODlhEAAQAOMIAAAAABoaGjMzM0xMTGZmZoCAgJmZmbKysv///////////////////////////////yH/C05FVFNDQVBFMi4wAwEAAAAh+QQBCgAIACwAAAAAEAAQAAAESBDJiQCgmFqbZwjVhhwH9n3hSJbeSa1sm5GUIHSTYSC2jeu63q0D3PlwCB1lMMgUChgmk/J8LqUIAgFRhV6z2q0VF94iJ9pOBAAh+QQBCgAPACwAAAAAEAAQAAAESPDJ+UKgmFqbpxDV9gAA9n3hSJbeSa1sm5HUMHTTcTy2jeu63q0D3PlwDx2FQMgYDBgmk/J8LqWPQuFRhV6z2q0VF94iJ9pOBAAh+QQBCgAPACwAAAAAEAAQAAAESPDJ+YSgmFqb5xjV9gQB9n3hSJbeSa1sm5EUQXQTADy2jeu63q0D3PlwDx2lUMgcDhgmk/J8LqUPg+FRhV6z2q0VF94iJ9pOBAAh+QQBCgAPACwAAAAAEAAQAAAESPDJ+cagmFqbJyHV9ggC9n3hSJbeSa1sm5FUUXRTEDy2jeu63q0D3PlwDx3FYMgAABgmk/J8LqWPw+FRhV6z2q0VF94iJ9pOBAAh+QQBCgAPACwAAAAAEAAQAAAESPDJ+QihmFqbZynV9gwD9n3hSJbeSa1sm5GUYXSTIDy2jeu63q0D3PlwDx3lcMgEAhgmk/J8LqUPAOBRhV6z2q0VF94iJ9pOBAAh+QQBCgAPACwAAAAAEAAQAAAESPDJ+UqhmFqbpzHV9hAE9n3hSJbeSa1sm5HUcXTTMDy2jeu63q0D3PlwDx0FAMgIBBgmk/J8LqWPQOBRhV6z2q0VF94iJ9pOBAAh+QQBCgAPACwAAAAAEAAQAAAESPDJ+YyhmFqb5znV9hQF9n3hSJbeSa1sm5EUAHQTQTy2jeu63q0D3PlwDx0lEMgMBhgmk/J8LqUPgeBRhV6z2q0VF94iJ9pOBAAh+QQBCgAPACwAAAAAEAAQAAAESPDJ+c6hmFqbJwDV9hgG9n3hSJbeSa1sm5FUEHRTUTy2jeu63q0D3PlwDx1FIMgQCBgmk/J8LqWPweBRhV6z2q0VF94iJ9pOBAA7";

function clickBtn(event) {
	if (event.button == 0) {
		event.preventDefault();
		var tFileURL = prompt("Torrent File URL:");
		if (tFileURL) {
			getTFile(tFileURL);
		}
	}
}

function checkDrag(event) {
	if (event.dataTransfer.types.contains("text/uri-list")) {
		event.preventDefault();
	}
}

function onDrop(event) {
	var tFileURL = event.dataTransfer.getData("URL");
	if (tFileURL) {
		getTFile(tFileURL);
	}
	event.preventDefault();
}

btn.addEventListener("click", clickBtn, true);
btn.addEventListener("dragenter", checkDrag, true);
btn.addEventListener("dragover", checkDrag, true);
btn.addEventListener("drop", onDrop, true);

btn.onDestroy = function() {
	btn.removeEventListener("click", clickBtn, true);
	btn.removeEventListener("dragenter", checkDrag, true);
	btn.removeEventListener("dragover", checkDrag, true);
	btn.removeEventListener("drop", onDrop, true);
}

В этом кусочке мы получаем объект кнопки, задаём два изображения (одно — главное, другое — стандартный индикатор загрузки файла, они будут чередоваться), определяем обработчики событий и привязываем их к кнопке.

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

В конце мы задаём деструкторы для привязанных обработчиков. В Custom Buttons есть неприятный баг: если явно не задать отвязки, обработчики будут наслаиваться друг на друга с каждым открытием и закрытием палитры инструментов (даже если вы настраиваете что-то другое при её помощи).

2. Получение торрент-файла

function getTFile(tFileURL) {
	btn.image = imgThrobber;
	var xhr = new XMLHttpRequest();
	xhr.mozBackgroundRequest = true;
	var sendData;
	if (tFileURL.indexOf("http://dl.rutracker.org/forum/dl.php") > -1) {
		xhr.open("POST", tFileURL, true);
		sendData = tFileURL.replace(/^.+b(t=d+).*$/, "$1");
		xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
		xhr.setRequestHeader("Referer", "http://rutracker.org/forum/viewtopic.php?t=" + tFileURL.replace(/^.+bt=(d+).*$/, "$1"));
	}
	else {
		xhr.open("GET", tFileURL, true);
		sendData = null;
	}
	xhr.timeout = 10000;
	xhr.channel.loadFlags |= Components.interfaces.nsIRequest.LOAD_BYPASS_CACHE;
	xhr.responseType = "arraybuffer";
	xhr.onload = function() {
		btn.image = imgMain;
		processTFile(this.response);
	}
	xhr.ontimeout = function() {
		btn.image = imgMain;
		alert("Timeout");
	}
	xhr.onerror = function() {
		btn.image = imgMain;
		alert("HTTP error");
	}
	xhr.send(sendData);
}

Заметим для начала, что торрент-файлы пишутся на языке BEncode (описание языка и формат торрент-файлов). Он немного похож на JSON по своим возможностям. Но в нём есть одна закавыка: теги, числа и строки в нём кодируются, как правило, на UTF-8, однако некоторые строки содержат бинарные данные (простую последовательность байтов, не подчиняющуюся правилам UTF-8). Поэтому нельзя просто обработать всю строку как UTF-8, декодировщик запнётся и выдаст ошибку о неправильном UTF-8. При разборе файла нужно иметь в виду эту предосторожность.

Теперь несколько замечаний о самом запросе и получении файла.

Мы запускаем картинку-пульсатор и задаём флаг фонового запроса, чтобы упростить работу браузеру. Потом мы проверяем адрес и успокаиваем защиту самого известного отечественного торрент-трекера, запрещающего скачивание торрент-файлов не со страниц сайта (этот способ, позволяющий расширениям работать с торрент-файлами трекера, когда-то при введении защиты описали сами разработчики сайта).

Раньше, чтобы получить бинарный файл от XMLHttpRequest, нужно было прибегать к некоторому колдунству. С введением новых типов ответа в XHR всё упростилось. Поэтому мы и будем использовать тип arraybuffer и работать в будущем с typed arrays.

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

3. Обработка и вывод торрент-файла

function processTFile(tFile) {
	var byteArray = new Uint8Array(tFile);
	var torrentObject = bdecode(byteArray);
	
	if (torrentObject) {
		if (torrentObject['creation date']) {
			torrentObject['creation date'] = (new Date(torrentObject['creation date']*1000)).toLocaleString();
		}
		if (torrentObject.info) {
			var files = torrentObject.info.files;
			if (files && files instanceof Array) {
				for (var i = 0, file; file = files[i]; i++) {
					if (file.length) {
						file.length = Number((file.length / 1024).toFixed(2)).toLocaleString() + " KB";
					}
					if (file['path.utf-8']) {
						file['path.utf-8'] = file['path.utf-8'].join("/");
					}
					if (file.path) {
						file.path = file.path.join("/");
					}
				}
				if (files[0]['path.utf-8']) {
					files = files.sort(
						function(o1, o2) {
							if (o1['path.utf-8'] > o2['path.utf-8']) {return 1;}
							else if (o1['path.utf-8'] < o2['path.utf-8']) {return -1;}
							else {return 0;}
						}
					);
				}
				else if (files[0].path) {
					files = files.sort(
						function(o1, o2) {
							if (o1['path'] > o2['path']) {return 1;}
							else if (o1['path'] < o2['path']) {return -1;}
							else {return 0;}
						}
					);
				}
				files.unshift(files.length);
			}
			else {
				if (torrentObject.info.length) {
					torrentObject.info.length = Number((torrentObject.info.length / 1024).toFixed(2)).toLocaleString() + " KB";
				}
			}
		}
		
		if (gBrowser.selectedBrowser.currentURI.spec == "about:blank" && !gBrowser.selectedBrowser.webProgress.isLoadingDocument) {
			gBrowser.selectedBrowser.loadURI(
				"data:application/json;charset=utf-8," +
				encodeURIComponent(JSON.stringify(torrentObject, null, 't'))
			);
		}
		else {
			gBrowser.selectedTab = gBrowser.addTab(
				"data:application/json;charset=utf-8," +
				encodeURIComponent(JSON.stringify(torrentObject, null, 't'))
			);
		}
		torrentObject = null;
	}
	else {
		alert("Parsing error");
	}
}

Создав массив байтов, мы передаём его декодировщику BEncode (о нём чуть позже) и получаем от него обычный объект (ассоциированный массив, хэш), копирующий структуру торрент-файла (а если не получаем, выдаём сообщение об ошибке разбора). Потом мы приводим некоторые данные в более читаемый вид (дату создания, размеры файлов и пути к ним), сортируем объекты файлов по свойству пути и вставляем в начало файлового списка общее количество файлов. После этого мы проверяем, не открыта ли у нас в текущей вкладке пустая страница и не загружается ли в неё что-нибудь. Если открыта и не загружается, мы будем выводить в текущую вкладку, если нет — открывать новую и выводить в неё.

Вывод мы будем осуществлять в JSON для простоты и универсальности. Вывод немного отформатируем. Но лучше всего установить какое-нибудь расширение, подсвечивающее JSON и позволяющее обращаться с ним как с древовидной структурой, сворачивая и разворачивая узлы (например, JSONView). После вывода на всякий случая обнуляем громадный объект (если это не паранойя).

4. Парсер BEncode

function bdecode(byteArray, byteIndex, isRawBytes) {
	if (byteIndex === undefined) {
		byteIndex = [0];
	}
	var item = String.fromCharCode(byteArray[byteIndex[0]++]);
	if(item == 'd') {
		var dic = {};
		item = String.fromCharCode(byteArray[byteIndex[0]++]);
		while(item != 'e') {
			byteIndex[0]--;
			var key = bdecode(byteArray, byteIndex);
			if (key == "pieces") {
				dic[key] = bdecode(byteArray, byteIndex, true);
			}
			else {
				dic[key] = bdecode(byteArray, byteIndex);
			}
			item = String.fromCharCode(byteArray[byteIndex[0]++]);
		}
		return dic;
	}
	if(item == 'l') {
		var list = [];
		item = String.fromCharCode(byteArray[byteIndex[0]++]);
		while(item != 'e') {
			byteIndex[0]--;
			list.push(bdecode(byteArray, byteIndex));
			item = String.fromCharCode(byteArray[byteIndex[0]++]);
		}
		return list;
	}
	if(item == 'i') {
		var num = '';
		item = String.fromCharCode(byteArray[byteIndex[0]++]);
		while(item != 'e') {
			num += item;
			item = String.fromCharCode(byteArray[byteIndex[0]++]);
		}
		return Number(num);
	}
	if(item.search(/d/) > -1) {
		var num = '';
		while(item.search(/d/) > -1) {
			num += item;
			item = String.fromCharCode(byteArray[byteIndex[0]++]);
		}
		num = Number(num);
		var line = '';
		if (isRawBytes) {
			byteIndex[0] += num;
			return "[" + num + "]";
		}
		else {
			while(num--) {
				line += escape(String.fromCharCode(byteArray[byteIndex[0]++]));
			}
			try {
				return decodeURIComponent(line);
			}
			catch(e) {
				return unescape(line) + " (?!)";
			}
		}
	}
	return null;
}

За основу я взял парсер на Perl, соблазнившись его краткостью и простотой. Сначала я пробовал превратить типизированный массив в обычный массив байтов, чтобы можно было работать с shift, но такая реализация работала очень медленно (наверное, из-за постоянной переделки большого массива). Потому пришлось ввести постоянно увеличивающийся индекс доступа, обернув его в массив (чтобы можно было передавать по ссылке в рекурсии).

Основное отличие от исходного образца заключается в блоке разбора строк. Во-первых, мы удаляем из вывода огромную строку с байтами, содержащую хэши сегментов (у неё чёткое местоположение, поэтому, добравшись до нужного ключа в разборе ассоциированного массива, мы временно выставляем флаг отключения кодировки в вызове разбора значения этого ключа). Во-вторых, мы производим некоторые манипуляции по превращению байтов в UTF-8 для остальных строк. Тут нас подстерегают опасности: иногда в строках оказывается отнюдь не UTF-8 (например, популярный трекер tracker.0day.kiev.ua почему-то вставляет в ключе «source» слово «Трекер» в кодировке Windows-1251) и decodeURIComponent вылетает с ошибкой. Поэтому для таких случаев мы возвращаем строке необработанный вид, помечая её немножко. Можно было бы вообще удалять такие строки, но иногда в них вызывает проблему только кусочек, основная же часть вполне читаема из-за совпадения ASCII и начала UTF-8.

IV. Перспективы

На основе парсинга и получения описанной информации можно реализовывать более сложные задачи. Например:

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

— если файл обновился, его можно автоматически сохранить из браузера в папку на диске, откуда его подхватит торрент-клиент (тут нас могут заинтересовать интерфейсы nsIFile, nsILocalFile, nsIFilePicker, nsIFileOutputStream, nsIBinaryOutputStream и пример кода).

— Поскольку последние реализации XHR поддерживают протокол file://, при помощи кнопки можно просматривать также локальные торрент-файлы и даже базы клиента (вроде settings.dat или resume.dat), правда, в последнем случае будет много бинарных строк с крокозябрами. Для этого нужно открыть папку с файлами в браузере и перетаскивать их на кнопку.


Обещанная ссылка на установку кнопки (если кто-то не захочет копировать код по частям во вкладку инициализации): поскольку хабрапарсер переделывает ссылку по протоколу http, нужно перейти на эту страницу и нажать на ссылку «Просмотр торрент-файлов» (конечно, после установки Custom Buttons).

Прошу прощения, если огорчил кого-то неумелостью поделок или некорректными формулировками. Надеюсь, хоть что-нибудь из этого опыта кому-нибудь пригодится.

Автор: vmb

* - обязательные к заполнению поля