Greasy Fork is available in English.

ニコ生アラート(簡)

Alerts you to live streams that match your search. Supports these sites: FC2 Live, CaveTube, koebu LIVE!, SHOWROOM, Stickam JAPAN!, TwitCasting, Twitch, Niconico Live, Himawari Stream, Livetube

Tính đến 03-10-2015. Xem phiên bản mới nhất.

// ==UserScript==
// @name        ニコ生アラート(簡)
// @name:ja     ニコ生アラート (簡)
// @name:en     Nico Live Alert (Kan)
// @namespace   http://userscripts.org/users/347021
// @id          niconico-alert-keyword-347021
// @version     5.0.0
// @description Alerts you to live streams that match your search. Supports these sites:  FC2 Live, CaveTube, koebu LIVE!, SHOWROOM, Stickam JAPAN!, TwitCasting, Twitch, Niconico Live, Himawari Stream, Livetube
// @description:ja キーワードにヒットしたライブ配信を通知します。次のサイトに対応: FC2ライブ、CaveTube、 こえ部LIVE!、SHOWROOM、Stickam JAPAN!、ツイキャス、Twitch、ニコニコ生放送、ひまわりストリーム、Livetube
// @match       http://*.nicovideo.jp/*
// @match       *://live.fc2.com/*
// @match       http://gae.cavelis.net/*
// @match       http://koebu.com/*
// @match       https://www.showroom-live.com/*
// @match       http://www.stickam.jp/*
// @match       http://twitcasting.tv/*
// @match       http://www.twitch.tv/*
// @match       http://himast.in/*
// @match       *://www.ustream.tv/*
// @match       *://www.youtube.com/*
// @match       https://www.younow.com/*
// @match       *://livestream.com/*
// @match       *://livetube.cc/*
// @run-at      document-start
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_deleteValue
// @grant       GM_xmlhttpRequest
// @grant       GM_registerMenuCommand
// @grant       GM_openInTab
// @grant       GM_info
// @icon        data:image/vnd.microsoft.icon;base64,AAABAAEAMDAAAAEAIAAuDQAAFgAAAIlQTkcNChoKAAAADUlIRFIAAAAwAAAAMAgGAAAAVwL5hwAADPVJREFUaIHNWXl4lNW5/52Zb/aZZCaZLaskk4RAWAIKCAoahKBUYkGsqLXQ9kLpo9el7a1irxYLaa1elVpUtNYqYkvdA2KLgoqsBsEsJCwhC8Ssk22yzExmvu+8/SMzw2QyoSHRen/P8+X55nxn+b3bed9zAnzDcNgtAIB0W7yQYNBunj97ljgpLfWUUWCzACDNGs++aQ5fC7TAQ7+857+pr7eP9n28lyZellwHQP1t8xox5MD2I4cOEhFJREQ3XDvPDWDyt81rxNABK1fd+j2qKC+nl7Y8R+k2czFwwcX+XyEaKSKCgWkOpVuTKcGg26EGrIG+Y4oB2VgGDwdXFxtCKkZmX7NqtTD7R2ubQaRo9AKtAFDd7KRvgsMlIZrGbTrrHXrYiuwGy3cGWuxbjn6uoMoTAiXEWN8eOod5VGuP2QIOuwUXVJitBnJ1QPKE7Ini86+/01WQPVF6A5ilzEwUM5OSJXx5TEBTt/zoQP+UiUCKGQCG2uw/jBiZNefqy+OcNy820oRUc8PGh3VEBL5onomA5JUF+San2w16+AE9AcnTMxLMj9yyxEjzrojrU8GWBQwo41sMavuan91lIF8/qK5GTh3tjEQR/NWX1FLBQpP30V/piQhUsNAkAYkbctLMnUSgRx7UE5CwKjiLw3ZpAsi/LvopcZrm6ir5xA92qMytrTLFdfk+mcSBuHiS3b7KIyxa7AMAysySkDlOvGZCjqSel+fD+++pcOyooJEQI0uM0Zw93+H0O+wWdPa6vy5qF0e6zRyxFSbMuHxCfKskgrb+WUMWrfX88huNnWUlArm6GN/xjorqamVEBE4E3tHOaO+HSnrwfgONs1jeBACH3TxixY45iGta2lDd7CSH3RwQgk6rVOTZ/YEKH/1TCadb8f2yL4UTgkD47BMlW3lrbFnerPi6IwcVrL2N4Y9P6dDulGFcmgS5nBIAgPANR7TDbhnkq5FbYGKsZaZJYd0ep7TeCQDzrzR1EYHWP6QnIDFPCdsrJccF2lWkIi1sexnsvzSrrS847OZQMI8Ulyyqw24BCKhucUZ+sgCYpgAmKAGtUgEDwFT9fiEpJVl+y73/45W98qIcxRXSIiUQvyAv9q/9XrnnwGHFnH60lAQnSYmzsPqOkSe3SxIg3WZGt8sla/P6OQDIgSsJWJqRZC9ITErKjjdbkDpuHKw2G4wmE5RKJQiAKHKcq22jlqazrKG+Bm1OZ+uxU9UnAb7twfvytz226UMvAKRZ42W1re3cYbNEU9Do4LBbkB5hUi2wVAt8mD93Dj326Hr6ZM9H1NXZSf8GnIg4cU4nysro5Re20J233EzJJkOtAlgHQB+uqNFm5qgChMGmAt65dtYMemv738jn84lE5A+QI7/Px/0+Hxf9fhJFkYc/fr+ffD4flyQpKIxERL7y0hJas/JOSjbFNABYMViIMSY1h92CzEQ7AEAH3JRmje95bMOjgzVKxAO4oOoo75zzQe/h44mIjhw6SIvmXUVa4I2ZOeM1QSFGQTqq5Gumj8+kzz75OHQgCTAOFyLUFvE+qC0obPj3oFVEURTv++lailPKjwLQRFPov3Utu1Efyg2zp0/Rxcjw89lTJ1NdbU1QayRJEnEucUmSwkkOESD4O9Av0lRDBAziicINlGyK2acEMgBkTcvOiAtySjHFDKd5C7IC7mLSKbNNWsX2BIP2fG6Wg6qrzoQvODgywxou4i7k8/mIE4/sx8P7BS2xf9+nZNEoacbE8bRkQR5dFm88owN+FuIarV4Kuo3AkHWZxeR6+qf30JyMDHp96ytSGE9ORHznu29TbpaDv1/0Lg+YPlKrg3zc6Wzl+XPn0IqlBSGfDzPIoLZ/frCLMsxxdOsNi3hTY4Pk9XqkkuPH6MYFeaQCNkbyHYJ4vWr/qw88Qvuf2sLz584JELmwkCiKNP/KmSQHKH/uVYMCMdxFwsm99PxzXAmQRavi/3h/ZzQBiIio2+WiudNz+W/uWMWLDx4cJFhnRwfNyMkmo8DmhAswqBYyqOVXjE8ZN+0Hi27CG3v/gbwbFhMAEAAWOHF4PB40NTbAHqtHZ2cHXvrTC3hu8zMoLy8DYwyMMRBRqD8ANNSfh0EtQBAENNTXA0CoD2MsdCA6XnIccUyBxIQkZE8OXVgQAGY0mahg2c3oEWk+AFQ3O4cK0O+TLOPsybpeTx9On69l1y1cCABgYQlbq9VSmiMDflGk9pZWfPTyayjduRsrb7oJG3798ED/iONVzpSp6PGK4Jxj0pSpQ6zOBkiio72DjHoDDFod3O6h5bTNZgcHjEM+BBGrFqbPnZTTW/f6DlqQm8vr6s8R9XrJfbiMPF+e4tzjJSKiXUXvkU2vpteeeIqo083pcCWd21ZE01NTadeunUREJIpiKFg9bjfd85PV9Ot1D4QChYjI39LO3cUnyHuyhoiIKioraE5mJh16+k/83Tf+PmivICL6yco7SQWsDec8qO7uF3kTuH8BGEtr73Uhf0kBizndQF5nO6S2LoA4BLuZOdvbUfPFcSpcXwiZ3QTXybOwGUxMo1JR4bNPY/bVV7OEhEQKupNCocANNy5h1163gAIaZ9ztJc+BLyF19UBqbodCo2bWLAc+ObCP6iorYdUYEJuWgthYIwOAD3YUsU2P/75RRvx+t8j70gOHnpALBYPC3d//w217djqPV5Qzd2+PJBPkAA94qWxAXperC3K5AF5VD/feYsi8foABxphYiN29eGzdQ2hrawsphoiiv3MecDeC3+cDAGza/DzOSn3Y+tetuPu22/HaKy9j48P/y+5e/WNXY4fre21ef4vDbmE1zVGKvWDqnpyZnmBUsP1P/v53RETUd7SCvBXVnPf7OBFRc1MTzZw8kfYUbiL6tITTx8e49FEx3Tb3Gro6M5NWXH89OZ3OQTkhMn8QEfc3Obn7cCl5T5wl7vcPpHgi7na7+YtbnqM7li+jWDk4gN8mxeodAY7DV9DptkG3ApOmZqaLrp5uIqLQ5MFt7603/84npSbzR2/7Af/Lfet4wYyZfMG8Ofzu//ox/6K4ODwnDMkNkWVIKCwGyoqgpOIz//c4xQoI3SE5RlLgOewWpMYbZQCgAZ5c8d2CIcVXkMChgwf4PXet5au+fxt/9plNwYQWXuCFCzDkW5Q5Q+vsKnqPslOSvABSg7xGVZ2a1YpXF141mw7s+zR8R4iqQU6c+3y+qNoO1kdhm1BUS7j7+uiJwg2UbjN364G8S2cctMTgemOF3aA5ufT6fPrzluepvLSUul0uKeBZ0hBXCNv6hnkPf6R+r1cqLy2hJwo30sycCaQH3gaQEO4Vw2HYgAgOMlus+Ly8AslGg7qtq2dxP3B9dmrSonHp6alJSSmw2m1ITE6GzWZHTKwROr2ONBoNFEolEwQBcrkcjMkABpJEEV6PBy5XN2tpakRzUyPO1dWiuqoKpyoqG043NP0tRoa3ujk+jyReHW3XuZgAkYJETKDAwCF+OoDUGBmuADBNqVQ4NGq1AYyBE5FsoFYAAuUAEYFzDoVCUevz+Q67uvucbuAEgP0AqgGIg9YlQnVLGy6GER/q0+1m1DQPnUwLxNqs8e9OnpqbJwjyYOFEIGDgT2jfZ2AMnHM6WXHi9JmG5uUAKiLn+1oP9BdDMEb0wPp7164Zia+Hnu2vbaVko6ESABx2i+JS70PDIYx2YFBLjKG24auvUFZSgl53H2OMEaJblgEgg15P5+rqGBhrCbTzsWh8VHd4kZdbMQr2YmK8ZfW0rInkF0VQgGzEQkwhCHSupZGVVp05kWC1LDrb7Gx02C3DBuhIMCoLRC6o1Gp8P1qyHL+49Ydw9fUOO86g1WFf6VHc9YdCnKpvbAzONRa/H7UFqpudSDBo17h63Mu0asVVOWkZ+vgYIyQuDTtOLpPB6/NReU0Va3P1fKZVCW+3ef3PMMYwWkuM5Rp48tzLc8s2Pv4kTHFx6Bf9JEkSu+iMNHDY0Wt11NPtYr/7zXrs3r1nuRcY8j+zkWLUQQwgw2qzYd78+cHf4X4fGQNBscK/92dkZql27N6TNQYOY7KAanxywpGcKVNyBZVK6vf1y4PqZ4yRTq1mgZyAPq+HiCiQ0QgalZrEfh8rLy2pdbvd19Z3uM7/RwVIt1tQ0+xEkk5jdbk9hzffuy49IzmVunp7mCCXw6iPobv/UMi/qKs7lJNgn7H53l+pJc7h9fXDqDfg2JlKPPLyH2tdItKBgTKeseHLhYthVC7EAtm1oc/TGqsRioqrK++/Y/F3mRBvIfT10LNvbpV1+zynAFzT4ek9UtXSMHP10tsJOh3Edie9uneXTJLLtkHkcNjMMoB4dZQsPyIuoxqFCzuRw2ZR93j6Xp/iyFqWYk2As6sD5TVnKly9vcu7PP5Ttlhdtlqp2jE5PTPTpI9BvbMZVV+dK2ro6F4GgAetOVr8C9DD3qnMZcwMAAAAAElFTkSuQmCC
// @compatible  Firefox
// @compatible  Opera
// @compatible  Chrome
// @author      100の人
// @homepage    https://greasyfork.org/scripts/272
// @contributor HADAA
// @license     Mozilla Public License Version 2.0 (MPL 2.0); https://www.mozilla.org/MPL/2.0/
// ==/UserScript==
	
// For UserScriptLoader.uc.js
if (typeof GM_info === 'undefined') {
	window.GM_info = {
		script: {
			version: '5.0.0',
		},
	};
}

(function () {
'use strict';

defineGettext();

// L10N
Gettext.setLocalizedTexts({
	'en': {
		//'(取得不可)': '(No data)',
		'検索ワードにヒットしたライブ配信番組': 'Live streams that match your search words',
		'どのライブ配信サービスか': 'Service from',
		'アイコン': 'Icon',
		'プライベート配信か否か': 'Private program or not',
		'限定公開': 'Limited',
		'経過': 'Elapsed',
		'%d 分': '%dm',
		'%d 時間 %u 分': '%dh%um',
		'配信開始からの経過時間': 'Time elapsed since start of live stream',
		'タイトル': 'Title',
		'番組のタイトル': 'Program title',
		'タグ': 'Tags',
		'カテゴリ・タグ': 'Category and tags',
		'配信者': 'Broadcaster',
		'配信者の名前': 'Broadcaster name',
		'説明文': 'Description',
		'来場': 'Visitors',
		'来場者数': 'Number of visitors',
		'%d 人': '%d',
		'コメ数': 'Comments',
		'%d コメ': '%d',
		'総コメント数': 'Total number of comments',
		'コミュニティ': 'Community',
		'コミュニティ・チャンネル': 'Community or channel',
		'%s 更新': 'Last updated %s', // %sは年月日
		//'メンテナンス中': 'Under maintenance',
		//'サーバーダウン': 'Server is down',
		//'オフライン': 'Offline',
		'検索語句': 'Search words',
		'除外するコミュニティ・チャンネルなどの URL': 'Community or channel URLs to be excluded',
		'保存': 'Save',
		'除外 URL リストの取得先を設定': 'Sets the location of the URL exclusion list',
		'特定の URL から、除外 URL のリストを読み込み、検索時に付加します。': 'Loads exclusion list from designated URL and adds to search.',
		'JSON 形式の URL 文字列の配列のみ有効です。': 'Array of URLs needs to be in JSON format.',
		'また、除外 URL リストの読み込みは、アラートページ読み込み時に1回だけ行われます。': 'Also, this script loads exclusion list only once when the alert page is opened.',
		//'GM_xmlhttpRequest エラー': 'GM_xmlhttpRequest error',
		'指定された URL から、除外 URL リストを読み込めませんでした。\n取得せずに続行します。\n\nエラーメッセージ:\n%s': 'Failure to fetch URL exclusion list from designated URL. Continue without fetching.\n\nError message:\n%s',
		'追加設定ボックスの開閉': 'Toggle extra settings',
		'検索対象のライブ配信サービス': 'Live streaming services for search',
		'サービス名': 'Service name',
		'最後に検索結果の取得に成功にした日時': 'Last successful search result timestamp',
		'直近のエラー': 'Last error',
		'FC2ライブ': 'FC2 Live',
		'CaveTube': 'CaveTube',
		'こえ部LIVE!': 'koebu LIVE!',
		'SHOWROOM': 'SHOWROOM',
		'Stickam JAPAN!': 'Stickam JAPAN!',
		'ツイキャス': 'TwitCasting',
		'Twitch': 'Twitch',
		'ニコニコ生放送': 'Niconico Live',
		'ひまわりストリーム': 'Himawari Stream',
		'Ustream': 'Ustream',
		'Youtube ライブ': 'Youtube Live',
		'YouNow': 'YouNow',
		'Livestream': 'Livestream',
		'Livetube': 'Livetube',
		'表示する項目の設定': 'Set which items to display',
		'その他の設定': 'Other Settings',
		'プライベート配信を通知しない': 'Do not notify about private programs',
		'タイトル・キャプション・コミュニティ名が %d 文字を超えたら省略する': 'Truncate to %d characters if title description or community name is longer',
		'言語で絞り込む (言語が取得可能なサービスのみ)': 'Filter by language (only for services that have this function)',
		'アラート音': 'Alert sound',
		'ファイルサイズが大きいため、設定に失敗しました。\n\nエラーメッセージ:\n%s': 'Failure to set because file is too large.\n\nError message:\n%s',
		'使用中のブラウザが対応していないファイル形式です。': 'Your browser cannot play this type of file.',
		'項目名クリックで番組を昇順・降順に並べ替えることができます。': 'If you click on item name, you can sort programs.',
		'項目名をドラッグ&ドロップで列の位置を変更できます。': 'Drag and drop item name to change column position.',
		'ユーザー名やコミュニティ名をテキストエリアにドラッグ&ドロップで除外 URL に指定できます。': 'Drag and drop user or community name to textarea to set URL exclusion filter.',
		'RSSの取得に失敗しました。ページを更新してみてください。\n\nエラーメッセージ:\n%s\n%d 行目': 'Failure to read RSS file. Please refresh this page.\n\nError message:\n%s\non line %d',
		'更新しますか?': 'Do you want to refresh?',
		'指定された URL から NG リストを読み込めませんでした。\n取得せずに続行します。\n\nエラーメッセージ:\n%s': 'Failure to get communities and channels from the specified URL.\nScript will continue without getting them.\n\nError message:\n%s',
		'設定のインポートとエクスポート': 'Import and export settings',
		'JSONファイルからインポートする': 'Import from JSON file',
		'JSON形式でファイルにエクスポート': 'Export to file in JSON format',
		'インポートに失敗しました。\n\nエラーメッセージ:\n%s': 'Import failed.\n\nError message:\n%s',
		'インポートが完了しました。ページを再読み込みします。': 'Import completed. Refreshing this page.',
		'ローカルストレージの容量制限を超えたので、プロパティ %p を無視しました。': 'Because the capacity of local storage was exceeded, %p property is ignored.', // %pはカンマ区切りのプロパティ名
		'値が壊れていたので、プロパティ %p を無視しました。': 'Because the value corrupted, %p property is ignored.', // %pはプロパティ名
		'使用中のブラウザが対応していないファイル形式のため、プロパティ %p を無視しました。': 'Because your browser doesn\'t support this type of file, %p property is ignored.', // %pはプロパティ名
		'アラート音を選択': 'Sets alert sound',
		'設定済みのアラート音を削除': 'Deletes alert sound set in advance',
		'設定済みのアラート音を削除': 'Deletes alert sound set in advance',
		' ❰❰%s❱❱ ': ' <<%s>> ', // ツールチップ内における一致箇所のマーク
		
		'ニコ生アラート (簡)': 'Nico Live alert (Kan)',
	},
});

// クライアントの言語を設定する
Gettext.setLocale(window.navigator.language);

/**
 * メインの処理を行うユーティリティークラス。
 */
var Alert = {
	/**
	 * ページタイトル、ブラウジングコンテキスト名、ユーザースクリプトのコマンド名に用いる文字列。
	 * @constant {string}
	 */
	NAME: _('ニコ生アラート (簡)'),
	
	/**
	 * {@link GM_setLargeString} や URL に用いる文字列。
	 * @constant {string}
	 */
	ID: 'alert-keyword-347021',
}

/**
 * アラートページのURL。
 * @type {string}
 */
var alertPageURL = 'http://live.nicovideo.jp/alert/?' + Alert.ID;

if (window.location.href !== alertPageURL) {
	GM_registerMenuCommand(Alert.NAME, function () {
		GM_openInTab(alertPageURL);
	});
} else {
	polyfill();
	startScript(
		main,
		parent => parent.localName === 'body',
		target => target.id === 'utility_link',
		() => document.getElementById('utility_link')
	);
}

function main () {
	defineClasses();
	
	// ページタイトル
	document.title = Alert.NAME;

	// Favicon
	var icon = document.querySelector('[rel="icon"]');
	icon.href = Alert.ICON;
	document.head.appendChild(icon);
	
	// 元のページ内容を削除
	document.getElementById('all_cover').remove();

	// ヘッダを修正
	for (var select of document.querySelectorAll('[href^="javascript:"]')) {
		select.target = '_self';
	}
	
	// リンク先を新しいタブで開く、スタイルの設定
	document.head.insertAdjacentHTML('beforeend', h`
		<base target="_blank" />
		
		<style>
			[aria-hidden="true"] {
				display: none;
			}
			
			main {
				margin: 1em;
				/* フッターとブラウザ表示領域下端の隙間埋め */
				min-height: calc(100vh - (2em /* mainの上下マージン */ + ${document.body.clientHeight}px));
			}
			main a:link {
				color: mediumblue;
			}
			main a:visited {
				color: midnightblue;
			}
			
			/*====================================
				表
			 */
			#programs {
				width: 100%;
			}
			#programs caption {
				display: none;
			}
			main tr {
				background: silver;
				border-width: 1px;
				border-style: solid none;
			}
			main thead th {
				white-space: nowrap;
			}
			
			#programs tbody {
				text-align: left;
				border-top: solid;
				border-bottom: solid;
			}
			
			/*------------------------------------
				ボタンの左右のマージン
			 */
			main button {
				margin-left: 0.2em;
				margin-right: 0.2em;
			}
	
			/*------------------------------------
				セル内容の右寄せ・改行禁止
			 */
			#programs [role="timer"],
			#programs [aria-live="off"] {
				text-align: right;
				white-space: nowrap;
			}
	
			/*------------------------------------
				行の背景色
			 */
			#programs tbody tr:nth-of-type(2n-1),
			#user-setting-options tbody tr:nth-of-type(2n-1),
			#programs tbody:not(.odd) + tfoot tr {
				background: white;
			}
			main tr td,
			main tr th {
				padding: 3px;
			}
	
			/*------------------------------------
				リンクでない文字列
			 */
			a:not(:link) {
				color: unset;
				text-decoration: unset;
			}
	
			/*------------------------------------
				番組のアイコン
			 */
			[itemtype="http://schema.org/VideoObject"] [itemprop="image"] {
				width: 64px;
			}
	
			/*------------------------------------
				取得失敗 / 空文字列 / 空白文字
			 */
			#programs .illegal {
				font-style: oblique;
			}
	
			/*------------------------------------
				項目の移動
			 */
			.inserting-before {
				border-left: solid lightskyblue thick;
			}
			.inserting-after {
				border-right: solid lightskyblue thick;
			}
			#main-settings {
				-webkit-column-count: 2;    /* Opera and Google Chrome */
				-moz-column-count: 2;       /* Firefox */
				column-count: 2;
			}
			#main-settings textarea {
				width: 100%;
				height: 20em;
			}
	
			/*------------------------------------
				検索語句の強調表示
			 */
			main mark {
				color: inherit;
				background: khaki;
			}
	
			/*------------------------------------
				省略記号の表示
			 */
			.ellipsis-left::before,
			.ellipsis-right::after {
				content: "…";
				color: dimgray;
			}
			.ellipsis-left::before {
				margin-right: 0.2em;
			}
			.ellipsis-right::after {
				margin-left: 0.2em;
			}
			.ellipsis-left::before {
				margin-right: 0.2em;
			}
			.ellipsis-right::after {
				margin-left: 0.2em;
			}
	
			/*------------------------------------
				Windows 版の Opera、Google Chrome における全文の表示
			 */
			main td {
				position: relative;
			}
			[data-title]:hover::after {
				content: attr(data-title);
				position: absolute;
				top: calc(100% + 0.2em);
				left: 1em;
				border: 1px solid dimgray;
				background: khaki;
				padding: 0.5em;
				opacity: 0.9;
				border-radius: 0.7em;
				z-index: 1;
			}
			
			/*------------------------------------
				ステータス
			 */
			#programs tfoot tr:first-of-type {
				border-bottom: none;
			}
			#programs tfoot tr:last-of-type {
				border-top: none;
			}
			
			/*------------------------------------
				ライブ配信サービス一覧
			 */
			#user-setting-options tr {
				background: whitesmoke;
			}
			#user-setting-options thead th {
				padding-right: 1em;
			}
	
			/*====================================
				追加設定ボックス
			 */
			#user-setting-options {
				display: flex;
				width: 100%;
				flex-direction: column;
				align-items: center;
			}
			#user-setting-options dt {
				margin-top: 2em;
				font-weight: bold;
			}
			#user-setting-options dd > * {
				text-align: left;
			}
			#user-setting-options > :last-of-type li {
				list-style: disc;
			}
	
			/*------------------------------------
				▲▼
			 */
			[aria-controls="user-setting-options"]::before {
				content: "▼";
				color: darkslategray;
				margin-right: 0.5em;
			}
			[aria-controls="user-setting-options"][aria-expanded="true"]::before {
				content: "▲";
			}
			#user-setting-options[aria-hidden="true"] {
				display: none;
			}
			
			/*====================================
				ライブ配信サービスのアイコン
			 */
			[itemtype="http://schema.org/Organization"] [itemprop="logo"],
			[itemtype="http://schema.org/BroadcastService"] [itemprop="image"] {
				width: 16px;
			}
		</style>
	`);

	// 挿入
	document.getElementById('utility_link').insertAdjacentHTML('beforebegin', h`<main id="${Alert.ID}">
		<table id="programs" sortable="">
			<caption>${_('検索ワードにヒットしたライブ配信番組')}</caption>
			<thead>
				<tr dropzone="move">
					<th id="service" title="${_('どのライブ配信サービスか')}" draggable="true">
					</th>
					<th id="thumbnail" title="${_('アイコン')}" draggable="true">
					</th>
					<th id="member_only" title="${_('プライベート配信か否か')}" draggable="true">
					</th>
					<th id="pubDate" title="${_('配信開始からの経過時間')}" sorted="" draggable="true">
						${_('経過')}
					</th>
					<th id="title" title="${_('番組のタイトル')}" draggable="true">
						${_('タイトル')}
					</th>
					<th id="category" title="${_('カテゴリ・タグ')}" draggable="true">
						${_('タグ')}
					</th>
					<th id="owner_name" title="${_('配信者の名前')}" draggable="true">
						${_('配信者')}
					</th>
					<th id="description" title="${_('説明文')}" draggable="true">
						${_('説明文')}
					</th>
					<th id="view" title="${_('来場者数')}" draggable="true">
						${_('来場')}
					</th>
					<th id="num_res" title="${_('総コメント数')}" draggable="true">
						${_('コメ数')}
					</th>
					<th id="community_name" title="${_('コミュニティ・チャンネル')}" draggable="true">
						${_('コミュニティ')}
					</th>
				</tr>
			</thead>
			<tbody aria-live="polite" aria-relevant="additions">
				<template>
					<tr itemscope="" itemtype="http://schema.org/VideoObject">
						<td itemprop="provider" itemscope="" itemtype="http://schema.org/Organization">
							<data itemprop="name" value="">
								<a itemprop="url">
									<img itemprop="logo" src="dummy" />
								</a>
							</data>
						</td>
						<td>
							<a itemprop="url"><img itemprop="image" src="dummy" hidden="" /></a>
						</td>
						<td>
							<data itemprop="requiresSubscription" value="false"></data>
						</td>
						<td role="timer">
							<time itemprop="duration" datetime="P365D" hidden=""></time>
						</td>
						<td>
							<data itemprop="name" value="">
								<a itemprop="url"></a>
							</data>
						</td>
						<td>
							<data itemprop="keywords" value="">
							</data>
						</td>
						<td itemprop="author" itemscope="" itemtype="http://schema.org/Person">
							<data itemprop="name" value="">
								<a itemprop="workLocation"></a>
							</data>
						</td>
						<td>
							<data itemprop="description" value="">
							</data>
						</td>
						<td aria-live="off" itemprop="interactionStatistic" itemscope="" itemtype="http://schema.org/InteractionCounter">
							<data itemprop="userInteractionCount" value="-1">
							</data>
						</td>
						<td aria-live="off">
							<data itemprop="commentCount" value="-1"></data>
						</td>
						<td itemprop="productionCompany" itemscope="" itemtype="http://schema.org/PerformingGroup">
							<data itemprop="name" value="">
								<a itemprop="url"></a>
							</data>
						</td>
					</tr>
				</template>
			</tbody>
			<tfoot>
				<tr>
					<td colspan="11" role="status"></td>
				</tr>
				<tr>
					<td colspan="11" role="status"></td>
				</tr>
			</tfoot>
		</table>
	</main>`);
	
	/**
	 * 番組を表示する表。
	 * @type {HTMLTableElement}
	 */
	var table = document.getElementById('programs');
	
	if (!('sortable' in table)) {
		document.head.insertAdjacentHTML('beforeend', `<style>
			/*====================================
				列ごとの並べ替え
			 */
			#programs thead th:hover,
			#programs thead th:focus {
				cursor: pointer;
				background: gainsboro;
			}
			
			/*------------------------------------
				▲▼
			 */
			[sorted]::before {
				content: "▲";
				color: darkslategray;
				margin-right: 0.5em;
			}
			[sorted*="reversed"]::before {
				content: "▼";
			}
		</style>`);
		
		for (var th of Array.from(table.getElementsByTagName('th'))) {
			th.setAttribute('role', 'button');
			th.tabIndex = 0;
		}
		
		table.tHead.addEventListener('keydown', function (event) {
			if (event.key === ' ') {
				event.preventDefault();
			}
		});
		
		table.tHead.addEventListener('keyup', function (event) {
			if (event.key === 'Enter' || event.key === ' ') {
				TableProcessor.sort(event.target);
				event.target.blur();
			}
		});
	}
	
	/**
	 * アプリケーション全体を内包する main 要素。
	 * @type {HTMLElement}
	 */
	var main = document.getElementById(Alert.ID);
	
	main.insertAdjacentHTML('beforeend', h`
		<dl id="main-settings">
			<dt>
				${_('検索語句')}
				<button name="save-searching-words" aria-controls="searching-words">${_('保存')}</button>
			</dt>
			<dd>
				<textarea name="searching-words" id="searching-words"></textarea>
			</dd>
			<dt>
				${_('除外するコミュニティ・チャンネルなどの URL')}
				<button name="save-ng-communities" aria-controls="ng-communities">${_('保存')}</button>
				<button name="sets-blacklist-uri">${_('除外 URL リストの取得先を設定')}</button></dt>
			<dd>
				<textarea name="ng-communities" id="ng-communities"></textarea>
			</dd>
		</dl>
		
		<audio id="alert-tone" controls="" preload="auto" hidden=""></audio>
		
		<button type="button" aria-expanded="false" aria-controls="user-setting-options">
			${_('追加設定ボックスの開閉')}
		</button>
		<dl id="user-setting-options" aria-hidden="true">
			<dt>${_('検索対象のライブ配信サービス')}</dt>
			<dd>
				<table>
					<thead>
						<tr>
							<th>${_('サービス名')}</th>
							<th>${_('最後に検索結果の取得に成功にした日時')}</th>
							<th>${_('直近のエラー')}</th>
						</tr>
					</thead>
					<tbody>` + Alert.services.map(function (service) {
						return h`<tr itemscope="" itemtype="http://schema.org/BroadcastService">
							<td>
								<label itemprop="name">
									<input name="target-services" value="${service.id}" type="checkbox">
									<img itemprop="image" src="${service.icon}" alt="" />
									${service.name}
								</label>
							</td>
							<td role="status">
							</td>
							<td role="status">
							</td>
						</tr>`;
					}).join('') + h`</tbody>
				</table>
			</dd>
			
			<dt>${_('表示する項目の設定')}</dt>
			<dd>
				<ul id="visible-columns">` + Array.from(main.getElementsByTagName('th')).map(function (th) {
					return h`<li>
						<label>
							<input name="visible-columns" value="${th.id}" aria-controls="${th.id}" type="checkbox" checked="">
							${_(th.title)}
						</label>
					</li>`;
				}).join('') + h`</ul>
			</dd>
			
			<dt>${_('その他の設定')}</dt>
			<dd>
				<ul>
					<li>
						<label>
							<input name="exclusionMemberOnly" type="checkbox">
							${_('プライベート配信を通知しない')}
						</label>
					</li>
					<li>
						<label>
							<input name="ellipsisTooLongRSSData" type="checkbox" checked="">
							${_('タイトル・キャプション・コミュニティ名が %d 文字を超えたら省略する').replace('%d', UserSettings.MAX_VISIBLE_CHARACTERS)}
						</label>
					</li>
					<li>
						<label>
							<input name="languageFilter" type="checkbox" checked="">
							${_('言語で絞り込む (言語が取得可能なサービスのみ)')}
						</label>
					</li>
				</ul>
			</dd>
			
			<dt>${_('アラート音')}</dt>
			<dd>
				<button name="select-sound" type="button" aria-controls="alert-tone">${_('アラート音を選択')}</button>
				<button name="delete-sound" type="button" aria-controls="alert-tone" hidden="">${_('設定済みのアラート音を削除')}</button>
				<input hidden="" type="file" accept="audio/*" />
			</dd>
			
			<dt>${_('設定のインポートとエクスポート')}</dt>
			<dd>
				<button name="import" type="button">${_('JSONファイルからインポートする')}</button>
				<button name="export" type="button">${_('JSON形式でファイルにエクスポート')}</button>
			</dd>
			
			<dt></dt>
			<dd>
				<ul>
					<li>${_('項目名クリックで番組を昇順・降順に並べ替えることができます。')}</li>
					<li>${_('項目名をドラッグ&ドロップで列の位置を変更できます。')}</li>
					<li>${_('ユーザー名やコミュニティ名をテキストエリアにドラッグ&ドロップで除外 URL に指定できます。')}</li>
				</ul>
			</dd>
		</dl>
	`);
	
	/**
	 * 列IDのリスト。
	 * @type {string[]}
	 */
	var columnNames = Array.from(table.querySelectorAll('thead th')).map(th => th.id);
	
	/**
	 * 配信サービスのIDのリスト。
	 * @type {string[]}
	 */
	var serviceIds = Alert.services.map(service => service.id);
	
	/**
	 * ユーザー設定をインポートする際に利用するJSONスキーマ。
	 * @type {Object}
	 */
	UserSettings.schema = {
		type: 'object',
		properties: {
			version: {
				type: 'string',
			},
			words: {
				type: 'array',
				items: {
					type: 'string',
				},
			},
			NGs: {
				type: 'array',
				items: {
					type: 'string',
				},
			},
			NGsURI: {
				type: 'string',
			},
			order: {
				type: 'object',
				required: ['name', 'order'],
				properties: {
					name: {
						type: 'string',
						enum: columnNames,
					},
					order: {
						type: 'string',
						enum: ['asc', 'desc'],
					},
				},
				default: function () {
					var sortedTH = document.querySelector('#programs [sorted]');
					return {
						name: sortedTH.id,
						order: TableProcessor.getSorting(sortedTH),
					};
				}(),
			},
			'columns-position': {
				type: 'array',
				uniqueItems: true,
				items: {
					type: 'string',
					enum: columnNames,
				},
				default: columnNames,
			},
			'visible-columns': {
				type: 'array',
				uniqueItems: true,
				items: {
					type: 'string',
					enum: columnNames,
				},
				default: ['service', 'member_only', 'pubDate', 'title', 'category', 'owner_name', 'description', 'view', 'num_res', 'community_name'],
			},
			'target-services': {
				type: 'array',
				uniqueItems: true,
				items: {
					type: 'string',
					enum: serviceIds,
				},
				default: serviceIds.filter(serviceId => ['ustream', 'youtube-live', 'younow', 'livestream'].indexOf(serviceId) === -1),
			},
			exclusionMemberOnly: {
				type: 'boolean',
				default: document.getElementsByName('exclusionMemberOnly')[0].checked,
			},
			ellipsisTooLongRSSData: {
				type: 'boolean',
				default: document.getElementsByName('ellipsisTooLongRSSData')[0].checked,
			},
			languageFilter: {
				type: 'boolean',
				default: document.getElementsByName('languageFilter')[0].checked,
			},
			audioMuted: {
				type: 'boolean',
				default: false,
			},
			audioVolume: {
				type: 'number',
				minimum: 0,
				maximum: 1,
				default: 1.0,
			},
			audioData: {
				type: 'string',
				pattern: '^data:(audio/[-_.0-9A-Za-z]+|video/ogg);base64,',
			},
		},
	};
	
	/**
	 * 設定保存時のバージョン番号。
	 * @type {?string}
	 */
	var version = GM_getValue('version');
	
	// 検索語句
	var words = GM_getValue('words');
	if (words) {
		words = JSON.parse(words);
		UserSettings.words = UserSettings.parseWords(words);
		document.getElementById('searching-words').value = words.join('\n') + '\n';
	}
	
	// 除外リスト
	var NGs = GM_getValue('NGs');
	if (NGs) {
		NGs = JSON.parse(NGs);
		UserSettings.exclusions = UserSettings.parseExclusions(NGs);
		if (!version) {
			// 5.0.0 より前のバージョンの設定であれば
			GM_setValue('NGs', JSON.stringify(UserSettings.exclusions));
		}
		document.getElementById('ng-communities').value = UserSettings.exclusions.join('\n') + '\n';
	}
	
	// 検索対象のサービス
	var targetServicesJSON = GM_getValue('target-services');
	if (targetServicesJSON) {
		UserSettings.enableServices(version, JSON.parse(targetServicesJSON));
	} else {
		UserSettings.enableServices(GM_info.script.version, UserSettings.schema.properties['target-services'].default);
	}
	
	// プライベート配信・長い文字列の省略・言語
	for (var key of ['exclusionMemberOnly', 'ellipsisTooLongRSSData', 'languageFilter']) {
		var input = document.getElementsByName(key)[0];
		var savedValue = GM_getValue(key);
		if (savedValue !== undefined && savedValue !== null) {
			input.checked = savedValue;
		}
	}
	
	/**
	 * アラート音を鳴らすための要素。
	 * @type {HTMLAudioElement}
	 */
	var alertTone = document.getElementById('alert-tone');
	
	// 音声ファイル
	var audioData = UserSettings.getLargeValue('audioData');
	if (audioData) {
		alertTone.src = audioData;
		alertTone.hidden = false;
		document.getElementsByName('delete-sound')[0].hidden = false;
	}
	
	// ミュート
	if (GM_getValue('audioMuted')) {
		alertTone.muted = true;
	}
	
	// 音量
	var audioVolume = GM_getValue('audioVolume');
	if (audioVolume !== undefined && audioVolume !== null) {
		alertTone.volume = audioVolume;
	}
	
	// volumechangeイベント
	alertTone.addEventListener('volumechange', function (event) {
		if (event.target.muted) {
			GM_setValue('audioMuted', true);
		} else {
			GM_deleteValue('audioMuted');
		}
		
		if (event.target.volume === UserSettings.schema.properties.audioVolume.default) {
			GM_deleteValue('audioVolume');
		} else {
			GM_setValue('audioVolume', event.target.volume.toString());
		}
	});
	
	// clickイベント
	main.addEventListener('click', function (event) {
		var target = event.target;
		var name = target.name;
		if (target.localName === 'button') {
			if (name === 'sets-blacklist-uri') {
				// 特定の URL から NG リストを読み込んで、検索時に付加
				var newURL = window.prompt(_('特定の URL から、除外 URL のリストを読み込み、検索時に付加します。') + '\n'
					+ _('JSON 形式の URL 文字列の配列のみ有効です。') + '\n'
					+ _('また、除外 URL リストの読み込みは、アラートページ読み込み時に1回だけ行われます。'), GM_getValue('NGsURI', ''));
				if (newURL !== null) {
					GM_setValue('NGsURI', newURL);
				}
			} else if (name === 'import') {
				// インポート
				UserSettings.import();
			} else if (name === 'export') {
				// エクスポート
				UserSettings.export();
			} else if (name === 'select-sound') {
				// アラート音の選択
				document.querySelector('[accept="audio/*"]').click();
			} else if (name === 'delete-sound') {
				// 設定済みのアラート音の削除
				target.hidden = true;
				document.getElementById('alert-tone').hidden = true;
				document.querySelector('[accept="audio/*"]').src = '';
				UserSettings.deleteLargeValue('audioData');
			} else if (target.getAttribute('aria-controls') === 'user-setting-options') {
				// 追加設定ボックスの開閉
				var previousOpened = target.getAttribute('aria-expanded') === 'true';
				target.setAttribute('aria-expanded', previousOpened ? 'false' : 'true');
				document.getElementById('user-setting-options').setAttribute('aria-hidden', previousOpened ? 'true' : 'false');
				target.scrollIntoView(!previousOpened);
				if (!previousOpened && !document.body.classList.contains('nofix')) {
					window.scrollBy(0, -document.getElementById('siteHeader').clientHeight);
				}
			} else if (name === 'save-searching-words' || name === 'save-ng-communities') {
				// 検索語句、検索から除外するユーザー・コミュニティ・チャンネルの保存
				// 二重クリックを防止
				target.disabled = true;
	
				/**
				 * 対応するテキストエリア。
				 * @type {string}
				 */
				var textarea = document.getElementById(target.getAttribute('aria-controls'));
	
				/**
				 * 前後の空白を削除したテキストエリアの値。
				 * @type {string}
				 */
				var trimedValue = textarea.value.trim();
				
				/**
				 * 正規化後、空行を含めずに改行で分割し、重複を削除した値。
				 * @type {string[]}
				 */
				var values = trimedValue === ''
					? []
					: Array.from(new Set(trimedValue.split(/\s*\n\s*/).map(StringProcessor.normalize)));
				
				/**
				 * 検索語句の保存なら真。
				 * @type {boolean}
				 */
				var seavingWords = name === 'save-searching-words';
				
				/**
				 * {@link GM_setValue} に保存する値。
				 * @type {string[]}
				 */
				var savedValues;
				
				// 解析、キャッシュ、保存
				if (values.length > 0) {
					if (seavingWords) {
						// 検索語句
						savedValues = values;
						UserSettings.words = UserSettings.parseWords(values);
					} else {
						// 検索から除外するユーザー・コミュニティ・チャンネル
						savedValues = UserSettings.parseExclusions(values);
						UserSettings.exclusions = savedValues.concat(UserSettings.exclusionsFromExternal);
						// すでに表示している番組を非表示に
						TableProcessor.removeExclusions();
					}
				} else {
					savedValues = [];
				}
				
				/**
				 * テキストエリアに出力しなおす、正規化後の入力値。末尾に空行を含みます。
				 * @type {string}
				 */
				var outputValue;
				
				// 保存
				var gmValueName = seavingWords ? 'words' : 'NGs';
				if (savedValues.length > 0) {
					GM_setValue(gmValueName, JSON.stringify(savedValues));
					outputValue = savedValues.join('\n') + '\n';
				} else {
					GM_deleteValue(gmValueName);
					outputValue = '';
				}
	
				// テキストエリアに正規化後の文字列を出力
				if (textarea.value !== outputValue) {
					textarea.value = outputValue;
				}
				
				if (seavingWords) {
					Alert.restart();
				}
				
				// クリック禁止を解除
				target.disabled = false;
			}
		} else if (target.localName === 'th') {
			// 列ごとの並び替え
			if (!('sorted' in target)) {
				TableProcessor.sort(target);
				event.target.blur();
			}
		}
	});
	
	// changeイベント
	document.getElementById('user-setting-options').addEventListener('change', function (event) {
		var input = event.target;
		
		switch (input.name) {
			case 'visible-columns':
				// 表示する項目の選択
				var th = document.getElementById(input.value);
				if (input.checked) {
					TableProcessor.showColumn(th);
				} else {
					TableProcessor.hideColumn(th);
				}
				GM_setValue('visible-columns', JSON.stringify(UserSettings.getShownColumns()));
				break;
			
			case 'target-services':
				// 検索対象のサービスの選択
				if (input.checked) {
					Alert.enableService(input.value);
				} else {
					Alert.disableService(input.value);
				}
				GM_setValue('target-services', JSON.stringify(UserSettings.getTargetServices()));
				break;
	
			case 'exclusionMemberOnly':
				// プライベート配信の非表示
				if (input.checked) {
					TableProcessor.removePrivatePrograms();
					GM_setValue('exclusionMemberOnly', true);
				} else {
					GM_deleteValue('exclusionMemberOnly');
				}
				break;
	
			case 'ellipsisTooLongRSSData':
				// 文字数制限を超えている場合に省略
				if (input.checked) {
					GM_deleteValue('ellipsisTooLongRSSData');
				} else {
					GM_setValue('ellipsisTooLongRSSData', false);
				}
				break;
	
			case 'languageFilter':
				// 言語で絞り込み
				if (input.checked) {
					GM_deleteValue('languageFilter');
				} else {
					GM_setValue('languageFilter', false);
				}
				break;
	
			default:
				if (input.accept === 'audio/*') {
					// 音楽ファイルが選択された時
					var file = input.files[0];
					if (file) {
						var alertTone = document.getElementById('alert-tone');
						if (alertTone.canPlayType(file.type)) {
							var alertReader = new FileReader();
							alertReader.addEventListener('load', function (event) {
								UserSettings.setAudioData(event.target.result);
							});
							alertReader.readAsDataURL(file);
						} else {
							window.alert(_('使用中のブラウザが対応していないファイル形式です。'));
						}
					}
				}
		}
	});
	
	// ダブルクリックした検索語句・除外URLを新しいタブで開く
	document.getElementById('main-settings').addEventListener('dblclick', function (event) {
		var textarea = event.target;
		if (textarea.localName === 'textarea') {
			/**
			 * ダブルクリックされた位置。
			 * @type {number}
			 */
			var clickedPosition = textarea.selectionStart;
		
			// ダブルクリックされた行を取得
			var words = textarea.value;
			var beginSlice = words.lastIndexOf(
				'\n',
				words.slice(clickedPosition, clickedPosition + 1) === '\n' ? clickedPosition - 1 : clickedPosition
			);
			if (beginSlice === -1) {
				beginSlice = 0;
			}
			var endSlice = words.indexOf('\n', clickedPosition);
			var word = words.slice(beginSlice === -1 ? 0 : beginSlice, endSlice === -1 ? undefined : endSlice).trim();
		
			if (word) {
				var url = textarea.name === 'searching-words'
					? 'http://live.nicovideo.jp/search/' + encodeURIComponent(word)
					: UserSettings.parseExclusion(word);
				if (url) {
					window.open(url);
				}
			}
		}
	});
	
	/**
	 * 現在のドラッグイベント。
	 * @type {DragEvent}
	 */
	var draggingEvent;

	// 列の位置
	table.addEventListener('dragstart', function (event) {
		draggingEvent = event;
		if (event.target.localName === 'th' && document.querySelector('#programs thead tr').contains(event.target)) {
			event.dataTransfer.setData('Text', '');
			// 他のスクリプトを抑制
			event.stopPropagation();
		}
	});
	
	var headRow = document.querySelector('#programs thead tr');
	headRow.addEventListener('drag', function (event) {
		if (event.target.localName === 'th') {
			// 他のスクリプトを抑制
			event.stopPropagation();
		}
	});
	
	headRow.addEventListener('dragover', function (event) {
		if (draggingEvent.target.localName === 'th' && event.currentTarget.contains(draggingEvent.target)) {
			event.preventDefault();
			// 項目の移動先
			var className = event.pageX < event.target.offsetLeft + event.target.offsetWidth / 2 ? 'inserting-before' : 'inserting-after';
			if (!event.target.classList.contains(className)) {
				TableProcessor.removeOldClassName();
				event.target.classList.add(className);
			}
		}
	});
	
	headRow.addEventListener('dragleave', function (event) {
		if (draggingEvent.target.localName === 'th' && event.currentTarget.contains(draggingEvent.target)) {
			TableProcessor.removeOldClassName();
		}
	});
	
	main.addEventListener('drop', function (event) {
		var row = event.currentTarget.querySelector('thead tr');
		if (draggingEvent.target.localName === 'th' && row.contains(draggingEvent.target) && event.target.localName === 'th' && row.contains(event.target)) {
			// 項目を移動
			var refIndex = event.target.cellIndex + (event.target.classList.contains('inserting-before') ? 0 : 1);
			if (draggingEvent.target.cellIndex !== refIndex) {
				// 変更があれば
				TableProcessor.moveColumn(draggingEvent.target, refIndex);
	
				// 設定を保存
				GM_setValue('columns-position', JSON.stringify(TableProcessor.getColumnPositions()));
			}
	
			event.target.classList.remove('inserting-before');
			event.target.classList.remove('inserting-after');
		} else if (event.target.name === 'ng-communities') {
			// NG コミュニティを追加
			event.preventDefault();
	
			// 他のスクリプトを阻害しないよう dragend イベントを発生させておく
			var init = {};
			for (var key in draggingEvent) {
				init[key] = draggingEvent[key];
			}
			draggingEvent.target.dispatchEvent(new DragEvent('dragend', init));
	
			event.target.value += '\n' + event.dataTransfer.getData('Text');
			document.getElementsByName('save-ng-communities')[0].click();
		}
	});
	
	// 列の位置
	var columnPositions = GM_getValue('columns-position');
	if (columnPositions) {
		TableProcessor.reflectColumnPositions(version, JSON.parse(columnPositions));
	}
	
	// 並び順
	var order = GM_getValue('order');
	if (order) {
		order = JSON.parse(order);
		if (order.name !== UserSettings.schema.properties.order.default.name
			|| order.order !== UserSettings.schema.properties.order.default.order) {
			TableProcessor.sort(document.getElementById(order.name));
		} else {
			GM_deleteValue('order')
		}
	}
	
	// ソート時に並び順を保存
	table.addEventListener('sort', function (event) {
		var tHead = event.target.tHead;
		new MutationObserver(function (mutations, observer) {
			observer.disconnect();
			var sortedTH = tHead.querySelector('[sorted]');
			GM_setValue('order', JSON.stringify({
				name: sortedTH.id,
				order: TableProcessor.getSorting(sortedTH),
			}));
		}).observe(tHead, {
			subtree: true,
			attributeFilter: ['sorted'],
		});
	});
	
	// 表示される列
	var visibleColumnsJSON = GM_getValue('visible-columns');
	UserSettings.showColumns(version, visibleColumnsJSON
		? JSON.parse(visibleColumnsJSON)
		: UserSettings.schema.properties['visible-columns'].default);
	
	// 現在のバージョン番号を保存
	GM_setValue('version', GM_info.script.version);

	// 外部からの除外リスト取得
	var NGsURI = GM_getValue('NGsURI');
	var preprocessing;
	if (NGsURI) {
		preprocessing = new HTTPRequest({
			method: 'GET',
			url: NGsURI,
			responseType: 'json',
			timeout: 30 * DateUtils.SECONDS_TO_MILISECONDS,
			mode: 'no-cors',
		}).send().then(function (response) {
			UserSettings.exclusionsFromExternal = UserSettings.parseExclusions(response);
			UserSettings.exclusions = UserSettings.exclusions.concat(UserSettings.exclusionsFromExternal);
		}).catch(function (error) {
			window.alert(_('指定された URL から、除外 URL リストを読み込めませんでした。\n取得せずに続行します。\n\nエラーメッセージ:\n%s').replace('%s', error));
		}).then();
	} else {
		preprocessing = Promise.resolve();
	}
	
	// 検索開始
	preprocessing.then(function () {
		Alert.initialize();
		Alert.restart();
		
		// 経過時間の定期的な更新
		TableProcessor.startUpdatingDurations();
	});
}

function defineClasses() {
	/**
	 * ページのFaviconに用いるデータURL。
	 * @constant {string}
	 */
	Alert.ICON = 'data:image/vnd.microsoft.icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABvb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//AAAAAAAAAAAAAAAAAAAAAAAAAABvb2//2drc/9na3P///////////42Hsv+Nh7L////////////Z2tz/2drc/29vb/8AAAAAAAAAAAAAAABvb2//qqqq/////////////////42Hsv/EufX/xLn1/42Hsv///////////9na3P+qqqr/b29v/wAAAABvb2//jYey///////EufX///////////+Nh7L//////8S59f+Nh7L////////////EufX/2drc/42Hsv9vb2//b29v/8S59f///////////xUYI////////////42Hsv+Nh7L///////////8VGCP//////9na3P/EufX/b29v/29vb//EufX///////////8VGCP/////////////////////////////////FRgj///////Z2tz/xLn1/29vb/8AAAAAb29v/9na3P//////////////////////////////////////////////////////2drc/29vb/8AAAAAAAAAAAAAAABvb2//b29v/9na3P/////////////////////////////////Z2tz/b29v/29vb/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABvb2//b29v/9na3P/Z2tz/2drc/9na3P9vb2//b29v/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABvb2//APD//w1qdv9vb2//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG9vb/8Nanb/BMbU/w1qdv8Nanb/APD//w1qdv9vb2//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABvb2//BMbU/wDw//8ExtT/DWp2/wTG1P8A8P//b29v/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG9vb/8Nanb/b29v/29vb/8Nanb/b29v/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAb29v/9na3P+/wMP/b29v/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG9vb///////2drc/29vb/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAb29v/29vb/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4AcAAMADAACAAQAAAAAAAAAAAAAAAAAAgAEAAMADAADwDwAA/D8AAPAPAADwDwAA+B8AAPw/AAD8PwAA/n8AAA==';
	
	/**
	 * 初期化。
	 */
	Alert.initialize = function () {
		// 各サービスのインスタンスにイベントリスナーを設定
		for (var service of this.services) {
			service.addEventListener('progress', this.onprogress);
			service.addEventListener('load', this.onload.bind(this));
			service.addEventListener('error', this.onerror);
		}
		
		// Faviconの設定
		this.favico = new Favico({
			animation: 'none',
			position : 'up',
		});
	};
	
	/**
	 * @param {Service#ProgramEvent}
	 * @access private
	 */
	Alert.onprogress = function (event) {
		var program = event.detail;
		if (!program.private || !document.getElementsByName('exclusionMemberOnly')[0].checked) {
			// プライベート配信で無い、またはプライベート配信を拒否していなければ
			var urls = [program.link];
			if (program.author && program.author.url) {
				urls.push(program.author.url);
			}
			if (program.community && program.community.url) {
				urls.push(program.community.url);
			}
			if (urls.every(url => UserSettings.exclusions.indexOf(url) === -1)) {
				// 除外リストに含まれていなければ
				// 一覧に追加
				TableProcessor.insertProgram(program, urls);
				
				// アラート音を鳴らす
				var alertTone = document.getElementById('alert-tone');
				if (!alertTone.hidden && !alertTone.muted && alertTone.volume > 0) {
					alertTone.play();
				}
			}
		}
	};
	
	/**
	 * @param {Service#LoadedEvent}
	 * @access private
	 */
	Alert.onload = function (event) {
		// 配信終了の番組を削除
		TableProcessor.removeOldPrograms(event.detail.service, event.detail.programs, event.detail.searchCriteria);
		
		// 最終更新日時を設定
		UserSettings.showLatestUpdatedDate(event.target);
		
		// ヒット数の更新
		this.showHits();
	};
	
	/**
	 * ヒット数を更新します。
	 */
	Alert.showHits = function () {
		var tBody = document.querySelector('#programs tbody');
		
		/**
		 * ヒット数。
		 * @type {number}
		 */
		var hits = tBody.rows.length;
	
		// ページタイトルの修正
		document.title = (hits > 0 ? `(${hits})` : '') + Alert.NAME;
		
		// Faviconの修正
		this.favico.badge(hits);
		
		// 行の色分けの調整
		if (hits % 2 === 0) {
			tBody.classList.remove('odd');
		} else {
			tBody.classList.add('odd');
		}
	};
	
	/**
	 * スクリプトが動作している状態であれば真。
	 * @type {boolean}
	 * @access private
	 */
	Alert.run = false;
	
	/**
	 * スクリプトが停止している状態であれば起動し、動作している状態であればOR検索ができないサービスを再読み込みします。
	 * 検索語句が空の場合は、スクリプトを停止します。
	 */
	Alert.restart = function () {
		if (UserSettings.words.length === 0) {
			// 検索語句が空であれば
			this.stop();
		} else if (this.run) {
			// スクリプトが動作している状態であれば
			for (var service of this.services) {
				if (service.disabledOr) {
					service.reset();
				}
			}
		} else {
			// スクリプトが停止している状態であれば
			// 有効なサービスで検索を開始
			this.run = true;
			var enabledServices = UserSettings.getTargetServices();
			for (var service of this.services) {
				if (enabledServices.indexOf(service.id) !== -1) {
					service.start();
				}
			}
		}
	};
	
	/**
	 * スクリプトを停止します。
	 */
	Alert.stop = function () {
		if (this.run) {
			// スクリプトが動作している状態であれば、すべてのサービスで検索を停止
			this.run = false;
			for (var service of this.services) {
				service.stop();
			}
		}
	};
	
	/**
	 * 指定されたサービスを有効化します。
	 * @param {string} id
	 */
	Alert.enableService = function (id) {
		if (this.run) {
			this.services.find(service => service.id === id).start();
		}
	},
	
	/**
	 * 指定されたサービスを無効化します。
	 * @param {string} id
	 */
	Alert.disableService = function (id) {
		if (this.run) {
			var service = this.services.find(service => service.id === id);
			service.stop();
			TableProcessor.removeProgramsWithService(service);
		}
	},
	
	/**
	 * ライブストリーミング配信サービスのリスト。
	 * @type {Service[]}
	 */
	Alert.services = [new Service({
		id: 'fc2-live',
		name: _('FC2ライブ'),
		url: 'http://live.fc2.com/',
		disabledSearch: true,
		disabledLanguageFilter: true,
		getDetails: function () {
			return {
				mode: 'no-cors',
				method: 'GET',
				url: 'https://live.fc2.com/contents/allchannellist.php',
				responseType: 'json',
			};
		},
		parseResponse: function (response) {
			return { programs: response.channel.filter(function (program) {
				return program.type !== 2 && program.login !== 2 && program.pay === 0;
			}) };
		},
		convertIntoEntry: function (program) {
			var url = 'http://live.fc2.com/' + program.id;
			return new Program(url, program.title, {
				icon: program.image,
				published: new Date(program.start.replace(' ', 'T') + '+09:00'),
				categories: program.category === 0 ? null : [['雑談', 'ゲーム', '作業', '動画', 'その他', , '公式'][program.category - 1]],
				author: {
					name: program.name,
					url: url,
				},
				visitors: Number.parseInt(program.total),
				language: program.lang,
			});
		},
	}), new Service({
		id: 'cavetube',
		name: _('CaveTube'),
		url: 'http://gae.cavelis.net/',
		disabledSearch: true,
		getDetails: function () {
			return {
				mode: 'no-cors',
				method: 'GET',
				url: 'http://rss.cavelis.net/index_live.xml',
				responseType: 'document',
			};
		},
		parseResponse: function (response) {
			return { programs: response.getElementsByTagNameNS(DOMUtils.ATOM_NAMESPACE, 'entry') };
		},
		/**
		 * CaveTube名前空間。
		 * @constant {string}
		 */
		CT_NAMESPACE: 'http://gae.cavelis.net',
		convertIntoEntry: function (program) {
			var name = program.getElementsByTagNameNS(DOMUtils.ATOM_NAMESPACE, 'name')[0].textContent;
			var parser = new DOMParser();
			return new Program(
				program.getElementsByTagNameNS(DOMUtils.ATOM_NAMESPACE, 'link')[0].getAttribute('href'),
				parser.parseFromString(program.getElementsByTagNameNS(DOMUtils.ATOM_NAMESPACE, 'title')[0].textContent, 'text/html').body.textContent,
				{
					icon: new URL(program.getElementsByTagNameNS(this.CT_NAMESPACE, 'thumbnail_path')[0].textContent, 'http://gae.cavelis.net/').href,
					published: new Date(program.getElementsByTagNameNS(DOMUtils.ATOM_NAMESPACE, 'published')[0].textContent),
					author: {
						name: name,
						url: 'http://gae.cavelis.net/user/' + encodeURIComponent(name),
					},
					categories: program.getElementsByTagNameNS(this.CT_NAMESPACE, 'tag')[0].textContent.split(' '),
					summary: parser.parseFromString(program.getElementsByTagNameNS(DOMUtils.ATOM_NAMESPACE, 'summary')[0].textContent, 'text/html').body.textContent,
					visitors: Number.parseInt(program.getElementsByTagNameNS(this.CT_NAMESPACE, 'viewer')[0].textContent),
					comments: Number.parseInt(program.getElementsByTagNameNS(this.CT_NAMESPACE, 'comment_num')[0].textContent),
				}
			);
		},
	}), new Service({
		id: 'koebu-live',
		name: _('こえ部LIVE!'),
		url: 'http://koebu.com/live/',
		icon: 'http://koebu.com/favicon.ico',
		disabledSearch: true,
		getDetails: function () {
			return {
				mode: 'no-cors',
				method: 'GET',
				url: 'http://koebu.com/live/list?page=1',
				responseType: 'document',
			};
		},
		parseResponse: function (response, details) {
			var programs = { programs: response.getElementsByClassName('unitCh') };
			var nextPage = response.querySelector('.pager .next a');
			if (nextPage) {
				details.url = nextPage.href;
				programs.next = details;
			}
			return programs;
		},
		convertIntoEntry: function (program) {
			var unitThumb = program.querySelector('.unitThumb img');
			var masterUnitChThumb = program.querySelector('.masterUnitCh img');
			var link = unitThumb.parentElement.href;
			return new Program(link, unitThumb.alt, {
				icon: unitThumb.src,
				author: {
					name: masterUnitChThumb.alt,
					url: masterUnitChThumb.parentElement.href,
				},
				categories: Array.from(program.querySelectorAll('[rel="tag"]'), function (anchor) {
					return anchor.text;
				}),
				summary: program.getElementsByClassName('description')[0].textContent,
				visitors: Number.parseInt(program.querySelector('.ttlNumListen ~ dd').textContent),
				community: {
					name: unitThumb.alt,
					url: link,
				},
			});
		},
	}), new Service({
		id: 'showroom',
		name: _('SHOWROOM'),
		url: 'https://www.showroom-live.com/',
		disabledSearch: true,
		getDetails: function () {
			return {
				mode: 'no-cors',
				method: 'GET',
				url: 'https://www.showroom-live.com/onlive',
				responseType: 'document',
			};
		},
		parseResponse: function (response) {
			return { programs: response.getElementsByClassName('onlive-list-li') };
		},
		convertIntoEntry: function (program) {
			return new Program(program.getElementsByClassName('overview-link')[0].href, program.getElementsByClassName('tx-title')[0].textContent, {
				icon: program.getElementsByClassName('img-main')[0].dataset.src,
				published: DateUtils.parseJSTString(program.getElementsByClassName('time')[0].textContent),
				visitors: Number.parseInt(program.getElementsByClassName('view')[0].textContent),
			});
		},
	}), new Service({
		id: 'stickam-japan',
		name: _('Stickam JAPAN!'),
		url: 'http://www.stickam.jp/',
		disabledSearch: true,
		getDetails: function () {
			return {
				mode: 'no-cors',
				method: 'GET',
				url: 'http://www.stickam.jp/explore/session?page=1',
				responseType: 'document',
			};
		},
		parseResponse: function (response, details) {
			var programs = { programs: response.getElementsByClassName('col-md-3 col-sm-3') };
			var nextPage = response.querySelector('.pagination > li:last-of-type:not(.disabled) a');
			if (nextPage) {
				details.url = nextPage.href;
				programs.next = details;
			}
			return programs;
		},
		convertIntoEntry: function (program) {
			var userNameLink = program.getElementsByTagName('a')[0];
			return new Program(userNameLink.href, userNameLink.text, {
				icon: program.getElementsByClassName('embed-responsive-item')[0].dataset.src,
				private: Boolean(program.getElementsByClassName('status')[0]),
				published: DateUtils.parseJSTString(program.getElementsByClassName('post-info')[0].textContent.replace('~', '')),
				author: {
					name: userNameLink.text,
					url: userNameLink.href.replace('stickon#webcam', ''),
				},
				summary: program.getElementsByTagName('p')[0].textContent,
			});
		},
	}), new Service({
		id: 'twitcasting',
		name: _('ツイキャス'),
		url: 'http://twitcasting.tv/',
		disabledOr: true,
		disabledMinus: true,
		disabledLanguageFilter: true,
		getDetails: function (searchCriteria) {
			return {
				mode: 'no-cors',
				method: 'GET',
				url: 'http://twitcasting.tv/search/text/' + encodeURIComponent(searchCriteria.plus.join(' ')),
				responseType: 'document',
			};
		},
		parseResponse: function (response) {
			return { programs: response.querySelectorAll('#content > div:first-of-type td') };
		},
		convertIntoEntry: function (program) {
			var url = program.querySelector('.searcheduser a').href;
			var titleAndComments = /(.*?)(?: \((0|[1-9][0-9]*)\))?$/.exec(program.querySelector('.title a').text);
			var language;
			switch (program.getElementsByClassName('countryflag')[0].src) {
				case 'http://twitcasting.tv/img/c/us.gif':
					language = 'en';
					break;
				case 'http://twitcasting.tv/img/c/br.gif':
					language = 'pt';
					break;
				case 'http://twitcasting.tv/img/c/mx.gif':
					language = 'es';
					break;
				case 'http://twitcasting.tv/img/c/jp.gif':
					language = 'ja';
					break;
			}
			return new Program(url, titleAndComments[1], {
				icon: program.getElementsByClassName('icon32')[0].src,
				author: {
					name: program.getElementsByClassName('fullname')[0].textContent,
					url: url,
				},
				categories: Array.from(program.getElementsByClassName('tag'), function (anchor) {
					return anchor.text;
				}),
				summary: program.getElementsByClassName('userdesc')[0].textContent.trim(),
				comments: titleAndComments[2] ? Number.parseInt(titleAndComments[2]) : null,
				language: language,
			});
		},
	}), new Service({
		id: 'twitch',
		name: _('Twitch'),
		url: 'http://www.twitch.tv/',
		/**
		 * 検索結果の最大件数。
		 * @constant {number}
		 */
		MAX_RESULT_LENGTH: 100,
		disabledOr: true,
		disabledMinus: true,
		disabledLanguageFilter: true,
		getDetails: function (searchCriteria) {
			return {
				mode: 'no-cors',
				method: 'GET',
				url: `https://api.twitch.tv/kraken/search/streams?limit=${this.MAX_RESULT_LENGTH}&q=${encodeURIComponent(searchCriteria.plus.join(' '))}`,
				responseType: 'json',
			};
		},
		parseResponse: function (response) {
			return { programs: response.streams };
		},
		convertIntoEntry: function (program) {
			return new Program(program.channel.url, program.channel.status || 'Untitled Broadcast', {
				icon: program.channel.logo || program.channel.profile_banner,
				published: new Date(program.created_at),
				author: {
					name: program.channel.display_name,
					url: program.channel.url + '/profile',
				},
				categories: program.channel.game ? [program.channel.game] : null,
				visitors: program.viewers,
				language: program.channel.language,
			});
		},
	}), new Service({
		id: 'niconico-live',
		name: _('ニコニコ生放送'),
		url: 'http://live.nicovideo.jp/',
		icon: 'http://nl.simg.jp/public/inc/assets/zero/img/base/favicon.ico',
		/**
		 * 検索結果の最大件数。
		 * @constant {number}
		 */
		MAX_RESULT_LENGTH: 100,
		getDetails: function (words) {
			return {
				mode: 'cors',
				method: 'POST',
				url: 'http://api.search.nicovideo.jp/api/',
				responseType: 'text',
				data: {
					query: words.map(function (searchCriteria) {
						var word = searchCriteria.plus.join(' ');
						if (searchCriteria.minus.length > 0) {
							word += ' -' +  searchCriteria.minus.join(' -')
						}
						return word;
					}).join(' OR '),
					service: ['live'],
					search: ['title', 'tags', 'description'],
					join: [
						'cmsid',            // ID
						'title',            // タイトル
						'member_only',      // プライベート
						'start_time',       // 開始時刻
						'community_icon',   // コミュニティアイコン
						'tags',             // タグ
						'description',      // 詳細
						'community_id',     // コミュニティID
						'channel_id',
						'view_counter',
						'comment_counter',
					],
					filters: [
						{
							type: 'equal',
							field: 'live_status',
							value: 'onair',
						}
					],
					sort_by: 'start_time',
					from: 0,
					size: this.MAX_RESULT_LENGTH,
					issuer: 'ニコ生アラート(簡)',
					reason: 'ma10',
				},
			};
		},
		parseResponse: function (response, details) {
			var programs = { programs: [] };
			for (var jsonString of response.trim().split('\n')) {
				var data = JSON.parse(jsonString);
				if (data.type === 'hits') {
					if (data.endofstream) {
						break;
					}
					programs.programs = data.values;
					if (programs.programs.length === details.data.size) {
						details.data.from = programs.programs[details.data.size - 1]._rowid + 1;
						programs.next = details;
					}
					break;
				}
			}
			return programs;
		},
		convertIntoEntry: function (program) {
			var otherDetails = {
				icon: program.community_icon,
				private: Boolean(program.member_only),
				published: new Date(program.start_time.replace(' ', 'T') + '+09:00'),
				// <br /> タグを半角スペースに置き換え、他のタグは取り除く
				summary: program.description.replace(/<br( )\/>|<font[^>]+>|<\/?(?:font|b|i|s|u)>/g, '$1'),
				categories: program.tags.split(' '),
				visitors: program.view_counter,
				comments: program.comment_counter,
			};
			
			if (p(otherDetails.summary).includes('&')) {
				// 文字参照が含まれていれば
				otherDetails.summary = new DOMParser().parseFromString(otherDetails.summary, 'text/html').body.textContent;
			}
			
			if (program.community_id || program.channel_id) {
				otherDetails.community = {
					name: program.community_id ? 'co' + program.community_id : 'ch' + program.channel_id,
				};
				otherDetails.community.url = 'http://com.nicovideo.jp/community/' + otherDetails.community.name;
			}
			
			return new Program('http://live.nicovideo.jp/watch/' + program.cmsid, program.title, otherDetails);
		},
		delay: 1 * DateUtils.MINUTES_TO_MILISECONDS,
	}), new Service({
		id: 'himawari-stream',
		name: _('ひまわりストリーム'),
		url: 'http://himast.in/',
		disabledSearch: true,
		getDetails: function () {
			return {
				method: 'GET',
				url: 'http://himast.in/?mode=program&cat=search&sort=st_start_date&st_status=1&rss=1',
				responseType: 'document',
			};
		},
		parseResponse: function (response) {
			return { programs: response.getElementsByTagName('item') };
		},
		convertIntoEntry: function (program) {
			var details = [];
			var description = new DOMParser().parseFromString(program.getElementsByTagName('description')[0].textContent, 'text/html');
			var summary;
			for (var node of description.getElementsByClassName('riRssContributor')[0].childNodes) {
				if (node.nodeType === Node.TEXT_NODE) {
					details.push([node.data.replace(/^\s+|:|:/g, '')]);
				} else if (node.localName === 'b') {
					details[details.length - 1][1] = node.textContent;
				} else {
					summary = node.nextSibling.data;
					break;
				}
			}
			
			return new Program(
				program.getElementsByTagName('link')[0].textContent,
				program.getElementsByTagName('title')[0].textContent,
				{
					icon: description.getElementsByTagName('img')[0].src,
					published: new Date(program.getElementsByTagName('pubDate')[0].textContent),
					author: {
						name: details.find(function (detail) {
							return detail[0] === '配信者';
						})[1],
					},
					summary: summary,
					visitors: Number.parseInt(details.find(function (detail) {
						return detail[0] === '延べ入場者数';
					})[1]),
					comments: Number.parseInt(details.find(function (detail) {
						return detail[0] === 'コメント数';
					})[1]),
				}
			);
		},
	}), new Service({
		id: 'ustream',
		name: _('Ustream'),
		url: 'http://www.ustream.tv/',
		icon: 'http://static-cdn1.ustream.tv/images/favicon-black:1.ico',
		disabledOr: true,
		disabledMinus: true,
		getDetails: function (searchCriteria) {
			return {
				mode: 'no-cors',
				method: 'GET',
				url: 'https://www.ustream.tv/ajax/search.json?type=live&q=' + encodeURIComponent(searchCriteria.plus.join(' ')),
				responseType: 'json',
			};
		},
		parseResponse: function (response) {
			var doc = new DOMParser().parseFromString(response.pageContent, 'text/html');
			doc.head.insertAdjacentHTML('beforeend', h`<base href="${this.url}" />`);
			return { programs: doc.getElementsByClassName('media-item') };
		},
		convertIntoEntry: function (program) {
			var mediaData = program.getElementsByClassName('media-data')[0];
			var title = mediaData.title;
			var url = mediaData.href;
			var viewers = program.querySelector('.item-viewers strong');
			return new Program(url, title, {
				icon: program.querySelector('.channel-tbn img').src,
				visitors: viewers && Number.parseInt(viewers.textContent),
				community: {
					name: title,
					url: url,
				},
			});
		},
	}), new Service({
		id: 'youtube-live',
		name: _('Youtube ライブ'),
		url: 'https://www.youtube.com/live',
		icon: 'https://i.ytimg.com/i/4R8DWoMoI7CAwX8_LjQHig/mq1.jpg',
		disabledOr: true,
		getDetails: function (word) {
			var query = word.plus.join(' ');
			if (word.minus.length > 0) {
				query += ' -' +  word.minus.join(' -')
			}
			return {
				mode: 'no-cors',
				method: 'GET',
				url: 'https://www.youtube.com/results?filters=live&search_sort=video_date_uploaded&search_query='
					+ encodeURIComponent(query),
				responseType: 'document',
			};
		},
		parseResponse: function (response) {
			return { programs: response.getElementsByClassName('yt-lockup-dismissable') };
		},
		convertIntoEntry: function (program) {
			var anchor = program.querySelector('.yt-lockup-title a');
			var userAnchor = program.querySelector('.yt-lockup-byline a');
			var description = program.getElementsByClassName('yt-lockup-description')[0];
			var metaInfo = document.getElementsByClassName('yt-lockup-meta-info')[0];
			return new Program(anchor.href, anchor.title, {
				icon: program.querySelector('.yt-thumb img').src,
				author: {
					name: userAnchor.text,
					url: userAnchor.href,
				},
				summary: description ? description.textContent : null,
				visitors: metaInfo ? Number.parseInt(/[0-9]+/.exec(metaInfo)[0]) : null,
			});
		},
	}), new Service({
		id: 'younow',
		name: _('YouNow'),
		url: 'https://www.younow.com/',
		disabledMinus: true,
		/**
		 * 検索結果の最大件数。
		 * @constant {number}
		 */
		MAX_RESULT_LENGTH: 100,
		getDetails: function (words) {
			return {
				mode: 'cors',
				method: 'POST',
				url: 'https://qz0xcgubgq.algolia.io/1/indexes/*/queries',
				responseType: 'json',
				headers: {
					'X-Algolia-Application-Id': 'QZ0XCGUBGQ',
					'X-Algolia-API-Key': '7f270d4586d986ef69fb5bab5ecd7f741b5cb3f7042881112ed46c97b5e8404a',
					'X-Algolia-TagFilters': '(public)',
				},
				data: { requests: words.map(searchCriteria => {
					return {
						indexName: 'people_search_live',
						params: `hitsPerPage=${this.MAX_RESULT_LENGTH}&advancedSyntax=1&query=${encodeURIComponent(searchCriteria.plus.join(' '))}`,
					};
				}) },
			};
		},
		parseResponse: function (response, details, words) {
			return { programs: Array.prototype.concat.apply([], response.results.map(function (result, index) {
				var programs = [];
				var searchCriteria = words[index];
				for (var program of result.hits) {
					if (program.tag === '') {
						break;
					}
					program.searchCriteria = searchCriteria;
					programs.push(program);
				}
				return programs;
			})) };
		},
		convertIntoEntry: function (program) {
			var url = 'https://www.younow.com/' + program.profile;
			
			return new Program(url, '#' + program.tag, {
				icon: 'https://cdn2.younow.com/php/api/channel/getImage/channelId=' + program.objectID,
				author: {
					name: program.firstName + ' ' + program.lastName,
					url: url,
				},
				categories: [program.tag],
				summary: program.description,
			}, program.searchCriteria);
		},
	}), new Service({
		id: 'livestream',
		name: _('Livestream'),
		url: 'https://livestream.com/watch/',
		icon: 'https://cdn.livestream.com/website/794776b/assets/favicon-80e0433a0ae645d507841ae46338238a.ico',
		/**
		 * 検索結果の最大件数。
		 * @constant {number}
		 */
		MAX_RESULT_LENGTH: 1000,
		getDetails: function (words) {
			return {
				mode: 'cors',
				method: 'POST',
				url: 'https://7kjecl120u-1.algolia.io/1/indexes/*/queries',
				responseType: 'json',
				data: {
					apiKey: '98f12273997c31eab6cfbfbe64f99d92',
					appID: '7KJECL120U',
					requests: words.map(searchCriteria => {
						var query = searchCriteria.plus.join(' ');
						if (searchCriteria.minus.length > 0) {
							query += ' -' +  searchCriteria.minus.join(' -')
						}
						return {
							indexName: 'events',
							params: `hitsPerPage=${this.MAX_RESULT_LENGTH}&advancedSyntax=1&facets=*&facetFilters=%5B%22is_live%3A1%22%5D&query=${encodeURIComponent(query)}`,
						};
					}),
				},
			};
		},
		parseResponse: function (response, details, words) {
			return { programs: Array.prototype.concat.apply([], response.results.map(function (result, index) {
				var searchCriteria = words[index];
				for (var program of result.hits) {
					program.searchCriteria = searchCriteria;
				}
				return result.hits;
			})) };
		},
		convertIntoEntry: function (program) {
			var tags = [];
			if (program.category_name !== 'No category') {
				tags.push(program.category_name);
			}
			if (program.subcategory_name) {
				tags.push(program.subcategory_name);
			}
			if (program.tags) {
				tags.concat(program.tags.split(','));
			}
			
			return new Program('https://livestream.com' + program.path, program.full_name, {
				icon: program.owner_logo ? program.owner_logo.thumbnail.url : null,
				private: Boolean(program.is_password_protected),
				published: new Date(program.start_time),
				author: {
					name: program.owner_account_full_name,
					url: 'https://livestream.com/accounts/' + program.owner_account_id,
				},
				categories: tags.length > 0 ? tags : null,
				visitors: program.concurrent_viewers_count,
				comments: program.live_video_post_comments_count,
			}, program.searchCriteria);
		},
	}), new Service({
		id: 'livetube',
		name: _('Livetube'),
		url: 'https://livetube.cc/',
		disabledSearch: true,
		getDetails: function () {
			return {
				mode: 'no-cors',
				method: 'GET',
				url: 'https://livetube.cc/index.live.json',
				responseType: 'json',
			};
		},
		parseResponse: function (response) {
			return { programs: response };
		},
		convertIntoEntry: function (program) {
			return new Program('https://livetube.cc/' + program.link, program.title, {
				published: new Date(program.created),
				author: {
					name: program.author,
					url: 'https://livetube.cc/hina0083/' + encodeURIComponent(program.author),
				},
				categories: program.tags,
				visitors: program.view,
				comments: program.comments,
			});
		},
	})];
	
	/**
	 * ライブ配信番組を表示する表に関する処理を行うユーティリティークラス。
	 */
	window.TableProcessor = {
		/**
		 * 経過時間を更新する間隔。ミリ秒数。
		 * @type {number}
		 */
		DURATION_UPDATING_INTERVAL: 20 * DateUtils.SECONDS_TO_MILISECONDS,
		
		/**
		 * 経過時間の更新を開始します。
		 */
		startUpdatingDurations: function () {
			for (var duration of document.querySelectorAll('[itemtype="http://schema.org/VideoObject"] [itemprop="duration"]:not([hidden])')) {
				var serialized = DateUtils.getDuration(new Date(duration.dataset.start));
				duration.value = serialized.dateTime;
				duration.textContent = serialized.text;
			}
			window.setTimeout(this.startUpdatingDurations.bind(this), this.DURATION_UPDATING_INTERVAL);
		},
		
		/**
		 * プライベート番組を表から取り除きます。
		 * @returns {HTMLRowElement[]}
		 */
		removePrivatePrograms: function () {
			for (var requiresSubscription of document.querySelectorAll('[itemtype="http://schema.org/VideoObject"] [itemprop="requiresSubscription"][value="true"]')) {
				requiresSubscription.closest('[itemscope]').remove();
			}
			Alert.showHits();
		},
		
		/**
		 * 指定されたサービスの番組を表から取り除きます。
		 * @param {Service} service
		 * @returns {HTMLRowElement[]}
		 */
		removeProgramsWithService: function (service) {
			for (var row of this.getProgramsWithService(service)) {
				row.remove();
			}
			Alert.showHits();
		},
		
		/**
		 * 指定されたサービスの番組を取得します。
		 * @param {Service} service
		 * @param {SearchCriteria} [searchCriteria] - OR検索ができないサービスにおいて、対象の検索条件。
		 * @returns {HTMLRowElement[]}
		 */
		getProgramsWithService: function (service, searchCriteria) {
			return Array.from(document.querySelectorAll(
				'[itemtype="http://schema.org/VideoObject"] [itemprop="provider"]' + (searchCriteria ? `[data-search-criteria="${CSS.escape(JSON.stringify(searchCriteria))}"]` : '') + ` [itemprop="url"][href="${service.url}"]`
			)).map(providerURL => providerURL.closest('[itemtype="http://schema.org/VideoObject"]'));
		},
	
		/**
		 * 除外対象の番組を表から取り除きます。
		 */
		removeExclusions: function () {
			for (var urlProperty of document.querySelectorAll('[itemtype="http://schema.org/VideoObject"] [itemprop="url"], [itemtype="http://schema.org/VideoObject"] [itemprop="workLocation"]')) {
				if (UserSettings.exclusions.indexOf(urlProperty.href) !== -1) {
					var row = urlProperty.closest('[itemtype="http://schema.org/VideoObject"]');
					if (row.parentElement) {
						row.remove();
					}
				}
			}
			Alert.showHits();
		},
		
		/**
		 * 以前に取得した番組を表から取り除きます。
		 * @param {Service} service - 対象のサービス。
		 * @param {Program[]} currentPrograms - 今回取得した番組。
		 * @param {SearchCriteria} [searchCriteria] - OR検索ができないサービスにおいて、対象の検索条件。
		 */
		removeOldPrograms: function (service, currentPrograms, searchCriteria) {
			var urls = currentPrograms.map(program => program.link);
			for (var row of this.getProgramsWithService(service, searchCriteria)) {
				if (urls.indexOf(row.querySelector('td > [itemprop="url"]').href) === -1) {
					row.remove();
				}
			}
		},
		
		/**
		 * 指定された番組を表に追加します。
		 * @param {Program} program
		 * @param {string[]} urls - 番組、ユーザー、コミュニティのURL。
		 */
		insertProgram: function (program, urls) {
			var table = document.getElementById('programs');
			var anchor = table.querySelector(urls.map(url => `[href="${CSS.escape(program.link)}"]`).join(','));
			var previousRow;
			if (anchor) {
				// すでに同じ番組、または同じユーザーの番組、同じコミュニティの番組が追加されていれば
				previousRow = anchor.closest('[itemtype="http://schema.org/VideoObject"]');
			}
			
			var row = this.convertProgramToRow(program, previousRow);
			var tBody = table.tBodies[0];
			tBody.insertBefore(row, tBody.rows[this.getInsertPosition(table, row)]);
		},
		
		/**
		 * 指定された番組を表すtr要素を返します。
		 * @param {Program} program
		 * @param {HTMLTableRowElement} [previousRow] - 指定されていれば、その行を更新します。
		 * @return {HTMLTableRowElement}
		 */
		convertProgramToRow: function (program, previousRow) {
			var row = previousRow || document.querySelector('#programs template').content.firstElementChild.cloneNode(true);
			
			// サービス
			if (!previousRow) {
				var provider = row.querySelector('[itemprop="provider"]');
				provider.querySelector('[itemprop="name"]').value = program.service.name;
				provider.querySelector('[itemprop="url"]').href = program.service.url;
				var logo = provider.querySelector('[itemprop="logo"]');
				logo.src = program.service.icon;
				logo.alt = program.service.name;
				logo.title = program.service.name;
				if (program.service.disabledOr) {
					provider.dataset.searchCriteria = JSON.stringify(program.searchCriteria);
				}
			}
			
			// アイコン
			var image = row.querySelector('[itemprop="image"]');
			if (program.icon) {
				if (!previousRow || program.icon !== image.src) {
					image.src = program.icon;
					image.hidden = false;
				}
			} else {
				image.hidden = true;
			}
			
			// コミュ限
			var requiresSubscription = row.querySelector('[itemprop="requiresSubscription"]');
			if (program.private) {
				if (!previousRow || requiresSubscription.value === 'false') {
					requiresSubscription.value = 'true';
					requiresSubscription.textContent = _('限定公開');
				}
			} else {
				if (previousRow && requiresSubscription.value === 'true') {
					requiresSubscription.value = 'false';
					requiresSubscription.textContent = '';
				}
			}
			
			// 経過時間
			var duration = row.querySelector('[itemprop="duration"]');
			if (program.published) {
				if (!previousRow || program.published.toISOString() !== duration.dataset.start) {
					var serialized = DateUtils.getDuration(program.published);
					duration.dateTime = serialized.dateTime;
					duration.textContent = serialized.text;
					duration.dataset.start = program.published.toISOString();
					duration.hidden = false;
				}
			}
			
			// タイトル
			var name = row.querySelector('td:not([itemprop="provider"]) > [itemprop="name"]');
			if (!previousRow || program.title !== name.value) {
				name.value = program.title;
				this.setMarkedText(name.firstElementChild, program.title, program.searchCriteria || UserSettings.words);
			}
			
			// タグ
			var keywords = row.querySelector('[itemprop="keywords"]');
			var tags = program.categories ? program.categories.join(',') : '';
			if (tags !== keywords.value) {
				keywords.value = tags;
				this.setMarkedText(keywords, program.categories ? program.categories.join(' ') : '', program.searchCriteria || UserSettings.words);
			}
			
			// 配信者
			var author = row.querySelector('[itemprop="author"]');
			if (program.author) {
				var name = author.querySelector('[itemprop="name"]');
				var workLocation = author.querySelector('[itemprop="workLocation"]');
				if (!previousRow || program.author.name !== name.value
					|| program.author.url && program.author.url !== workLocation.href) {
					name.value = program.author.name;
					this.setMarkedText(workLocation, program.author.name);
					if (program.author.url) {
						workLocation.href = program.author.url;
					}
				}
			}
			
			// 説明文
			var description = row.querySelector('[itemprop="description"]');
			if (program.summary && !(previousRow && program.summary === description.value)) {
				description.value = program.summary;
				this.setMarkedText(description, program.summary, program.searchCriteria || UserSettings.words);
			}
			
			// 累計来場者数
			var userInteractionCount = row.querySelector('[itemprop="userInteractionCount"]');
			if (typeof program.visitors === 'number') {
				userInteractionCount.value = program.visitors;
				userInteractionCount.textContent = _('%d 人').replace('%d', program.visitors);
			}
			
			// コメント数
			var commentCount = row.querySelector('[itemprop="commentCount"]');
			if (typeof program.comments === 'number') {
				commentCount.value = program.comments;
				commentCount.textContent = _('%d コメ').replace('%d', program.comments);
			}
			
			// コミュニティ
			var productionCompany = row.querySelector('[itemprop="productionCompany"]');
			if (program.community) {
				productionCompany.querySelector('[itemprop="name"]').value = program.community.name;
				var url = productionCompany.querySelector('[itemprop="url"]');
				this.setMarkedText(url,  program.community.name, program.searchCriteria || UserSettings.words);
				if (program.community.url) {
					url.href = program.community.url;
				}
			}
			
			// リンク
			for (var url of row.querySelectorAll('td > [itemprop="url"], td:not([itemscope]) > [itemprop="name"] > [itemprop="url"]')) {
				url.href = program.link;
			}
			
			return row;
		},
		
		/**
		 * 検索対象だった文字列を記入します。
		 * @param {HTMLElement} target
		 * @param {string} str
		 * @param {(SearchCriteria|SearchCriteria[])} [searchCriteria] - 検索対象の項目でない (文字数制限のみを行う) 場合は省略。
		 */
		setMarkedText: function (target, str, searchCriteria) {
			/**
			 * 昇順に並んでいる、重なる部分が無い [先頭のオフセット, 末尾のオフセット] の配列。
			 * @type {number[][]}
			 */
			var offsets = searchCriteria ? WordProcessor.getMatches(str, searchCriteria) : [];
			
			/**
			 * 挿入するTextノード。
			 * @type {Text}
			 */
			var text = new Text(str);
			
			/**
			 * mark要素に内包する範囲。
			 * @type {Range[]}
			 */
			var ranges = WordProcessor.convertOffsetsToRanges(text, offsets);
			
			if (str.length > UserSettings.MAX_VISIBLE_CHARACTERS
				&& document.getElementsByName('ellipsisTooLongRSSData')[0].checked) {
				// 文字数が制限を超えており、表示文字数の制限が有効なら
				DOMTokenList.prototype.add.apply(
					target.classList,
					WordProcessor.extractTextNode(text, offsets, UserSettings.MAX_VISIBLE_CHARACTERS, UserSettings.MAX_BEFORE_CHARACTERS)
				);
				var title = WordProcessor.markMatchesAsPlainText(str, offsets);
				if (/\(Windows .+ Chrome\/.+(?!Edge\/)/.test(window.navigator.userAgent)) {
					// Windows 版の Opera、Google Chrome におけるフリーズの回避
					target.dataset.title = title;
				} else {
					target.title = title;
				}
			} else {
				delete target.dataset.title;
				target.removeAttribute('title');
			}
			
			while (target.hasChildNodes()) {
				target.firstChild.remove();
			}
			target.appendChild(text);
			for (var range of ranges) {
				range.surroundContents(document.createElement('mark'));
			}
		},
	
		/**
		 * 指定された列が昇順に並んでいれば「asc」、降順に並んでいれば「desc」を返します。
		 * @param {HTMLTableHeaderCellElement} sortedTH
		 */
		getSorting: function (sortedTH) {
			return sortedTH.getAttribute('sorted').toLowerCase().trim().split(/\s+/).indexOf('reversed') !== -1
				? 'desc'
				: 'asc';
		},
	
		/**
		 * 指定された列をキーに行を並べ替えます。
		 * @see [HTML Standard]{@link https://html.spec.whatwg.org/multipage/tables.html#table-sorting-algorithm}
		 * @param {HTMLTableHeaderCellElement} th
		 */
		sort: function (th) {
			var table = th.closest('table');
			var event = new Event('sort', {
				cancelable: true,
			});
			table.dispatchEvent(event);
			if (!event.defaultPrevented) {
				/**
				 * すでに並び替えられている列。
				 * @type {HTMLTableHeaderCellElement}
				 */
				var sortedTH = th.parentElement.querySelector('[sorted]');
				
				/**
				 * 並べ替え後、降順になるなら真。
				 * @type {boolean}
				 */
				var reversed = sortedTH === th && this.getSorting(sortedTH) === 'asc';
				
				// 行リストを配列化
				var tBody = table.tBodies[0];
				var rows = Array.from(tBody.rows);
	
				if (sortedTH === th) {
					// 選択された列が、すでに並べ替えられている列なら
					// sorted属性の設定
					sortedTH.setAttribute('sorted', (reversed ? 'reversed ' : '') + '1');
					// 並び順を反転
					rows.reverse();
				} else {
					// 他の列のsorted属性を削除
					sortedTH.removeAttribute('sorted');
	
					// sorted属性の設定
					th.setAttribute('sorted', '1');
	
					// 昇順に並び替え
					rows.sort((a, b) => this.compareRows(th, a, b));
				}
				
				// 画面に反映
				tBody.removeAttribute('aria-live');
				for (var row of rows) {
					tBody.appendChild(row);
				}
				window.setTimeout(function () {
					tBody.setAttribute('aria-live', 'polite');
				}, 0);
			}
		},
		
		/**
		 * 行の挿入位置を取得します。
		 * @param {HTMLTableElement} table
		 * @param {HTMLTableRowElement} row
		 * @returns {number} 0から始まるインデックス。
		 */
		getInsertPosition: function (table, row) {
			var insertingColumn = table.querySelector('[sorted]');
			var reversed = this.getSorting(insertingColumn) === 'desc';
			var rows = table.tBodies[0].rows;
			var insertPosition = rows.length;
			for (var comparisonRow of Array.from(rows)) {
				var result = this.compareRows(insertingColumn, comparisonRow, row);
				if (reversed ? result < 0 : 0 < result) {
					insertPosition = comparisonRow.sectionRowIndex;
					break;
				}
			}
			return insertPosition;
		},
		
		/**
		 * {@link Array#sort}の比較関数内で用いる、行と行を比較する関数
		 * @param {HTMLTableHeaderCellElement} th - キーとなるセル。
		 * @param {HTMLTableRowElement} a
		 * @param {HTMLTableRowElement} b
		 * @returns {number} a < b なら -1、a > b なら 1 を返す
		 */
		compareRows: function (th, a, b) {
			return this.getCellValue(th, a) < this.getCellValue(th, b) ? -1 : 1;
		},
		
		/**
		 * セルの値を取得します。
		 * @param {HTMLTableHeaderCellElement} th - キーとなるセル。
		 * @param {HTMLTableRowElement} row
		 * @returns {string}
		 */
		getCellValue: function (th, row) {
			var value;
			var cell = row.cells[th.cellIndex];
			var child = cell.firstElementChild;
			switch (child && child.localName) {
				case 'data':
					value = child.value;
					if (/^[0-9]+$/.test(value)) {
						value = Number.parseInt(value);
					}
					break;
				case 'time':
					value = DateUtils.parseDurationString(child.dateTime);
					break;
				default:
					value = cell.textContent;
			}
			return value;
		},
		
		/**
		 * ステータス行を除くすべての行を取得します。
		 * @param {HTMLTableElement} table
		 * @returns {HTMLTableRowElement[]}
		 * @access private
		 */
		getRows: function (table) {
			var rows = Array.from(table.querySelectorAll(':not(tfoot) > tr'));
			rows.push(table.getElementsByTagName('template')[0].content.firstElementChild);
			return rows;
		},
		
		/**
		 * 列 th を列 refTH の前に移動します。
		 * @param {HTMLTableHeaderCellElement} th
		 * @param {?(HTMLTableHeaderCellElement|number)} refTH - null の場合、末尾に移動します。
		 */
		moveColumn: function (th, refTH) {
			var targetIndex = th.cellIndex;
			var refIndex = typeof refTH === 'number' ? refTH : (refTH ? refTH.cellIndex : -1);
			for (var tr of this.getRows(th.closest('table'))) {
				tr.insertBefore(tr.cells[targetIndex], tr.cells[refIndex]);
			}
			
			// 表示する列の設定項目の並び替え
			var ul = document.getElementById('visible-columns');
			ul.insertBefore(ul.querySelector(`[value="${th.id}"]`).closest('li'), ul.children[refIndex]);
		},
		
		/**
		 * 列 th を隠します。
		 * @param {HTMLTableHeaderCellElement} th
		 */
		hideColumn: function (th) {
			if (th.getAttribute('aria-hidden') !== 'true') {
				var targetIndex = th.cellIndex;
				for (var tr of this.getRows(th.closest('table'))) {
					tr.cells[targetIndex].setAttribute('aria-hidden', 'true');
				}
			}
		},
		
		/**
		 * 列 th を表示します。
		 * @param {HTMLTableHeaderCellElement} th
		 */
		showColumn: function (th) {
			if (th.getAttribute('aria-hidden') === 'true') {
				var targetIndex = th.cellIndex;
				for (var tr of this.getRows(th.closest('table'))) {
					tr.cells[targetIndex].removeAttribute('aria-hidden');
				}
			}
		},
		
		/**
		 * 列の順番を取得します。
		 * @return {string[]}
		 */
		getColumnPositions: function () {
			return Array.from(document.querySelectorAll('#programs th')).map(function (th) {
				return th.id;
			});
		},
	
		/**
		 * 列の移動先を示すクラス名を削除します。
		 */
		removeOldClassName: function () {
			var oldRef = document.querySelector('.inserting-before, .inserting-after');
			if (oldRef) {
				oldRef.classList.remove('inserting-before', 'inserting-after');
			}
		},
		
		/**
		 * 列の順番を反映します。
		 * @param {?string} version
		 * @param {string[]} columnPositions - 指定されなかった列は末尾に並びます。
		 */
		reflectColumnPositions: function (version, columnPositions) {
			if (!version) {
				// 5.0.0 より前のバージョンの設定であれば
				columnPositions.unshift('service');
				GM_setValue('columns-position', JSON.stringify(columnPositions));
			}
			
			var tBody = document.querySelector('#programs tbody');
			tBody.removeAttribute('aria-live');
			var i = 0;
			for (var column of columnPositions) {
				this.moveColumn(document.getElementById(column), i);
				i++;
			}
			window.setTimeout(function () {
				tBody.setAttribute('aria-live', 'polite');
			}, 0);
		},
	}
	
	/**
	 * ユーザー設定値、およびその変更に関するメソッド、プロパティ。
	 */
	window.UserSettings = {
		/**
		 * 省略設定が有効の時に、一項目で表示する最大の符号単位数。
		 * @constant {number}
		 */
		MAX_VISIBLE_CHARACTERS: 60,
		
		/**
		 * 表示を省略した際に、ヒットした文字列より前に表示する最大の符号単位数。
		 * @constant {number}
		 */
		MAX_BEFORE_CHARACTERS: 3,
		
		/**
		 * 表示を省略した際に、ヒットした文字列より前に表示する最大の符号単位数。
		 * @constant {number}
		 */
		MAX_BEFORE_CHARACTERS: 3,
		
		/**
		 * 検索条件。
		 * @type {SearchCriteria[]}
		 */
		words: [],
		
		/**
		 * 検索から除外するユーザー、コミュニティ、チャンネルのURL。exclusionsFromExternalを含む。
		 * @type {string[]}
		 */
		exclusions: [],
		
		/**
		 * ユーザー設定値 NGsURI から取得した検索から除外するユーザー、コミュニティ、チャンネルのURL。
		 * @type {string[]}
		 */
		exclusionsFromExternal: [],
		
		/**
		 * ユーザー設定値をJSONファイルにエクスポートします。
		 */
		export: function () {
			var exportedValues = {};
			for (var key in UserSettings.schema.properties) {
				var property = UserSettings.schema.properties[key];
				var value;
				switch (property.type) {
					case 'string':
					case 'integer':
					case 'boolean':
						value = key === 'audioData' ? UserSettings.getLargeValue(key) : GM_getValue(key);
						break;
					case 'number':
						value = GM_getValue(key);
						if (value !== undefined && value !== null) {
							value = Number.parseFloat(value);
						}
						break;
					case 'array':
					case 'object':
						value = GM_getValue(key);
						if (value !== undefined && value !== null) {
							value = JSON.parse(value);
						}
						break;
				}
				if (value !== undefined && value !== null) {
					exportedValues[key] = value;
				}
			}
			
			document.body.insertAdjacentHTML(
				'beforeend',
				h`<a href="${window.URL.createFor(new Blob([JSON.stringify(exportedValues, null, '\t')],{ type: 'application/json' }))}" download="${Alert.ID + '.json'}" hidden=""></a>`
			);
			var anchor = document.body.lastElementChild;
			anchor.click();
			anchor.remove();
		},
		
		/**
		 * ファイルのインポート用に生成したinput要素を取り除くまでのミリ秒数。
		 * @constant {number}
		 */
		MAX_LIFETIME: 10 * DateUtils.MINUTES_TO_MILISECONDS,
		
		/**
		 * ユーザー設定値をJSONファイルからインポートします。
		 */
		import: function () {
			document.body.insertAdjacentHTML(
				'beforeend',
				h`<input type="file" accept=".json,application/json" hidden="" />`
			);
			var input = document.body.lastElementChild;
			
			input.addEventListener('change', function parse(event) {
				event.target.removeEventListener(event.type, parse);
				var file = event.target.files[0];
				if (file) {
					var reader = new FileReader();
					reader.addEventListener('load', function (event) {
						var result;
						try {
							result = JSON.parse(event.target.result);
						} catch (error) {
							window.alert(_('インポートに失敗しました。\n\nエラーメッセージ:\n%s').replace('%s', error));
						}
						
						if (result !== undefined) {
							if (Object.prototype.toString.call(result) === '[object Object]') {
								for (var key in result) {
									if (result[key] === null) {
										delete result[key];
									}
								}
							}
							var validate = jsen(UserSettings.schema, { greedy: true });
							if (validate(result)) {
								// 検索語句
								document.getElementById('searching-words').value
									= result.words ? result.words.join('\n') + '\n' : '';
								document.getElementsByName('save-searching-words')[0].click();
								
								// 除外リスト
								document.getElementById('ng-communities').value
									= result.NGs ? result.NGs.join('\n') + '\n' : '';
								document.getElementsByName('save-ng-communities')[0].click();
								
								// 外部の除外リストURL
								if (result.NGsURI) {
									GM_setValue('NGsURI', result.NGsURI);
								} else {
									GM_deleteValue('NGsURI');
								}
								
								// 行のソート
								if (!result.order) {
									result.order = UserSettings.schema.properties.order.default;
								}
								var sortedTH = document.querySelector('#programs [sorted]');
								if (result.order.name !== sortedTH.id) {
									sortedTh = document.getElementById(result.order.name);
									sortedTh.click();
								}
								if (result.order.order !== TableProcessor.getSorting(sortedTH)) {
									sortedTh.click();
								}
								
								// 列の位置
								if (result['columns-position']) {
									GM_setValue('columns-position', JSON.stringify(result['columns-position']));
									TableProcessor.reflectColumnPositions(result.version, result['columns-position']);
								} else {
									GM_deleteValue('columns-position');
									TableProcessor.reflectColumnPositions(GM_info.script.version, UserSettings.schema.properties['columns-position'].default);
								}
								
								// 表示される列
								UserSettings.showColumns(result.version, result['visible-columns']
									? result['visible-columns']
									: UserSettings.schema.properties['visible-columns'].default);
								
								// 検索対象のサービス
								UserSettings.enableServices(result.version, result['target-services'] || serviceIds);
	
								// プライベート配信・長い文字列の省略・言語
								for (var key of ['exclusionMemberOnly', 'ellipsisTooLongRSSData', 'languageFilter']) {
									var input = document.getElementsByName(key)[0];
									if (input.checked !== (key in result ? result[key] : UserSettings.schema.properties[key].default)) {
										input.click();
									}
								}
								
								// ミュート
								var alertTone = document.getElementById('alert-tone');
								if (result.audioMuted) {
									alertTone.muted = true;
								}
								
								// 音量
								if ('audioVolume' in result) {
									alertTone.volume = result.audioVolume;
								}
								
								// 音声ファイル
								var deleteSound = document.getElementsByName('delete-sound')[0];
								if (!deleteSound.hidden) {
									deleteSound.click();
								}
								if (result.audioData) {
									var audio = new Audio(result.audioData);
									audio.addEventListener('loadeddata', function () {
										if (audio.error) {
											// ブラウザが再生できないデータなら
											window.alert(_('使用中のブラウザが対応していないファイル形式のため、プロパティ %p を無視しました。').replace('%p', 'audioData'));
										} else {
											UserSettings.setAudioData(result.audioData);
										}
									});
									audio.addEventListener('error', function () {
										// ブラウザが再生できないデータなら
										window.alert(_('使用中のブラウザが対応していないファイル形式のため、プロパティ %p を無視しました。').replace('%p', 'audioData'));
									});
								}
							} else {
								window.alert(_('インポートに失敗しました。\n\nエラーメッセージ:\n%s').replace('%s', JSON.stringify(validate.errors, null, '\t')));
							}
						}
					});
					reader.readAsText(file);
				}
				input.remove();
			});
			input.click();
			
			window.setTimeout(function () {
				if (input.parentNode) {
					input.remove();
				}
			}, this.MAX_LIFETIME);
		},
		
		/**
		 * 音声ファイルを設定します。
		 * @param {string} audioData - data URL。
		 */
		setAudioData: function (audioData) {
			try {
				UserSettings.setLargeValue('audioData', audioData);
				var alertTone = document.getElementById('alert-tone');
				alertTone.src = audioData;
				alertTone.hidden = false;
				document.getElementsByName('delete-sound')[0].hidden = false;
			} catch (error) {
				if (error.name === 'QuotaExceededError' || error.name === 'NS_ERROR_DOM_QUOTA_REACHED') {
					window.alert(_('ファイルサイズが大きいため、設定に失敗しました。\n\nエラーメッセージ:\n%s').replace('%s', e));
				} else {
					throw error;
				}
			}
		},
		
		/**
		 * 検索語句文字列を {@link SearchCriteria} に変換します。
		 * @param {string[]} words - 正規化済みの文字列。
		 * @returns {SearchCriteria[]}
		 */
		parseWords: function (words) {
			return words.map(function (word) {
				var searchCriteria = {
					plus: [],
					minus: [],
				};
				for (var value of StringProcessor.unifyCases(word).split(' ')) {
					if (value.startsWith('-')) {
						searchCriteria.minus.push(value.slice(1));
					} else {
						searchCriteria.plus.push(value);
					}
				}
				return searchCriteria;
			});
		},
		
		/**
		 * バージョン5.0.0より前の除外IDをURLにします。
		 * @param {string[]} exclusions
		 * @returns {string[]}
		 */
		parseExclusions: function (exclusions) {
			return exclusions.map(this.parseExclusion).filter(url => url);
		},
		
		/**
		 * バージョン5.0.0より前の除外IDをURLにします。
		 * @param {string} exclusion
		 * @returns {?string}
		 */
		parseExclusion: function (exclusion) {
			var url = null;
			if (exclusion.startsWith('http')) {
				url = exclusion;
			} else {
				var result = /(?:co|ch)[1-9][0-9]*/.exec(exclusion);
				if (result) {
					url = 'http://com.nicovideo.jp/community/' + result[0];
				}
			}
			return url;
		},
		
		/**
		 * 検索対象のサービスを取得します。
		 * @return {string[]}
		 */
		getTargetServices: function () {
			return Array.from(document.querySelectorAll('[name="target-services"]:checked')).map(checkbox => checkbox.value);
		},
		
		/**
		 * 表示中の列を取得します。
		 * @return {string[]}
		 */
		getShownColumns: function () {
			return Array.from(document.querySelectorAll('[name="visible-columns"]:checked')).map(checkbox => checkbox.value);
		},
		
		/**
		 * 指定されたサービスを有効化し、それ以外を無効化します。
		 * @param {?string} version
		 * @param {string[]} services
		 */
		enableServices: function (version, services) {
			for (var service of document.getElementsByName('target-services')) {
				if ((services.indexOf(service.value) !== -1) !== service.checked) {
					service.click();
				}
			}
		},
		
		/**
		 * 最後に検索結果の取得に成功にした日時を表示します。
		 * @param {Service} service
		 */
		showLatestUpdatedDate: function (service) {
			var date = new Date();
			var html = h`
				<time datetime="${date.toISOString()}">
					${date.toLocaleString()}
				</time>
			`;
			document.querySelector(`[name="target-services"][value="${service.id}"]`).closest('tr').cells[1].innerHTML = html;
			document.querySelector('#programs tfoot tr:nth-of-type(1) td').innerHTML = h(_('%s 更新')).replace('%s', html);
		},
		
		/**
		 * 直近の例外を表示します。
		 * @param {Service} service
		 * @param {Error} error
		 */
		showLatestError: function (service, error) {
			var message = error.toString();
			if ('lineNumber' in error) {
				message += ` (${error.lineNumber}:${error.columnNumber})`
			}
			var html = h`<pre>${message}</pre>`;
			document.querySelector(`[name="target-services"][value="${service.id}"]`).closest('tr').cells[2].innerHTML = html;
			document.querySelector('#programs tfoot tr:nth-of-type(2) td').innerHTML = html;
		},
		
		/**
		 * 指定された列を表示し、それ以外を非表示にします。
		 * @param {?string} version
		 * @param {string[]} columns
		 */
		showColumns: function (version, columns) {
			if (!version) {
				// 5.0.0 より前のバージョンの設定であれば
				columns.unshift('service');
				if (columns.indexOf('category') === -1) {
					columns.push('category');
				}
				GM_setValue('visible-columns', JSON.stringify(columns));
			}
			
			for (var column of document.getElementsByName('visible-columns')) {
				if ((columns.indexOf(column.value) !== -1) !== column.checked) {
					column.click();
				}
			}
		},
		
		/**
		 * Firefox 23 からの仕様変更 (Bug 836263) により、UserScriptLoader.uc.js において {@link GM_setValue} で1MiB以上の
		 * データを保存できなくなったため、容量制限を超過したデータはローカルストレージに保存します。
		 * @param {string} name
		 * @param {(string|number|boolean)} value
		 * @returns {string}
		 * @see [GM_setValue size exception(1 * 1024 * 1024) · Issue #1 · Constellation/ldrfullfeed · GitHub]{@link https://github.com/Constellation/ldrfullfeed/issues/1}
		 */
		setLargeValue: function (name, value) {
			var error;
			GM_setValue(name, value);
			if (GM_getValue(name) !== value) {
				// 値が正しく設定されていなければ
				var item = this.getValuesFromLocalStorage();
				item[name] = value;
				window.localStorage.setItem(Alert.ID, JSON.stringify(item));
				GM_deleteValue(name);
			}
			return value;
		},
		
		/**
		 * {@link UserSettings.setLargeValue} で保存したデータを取得します。
		 * @param {type} name
		 * @param {*} defaultValue
		 * @returns {*}
		 */
		getLargeValue: function (name, defaultValue) {
			var value = GM_getValue(name);
			if (value === undefined || value === null) {
				var item = this.getValuesFromLocalStorage();
				value = item[name];
			}
			return value === undefined ? defaultValue : value;
		},
		
		/**
		 * {@link UserSettings.setLargeValue} で保存したデータを削除します。
		 * @param {string} name
		 */
		deleteLargeValue: function (name) {
			GM_deleteValue(name);
			var item = this.getValuesFromLocalStorage();
			delete item[name];
			window.localStorage.setItem(Alert.ID, JSON.stringify(item));
		},
		
		/**
		 * {@link UserSettings.setLargeValue} {@link UserSettings.getLargeValue} {@link UserSettings.deleteLargeValue} から利用される、すべての設定値を取得する関数。
		 * @returns {Object.<(string|number|boolean)>}
		 * @acsess private
		 */
		getValuesFromLocalStorage: function () {
			var item = window.localStorage.getItem(Alert.ID);
			if (item) {
				try {
					item = JSON.parse(item);
				} catch (e) {
					item = {};
				}
			} else {
				item = {};
			}
			return item;
		}
	};
	
	/**
	 * 文字列の処理を行うユーティリティークラス。
	 */
	window.StringProcessor = {
		/**
		 * ひらがなをカタカナに変換するときの加数。
		 * @constant {number}
		 */
		ADDEND_HIRAGANA_TO_KATAKANA: 'ァ'.charCodeAt() - 'ぁ'.charCodeAt(),
		
		/**
		 * ひらがなをカタカナに変換します。
		 * @param {string} str
		 * @returns {string}
		 */
		convertToKatakana: function (str) {
			return str.replace(
				/[ぁ-ゖ]/g,
				match => String.fromCharCode(match.charCodeAt() + this.ADDEND_HIRAGANA_TO_KATAKANA)
			);
		},
		
		/**
		 * 正規化し、連続する空白文字を半角スペースに。
		 * @param {string} str
		 * @returns {string}
		 */
		normalize: function (str) {
			return str.normalize('NFKC').replace(/\s+/g, ' ');
		},
		
		/**
		 * 文字種を統一します。
		 * @param {string} str - 正規化済みの文字列。
		 * @returns {string}
		 */
		unifyCases: function (normalizedStr) {
			return this.convertToKatakana(normalizedStr).toLocaleLowerCase();
		},
	};
	
	var WordProcessor = {
		/**
		 * OR検索を行います。
		 * @param {string} str
		 * @param {SearchCriteria[]} words
		 * @return {?SearchCriteria}
		 */
		orSearch: function (str, words) {
			var searchCriteria = null;
			for (var word of words) {
				if (this.andSearch(str, word)) {
					searchCriteria = word;
					break;
				}
			}
			return searchCriteria;
		},
		
		/**
		 * AND検索を行います。
		 * @param {string} str
		 * @param {SearchCriteria} word
		 * @return {boolean}
		 */
		andSearch: function (str, word) {
			return this.minusSearch(str, word.minus) && word.plus.every(plus => p(str).includes(plus));
		},
		
		/**
		 * マイナス検索を行います。
		 * @param {string} str
		 * @param {string[]} minusWord
		 * @return {boolean} str に minusWord のどの文字列も含まれていなければ真。
		 */
		minusSearch: function (str, minusWord) {
			return !minusWord.some(minus => p(str).includes(minus));
		},
		
		/**
		 * 検索語句が含まれる位置を取得します。
		 * @param {string} data
		 * @param {(SearchCriteria|SearchCriteria[])} searchCriteria
		 * @returns {number[][]} [先頭のオフセット, 末尾のオフセット] の配列。重なる部分は一つにまとめます。
		 */
		getMatches: function (data, searchCriteria) {
			var words = Array.isArray(searchCriteria)
				? searchCriteria.reduce((words, searchCriteria) => words.concat(searchCriteria.plus), [])
				: searchCriteria.plus;
			
			var unified = StringProcessor.unifyCases(data);
			var offsets = [];
			for (var word of words) {
				var index = unified.indexOf(word);
				if (index !== -1) {
					offsets.push([index, index + word.length]);
				}
			}
	
			// ソート
			offsets.sort((a, b) => a[0] - b[0]);
	
			// 位置が重なっていたら一つにまとめる
			for (var i = 0, l = offsets.length; i < l; i++) {
				if (offsets[i + 1] && offsets[i][1] >= offsets[i + 1][0]) {
					// 次の位置のペアが存在し、現在のペアの終了位置が次のペアの開始位置以上であれば
					// 現在のペアの終了位置を次のペアの終了位置に
					offsets[i][1] = offsets[i + 1][1];
					// 次のペアを削除
					offsets.splice(i + 1, 1);
					// 次の次のペアと重なっているかも確認
					i--;
				}
			}
	
			return offsets;
		},
		
		/**
		 * 指定された箇所の範囲を作成します。
		 * @param {Text} text
		 * @param {number[][]} offsets - 重なる部分が無い [先頭のオフセット, 末尾のオフセット] の配列。
		 * @returns {(Range[]|Text)} 
		 */
		convertOffsetsToRanges: function (text, offsets) {
			return offsets.map(function (offset) {
				var range = new Range();
				range.setStart(text, offset[0]);
				range.setEnd(text, offset[1]);
				return range;
			});
		},
		
		/**
		 * 指定された部分を括弧で囲みます。
		 * @param {string} data
		 * @param {number[][]} offsets - 昇順に並んでいる、重なる部分が無い [先頭のオフセット, 末尾のオフセット] の配列。
		 * @returns {string}
		 */
		markMatchesAsPlainText: function (data, offsets) {
			return offsets.concat().reverse().reduce(function (data, offset) {
				return data.slice(0, offset[0])
					+ _(' ❰❰%s❱❱ ').replace('%s', data.slice(offset[0], offset[1]))
					+ data.slice(offset[1]);
			}, data);
		},
		
		/**
		 * 指定された範囲が含まれるようにTextノードを切り出します。
		 * @param {Text} text
		 * @param {number[][]} offsets - 昇順に並んでいる、重なる部分が無い [先頭のオフセット, 末尾のオフセット] の配列。
		 * @param {number} maxLength - 表示する最大の符号単位数。切り取ったときにサロゲートペアが壊れるようであれば、1、2文字増やします。
		 * @param {number} beforeLength - 一致箇所より前に表示する符号単位数。切り取ったときにサロゲートペアが壊れるようであれば、1文字増やします。
		 * @returns {string[]} 先頭が切り取られていれば「ellipsis-left」、末尾が切り取られていれば「ellipsis-right」を含む配列。
		 */
		extractTextNode: function (text, offsets, maxLength, beforeLength) {
			/**
			 * 切り取り範囲。
			 * @type {Range[]}
			 */
			var trimRanges = [];
			
			/**
			 * 切り取る前の文字列。
			 * @type {number}
			 */
			var data = text.data;
			
			/**
			 * 切り取る前の文字列の符号単位数。
			 * @type {number}
			 */
			var dataLength = text.length;
			
			/**
			 * 戻り値。
			 * @type {string[]}
			 */
			var classes = [];
				
			/**
			 * 表示する部分の終了位置。
			 * @type {number}
			 */
			var viewEndOffset;
			
			if (offsets.length > 0) {
				// 検索語句が一致する箇所があれば
				/**
				 * 表示する部分の開始位置。
				 * @type {number}
				 */
				var viewStartOffset;
				
				if (offsets[offsets.length - 1][1] <= maxLength) {
					// 先頭から制限文字数の範囲内にマーク位置がすべて含まれていれば
					viewStartOffset = 0;
					viewEndOffset = maxLength;
				} else {
					viewStartOffset = offsets[0][0] - beforeLength;
					viewEndOffset = viewStartOffset + maxLength;
					if (viewStartOffset < 0) {
						// 表示する部分の開始位置が先頭を超えていれば
						viewStartOffset = 0;
					}
					if (viewEndOffset >= dataLength) {
						// 表示する部分の終了位置が末尾を超えていれば
						// 終了位置を末尾に
						viewEndOffset = dataLength;
						// 開始位置を終了位置から最大文字数分引いた位置に
						viewStartOffset = viewEndOffset - maxLength;
					}
				}
				
				if (viewStartOffset > 0) {
					// 表示部分の開始位置が先頭より後ろなら
					var charCode = data.charCodeAt(viewStartOffset);
					if (0xDC00 <= charCode && charCode <= 0xDFFF) {
						// 表示部分の先頭文字が下位サロゲートであれば
						viewStartOffset--;
					}
					if (viewStartOffset > 0) {
						var range = new Range();
						range.setStart(text, 0);
						range.setEnd(text, viewStartOffset);
						trimRanges.push(range);
						classes.push('ellipsis-left');
					}
				}
			} else {
				viewEndOffset = maxLength;
			}
			
			if (viewEndOffset < dataLength) {
				// 表示部分の終了位置が末尾より前なら
				var charCode = data.charCodeAt(viewEndOffset - 1);
				if (0xD800 <= charCode && charCode <= 0xDBFF) {
					// 表示部分の末尾文字が上位サロゲートであれば
					viewEndOffset++;
				}
				if (viewEndOffset < dataLength) {
					var range = new Range();
					range.setStart(text, viewEndOffset);
					range.setEnd(text, dataLength);
					trimRanges.push(range);
					classes.push('ellipsis-right');
				}
			}
			
			// 切り取る
			for (var range of trimRanges) {
				range.deleteContents();
			};
			
			return classes;
		},
	};
	
	/**
	 * AND検索を行う検索語句。
	 * @typedef {Object} SearchCriteria
	 * @property {string} plus - AND検索を行うキーワード。
	 * @property {string} minus - マイナス検索を行うキーワード。
	 */
	
	
	/**
	 * 検索語句にヒットする番組が見つかったとき。
	 * @event Service#ProgramEvent
	 * @type {CustomEvent}
	 * @property {string} type - 「progress」を返す。
	 * @property {Program} detail - ライブ配信番組。
	 */
	
	/**
	 * 最後のページを取得し終えたとき。OR検索できないサービスの場合は、一度の検索で複数回送出される。
	 * @event Service#LoadedEvent:load
	 * @type {CustomEvent}
	 * @property {string} type - 「load」を返す。
	 * @property {Object} detail
	 * @property {Service} detail.service - 対象のサービス。
	 * @property {Program[]} detail.programs - 今回の検索でヒットしたライブ配信番組。OR検索できないサービスの場合は、一つの検索条件にヒットした番組のみ。
	 * @property {SearchCriteria} [detail.searchCriteria] - OR検索ができないサービスにおいて、対象の検索条件。
	 */
	
	/**
	 * 配信の取得に必要な情報を返す。
	 * @callback getDetails
	 * @param {(SearchCriteria|SearchCriteria[])} [words] - OR検索が可能なら配列となる。
	 * @return {HTTPRequestInit}
	 */
	
	/**
	 * 取得した情報から番組を取り出す。
	 * @callback parseResponse
	 * @param {(Object|string)} response
	 * @param {HTTPRequestInit} details
	 * @param {SearchCriteria[]} [words]
	 * @return {(Programs|Error)} 取得に失敗している場合は例外を返す。
	 */
	
	 /**
	  * 取得した番組の一覧。
	  * @typedef {Object} Programs
	  * @property {(Object[]|NodeList|HTMLCollection)} programs - サイト独自形式の番組情報の配列。
	  * @property {HTTPRequestInit} [next] - 結果が複数ページにわたる場合に、次のページが存在すれば指定。
	  */
	
	 /**
	  * サイト独自形式の番組情報を {@link Program} に変換する関数。
	  * @callback convertIntoEntry
	  * @property {Object} programs - サイト独自形式の番組情報。
	  * @returns {Program}
	  */
	
	/**
	 * ライブ配信サービス。
	 * @class
	 * @augments EventTarget
	 * @param {Object} details
	 * @param {string} details.id - サービスを識別するID。
	 * @param {string} details.name - サイト名。
	 * @param {string} details.url - サイトのURL。
	 * @param {string} [details.icon] - サイトアイコンのURL。サイトのURLがホストとスラッシュで終わり、アイコンが /favicon.ico に配置されている場合は省略可。
	 * @param {getDetails} details.getDetails - 番組の取得に必要な情報を返す。
	 * @param {parseResponse} details.parseResponse - 取得した情報から番組を取り出す。
	 * @param {boolean} [details.disabledSearch] - 全件取得が可能な (検索ができない) サービスなら真。
	 * @param {boolean} [details.disabledOr] - OR検索ができないサービスなら真。
	 * @param {boolean} [details.disabledMinus] - マイナス検索ができないサービスなら真。
	 * @param {boolean} [details.disabledLanguageFilter] - 言語の絞り込み検索ができないサービスなら真。
	 * @param {convertIntoEntry} details.convertIntoEntry - 第1引数のサイト独自形式の番組情報を {@link Program} に変換する関数。
	 * @param {number} [details.delay=360000] - 情報を取得する間隔。ミリ秒。OR検索できないサービスの場合、各単語の検索間隔。既定値は6分。
	 * @param {number} [details.pagingDelay=10000] - 結果が複数ページにわたる場合に、次のページを取得するまでの間隔。ミリ秒。既定値は10秒。
	 * @global
	 */
	function Service (details) {
		/**
		 * 情報を取得する間隔の既定値。ミリ秒。
		 * @constant {number}
		 * @access private
		 */
		this.DEFAULT_DELAY = 6 * DateUtils.MINUTES_TO_MILISECONDS;
		
		/**
		 * 結果が複数ページにわたる場合に、次のページを取得するまでの間隔の既定値。ミリ秒。
		 * @constant {number}
		 * @access private
		 */
		this.DEFAULT_PAGING_DELAY = 1 * DateUtils.SECONDS_TO_MILISECONDS;
		
		/**
		 * サービスを識別するID。
		 * @type {string}
		 * @readonly
		 */
		this.id = details.id;
		
		/**
		 * サイト名。
		 * @type {string}
		 * @readonly
		 */
		this.name = details.name;
		
		/**
		 * サイトのURL。
		 * @type {string}
		 * @readonly
		 */
		this.url = details.url;
		
		/**
		 * サイトアイコンのURL。
		 * @type {string}
		 * @readonly
		 */
		this.icon = details.icon || details.url + 'favicon.ico';
		
		/**
		 * OR検索ができないサービスについて、検索語句の0から始まるインデックス。
		 * @type {number}
		 * @access private
		 */
		this.wordIndex = 0;
		
		/**
		 * 検索対象のサービスなら真。
		 * @type {boolean}
		 * @access private
		 */
		this.enabled = false;
		
		/**
		 * abort() メソッドを持つオブジェクト。
		 * @type {HTTPRequest}
		 * @access private
		 */
		this.request;
		
		/**
		 * タイマーID。
		 * @type {number}
		 * @access private
		 */
		this.timer;
		
		/**
		 * 現在の検索で取得した番組。
		 * @type {Program[]}
		 * @access private
		 */
		this.currentPrograms = [];
		
		/**
		 * OR検索できないサービスなら真。
		 * @type {boolean}
		 */
		this.disabledOr = details.disabledOr;
	
		/**
		 * 検索ワードにヒットした番組を繰り返し取得します。
		 * @param {HTTPRequestInit} [nextSearchInit] - 次のページの取得に必要な情報。
		 * @returns {Promise}
		 * @fires Service#ProgramEvent:progress
		 * @fires Service#LoadedEvent:load
		 * @access private
		 */
		this.getHitPrograms = function (nextSearchInit) {
			if (this.enabled) {
				/**
				 * 現在の検索ワード。接続前と取得完了時の検索ワードの変化を防ぎます。
				 * @type {SearchCriteria[]}
				 */
				var words = UserSettings.words;
				
				var searchInit = nextSearchInit
					|| details.getDetails(details.disabledOr ? words[this.wordIndex] : words);
				searchInit.timeout = Service.TIMEOUT;
				this.request = new HTTPRequest(searchInit);
				
				this.request.send().then(response => {
					var programs = details.parseResponse(response, searchInit, words);
					if (programs instanceof Error) {
						return Promise.reject(error);
					}
					
					// 番組情報の取得に成功していれば
					for (var program of Array.from(programs.programs)) {
						var entry = details.convertIntoEntry(program);
						entry.service = this;
						var hit = false;
						if (details.disabledOr) {
							// OR検索ができないサービスなら
							entry.searchCriteria = words[this.wordIndex];
						}
						if (details.disabledSearch) {
							// 全件取得が可能なサービスなら
							hit = WordProcessor.orSearch(entry.getSearchTarget(), words);
							if (hit) {
								entry.searchCriteria = hit;
							}
						} else if (details.disabledMinus) {
							// マイナス検索ができないサービスなら
							hit = WordProcessor.minusSearch(entry.getSearchTarget(), entry.searchCriteria.minus);
						} else {
							hit = true;
						}
						// 言語の絞り込み
						if (hit && document.getElementsByName('languageFilter')[0].checked && details.disabledLanguageFilter
							&& window.navigator.language.split('-')[0] !== entry.language.split('-')[0]) {
							hit = false;
						}
						if (hit) {
							entry.service = this;
							this.currentPrograms.push(entry);
							this.dispatchEvent(new CustomEvent('progress', { detail: entry }));
						}
					}
					
					if (programs.next) {
						// 次のページが存在すれば
						this.timer = window.setTimeout(this.getHitPrograms.bind(this), details.pagingDelay || this.DEFAULT_PAGING_DELAY, programs.next);
					} else {
						// 取得完了
						this.dispatchEvent(new CustomEvent('load', { detail: {
							service: this,
							programs: this.currentPrograms,
							searchCriteria: details.disabledOr ? words[this.wordIndex] : null,
						}}));
						this.currentPrograms = [];
						
						if (details.disabledOr) {
							// OR検索ができないサービスなら
							this.wordIndex++;
							if (!UserSettings.words[this.wordIndex]) {
								// 次の検索語句が存在しなければ
								this.wordIndex = 0;
							}
						}
						
						this.timer = window.setTimeout(this.getHitPrograms.bind(this), details.delay || this.DEFAULT_DELAY);
					}
				}).catch(error => {
					// 番組情報の取得に失敗していれば
					if (!(error instanceof AbortException)) {
						// 意図的な停止による例外でなければ
						console.log(error);
						UserSettings.showLatestError(this, error);
						// 一定時間後に最初から検索をやり直す
						this.timer = window.setTimeout(this.getHitPrograms.bind(this), Service.RETRY_DELAY);
					}
				});
			}
		};
		
		// EventTargetの疑似継承
		// <http://stackoverflow.com/questions/22186467/how-to-use-javascript-eventtarget#answer-24216547>
		var eventTarget = new DocumentFragment();
		for (var key in this) {
			eventTarget[key] = typeof this[key] === 'function' ? this[key].bind(this) : this[key];
		}
		for (var methodName of ['addEventListener', 'removeEventListener', 'dispatchEvent']) {
			this[methodName] = eventTarget[methodName].bind(eventTarget);
		}
	};
	
	/**
	 * タイムアウトミリ秒数。
	 * @constant {number}
	 */
	Service.TIMEOUT = 10 * DateUtils.SECONDS_TO_MILISECONDS;
	
	/**
	 * メンテナンス中など、サーバー側のエラーが発生した場合に再度取得する間隔。ミリ秒。
	 * @constant {number}
	 */
	Service.RETRY_DELAY = 15 * DateUtils.MINUTES_TO_MILISECONDS;
	
	/**
	 * 検索を開始します。
	 */
	Service.prototype.start = function () {
		if (!this.enabled) {
			// 検索が無効であれば
			this.enabled = true;
			this.getHitPrograms();
		}
	};
	
	/**
	 * 検索を停止します。
	 */
	Service.prototype.stop = function () {
		this.enabled = false;
		if (this.request) {
			this.request.abort();
		}
		window.clearTimeout(this.timer);
	};
	
	/**
	 * 検索語句を読み込み直して最初から検索しなおします。
	 */
	Service.prototype.reset = function () {
		if (this.disabledOr) {
			this.wordIndex = 0;
			TableProcessor.removeProgramsWithService(this);
		}
	};
	
	/**
	 * 1つのライブ配信番組。
	 * @param {string} link - 配信のURL。
	 * @param {string} title - 配信のタイトル。
	 * @param {Object} otherDetails
	 * @param {string} [otherDetails.icon] - コミュニティやチャンネルなどのアイコンのURL。取得できなければユーザーのアイコンのURL。それも取得できなければ配信のアイコンのURL。
	 * @param {boolean} [otherDetails.private] - プライベート配信であれば真。
	 * @param {Date} [otherDetails.published] - 配信開始日時。
	 * @param {Person} [otherDetails.author] - 配信者。
	 * @param {string[]} [otherDetails.categories] - 配信のタグ。カテゴリを含みます。
	 * @param {string} [otherDetails.summary] - 配信の説明文。
	 * @param {number} [otherDetails.visitors] - 累計視聴者数。取得できなければ最高同時視聴者数。それも取得できなければ現在の視聴者数。
	 * @param {number} [otherDetails.comments] - コメントの数。
	 * @param {Person} [otherDetails.community] - コミュニティやチャンネルなど。
	 * @param {string} [otherDetails.language] - 言語。
	 * @param {SearchCriteria} [details.searchCriteria] - 検索条件。
	 */
	window.Program = function (link, title, otherDetails, searchCriteria) {
		this.link = link;
		this.title = StringProcessor.normalize(title);
		for (var key in otherDetails) {
			var value = otherDetails[key];
			if (value !== undefined && value !== null) {
				switch (key) {
					case 'summary':
						value = StringProcessor.normalize(value);
						break;
					case 'categories':
						value = value.map(StringProcessor.normalize);
						break;
					case 'community':
						value.name = StringProcessor.normalize(value.name);
						break;
				}
				this[key] = value;
			}
		}
		this.searchCriteria = searchCriteria;
		
		/**
		 * @type {Service}
		 */
		this.service;
	};
	
	/**
	 * 配信者、またはコミュニティ。
	 * @typedef {Object} Person
	 * @property {string} name - 名前。
	 * @property {string} [url] - URL。
	 */
	
	/**
	 * 検索対象を取得します。
	 * @return {string} 文字種を統一した文字列。
	 */
	Program.prototype.getSearchTarget = function () {
		var target = [this.title];
		if (this.categories) {
			target.concat(this.categories);
		}
		if (this.summary) {
			target.push(this.summary);
		}
		if (this.community) {
			target.push(this.community.name);
		}
		return StringProcessor.unifyCases(target.join(' '));
	};
}

/**
 * 挿入された節の親節が、目印となる節の親節か否かを返すコールバック関数。
 * @callback isTargetParent
 * @param {(Document|Element)} parent
 * @returns {boolean}
 */

/**
 * 挿入された節が、目印となる節か否かを返すコールバック関数。
 * @callback isTarget
 * @param {(DocumentType|Element)} target
 * @returns {boolean}
 */

/**
 * 目印となる節が文書に存在するか否かを返すコールバック関数。
 * @callback existsTarget
 * @returns {boolean}
 */

/**
 * 目印となる節が挿入された直後に関数を実行する。
 * @param {Function} main - 実行する関数。
 * @param {isTargetParent} isTargetParent
 * @param {isTarget} isTarget
 * @param {existsTarget} existsTarget
 * @param {Object} [callbacksForFirefox]
 * @param {isTargetParent} [callbacksForFirefox.isTargetParent] - Firefoxにおける{@link isTargetParent}。
 * @param {isTarget} [callbacksForFirefox.isTarget] - Firefoxにおける{@link isTarget}。
 * @param {number} [timeoutSinceStopParsingDocument=0] - DOM構築完了後に監視を続けるミリ秒数。
 * @version 2014-11-25
 * @global
 */
function startScript(main, isTargetParent, isTarget, existsTarget) {
	/**
	 * {@link checkExistingTarget}で{@link startMain}を実行する間隔(ミリ秒)。
	 * @constant {number}
	 */
	var INTERVAL = 10;
	/**
	 * {@link checkExistingTarget}で{@link startMain}を実行する回数。
	 * @constant {number}
	 */
	var LIMIT = 500;

	/**
	 * 実行済みなら真。
	 * @type {boolean}
	 */
	var alreadyCalled = false;

	// 指定した節が既に存在していれば、即実行
	startMain();
	if (alreadyCalled) {
		return;
	}

	// FirefoxのMutationObserverは、HTMLのDOM構築に関して要素をまとめて挿入したと見なすため、isTargetParent、isTargetを変更
	var callbacksForFirefox = arguments[4];
	if (callbacksForFirefox && typeof MozSettingsEvent !== 'undefined') {
		isTargetParent = callbacksForFirefox.isTargetParent || isTargetParent;
		isTarget = callbacksForFirefox.isTarget || isTarget;
	}

	var observer = new MutationObserver(mutationCallback);
	observer.observe(document, {
		childList: true,
		subtree: true,
	});

	var timeoutSinceStopParsingDocument = arguments[5] || 0;
	if (document.readyState === 'complete') {
		// DOMの構築が完了していれば
		onDOMContentLoaded();
	} else {
		document.addEventListener('DOMContentLoaded', onDOMContentLoaded);
	}

	/**
	 * {@link startMain}を実行し、スクリプトが開始されていなければさらに{@link timeoutSinceStopParsingDocument}ミリ秒待機し、
	 * スクリプトが開始されていなければ{@link stopObserving}を実行する。
	 */
	function onDOMContentLoaded() {
		startMain();
		if (timeoutSinceStopParsingDocument === 0) {
			if (!alreadyCalled) {
				stopObserving();
			}
		} else {
			window.setTimeout(function () {
				if (!alreadyCalled) {
					stopObserving();
				}
			}, timeoutSinceStopParsingDocument);
		}
	}

	/**
	 * 目印となる節が挿入されたら、監視を停止し、{@link checkExistingTarget}を実行する。
	 * @param {MutationRecord[]} mutations - A list of MutationRecord objects.
	 * @param {MutationObserver} observer - The constructed MutationObserver object.
	 */
	function mutationCallback(mutations, observer) {
		for (var mutation of mutations) {
			var target = mutation.target;
			if (target.nodeType === Node.ELEMENT_NODE && isTargetParent(target)) {
				// 子が追加された節が要素節で、かつその節についてisTargetParentが真を返せば
				for (var addedNode of mutation.addedNodes) {
					if (addedNode.nodeType === Node.ELEMENT_NODE && isTarget(addedNode)) {
						// 追加された子が要素節で、かつその節についてisTargetが真を返せば
						observer.disconnect();
						checkExistingTarget(0);
						return;
					}
				}
			}
		}
	}

	/**
	 * {@link startMain}を実行し、スクリプトが開始されていなければ再度実行。
	 * @param {number} count - {@link startMain}を実行した回数。
	 */
	function checkExistingTarget(count) {
		startMain();
		if (!alreadyCalled && count < LIMIT) {
			window.setTimeout(checkExistingTarget, INTERVAL, count + 1);
		}
	}

	/**
	 * 指定した節が存在するか確認し、存在すれば{@link stopObserving}を実行しスクリプトを開始。
	 */
	function startMain() {
		if (!alreadyCalled && existsTarget()) {
			stopObserving();
			main();
		}
	}

	/**
	 * 監視を停止する。
	 */
	function stopObserving() {
		alreadyCalled = true;
		if (observer) {
			observer.disconnect();
		}
		document.removeEventListener('DOMContentLoaded', onDOMContentLoaded);
	}
}

/**
 * 国際化・地域化関数を定義します。
 */
function defineGettext() {
	/**
	 * 以下のような形式の翻訳リソース。すべての言語について、msgidは欠けていないものとする。
	 * {@link Gettext.DEFAULT_LOCALE}のリソースを必ず含む。{@link Gettext.ORIGINAL_LOCALE}のリソースは無視される。
	 * {
	 *     'IETF言語タグ': {
	 *         '翻訳前 (msgid)': '翻訳後 (msgstr)',
	 *         ……
	 *     },
	 *     ……
	 * }
	 * @typedef {Object} LocalizedTexts
	 */

	/**
	 * i18n。
	 * @version 2014-07-10
	 */
	window.Gettext = {
		/**
		 * 翻訳対象文字列 (msgid) の言語。IETF言語タグの「language」サブタグ。
		 * @constant {string}
		 */
		ORIGINAL_LOCALE: 'ja',

		/**
		 * クライアントの言語の翻訳リソースが存在しないとき、どの言語に翻訳するか。IETF言語タグの「language」サブタグ。
		 * @constant {string}
		 */
		DEFAULT_LOCALE: 'en',

		/**
		 * 翻訳リソースを追加する。
		 * @param {LocalizedTexts} localizedTexts
		 */
		setLocalizedTexts: function (localizedTexts) {
			this.multilingualLocalizedTexts = localizedTexts;
		},

		/**
		 * クライアントの言語を設定する。
		 * @param {string} clientLang - IETF言語タグ(「language」と「language-REGION」にのみ対応)。
		 */
		setLocale: function (clientLang) {
			var splitedClientLang = clientLang.split('-', 2);
			this.language = splitedClientLang[0].toLowerCase();
			this.langtag = this.language + (splitedClientLang[1] ? '-' + splitedClientLang[1].toUpperCase() : '');
			if (this.language === 'ja') {
				// ja-JPをjaと同一視
				this.langtag = this.language;
			}
		},

		/**
		 * テキストをクライアントの言語に変換する。
		 * @param {string} message - 翻訳前。
		 * @returns {string} 翻訳後。
		 */
		gettext: function (message) {
			// クライアントの言語が翻訳元の言語なら、そのまま返す
			return this.langtag === this.ORIGINAL_LOCALE && message
					// クライアントの言語の翻訳リソースが存在すれば、それを返す
					|| this.langtag in this.multilingualLocalizedTexts && this.multilingualLocalizedTexts[this.langtag][message]
					// 地域下位タグを取り除いた言語タグの翻訳リソースが存在すれば、それを返す
					|| this.language in this.multilingualLocalizedTexts && this.multilingualLocalizedTexts[this.language][message]
					// 既定言語の翻訳リソースが存在すれば、それを返す
					|| this.DEFAULT_LOCALE in this.multilingualLocalizedTexts && this.multilingualLocalizedTexts[this.DEFAULT_LOCALE][message]
					// そのまま返す
					|| message;
		},

		/**
		 * クライアントの言語。{@link Gettext.setLocale}から変更される。
		 * @type {string}
		 * @access private
		 */
		langtag: 'ja',

		/**
		 * クライアントの言語のlanguage部分。{@link Gettext.setLocale}から変更される。
		 * @type {string}
		 * @access private
		 */
		language: 'ja',

		/**
		 * 翻訳リソース。{@link Gettext.setLocalizedTexts}から変更される。
		 * @type {LocalizedTexts}
		 * @access private
		 */
		multilingualLocalizedTexts: {},
	};
	window._ = Gettext.gettext.bind(Gettext);
}

/**
 * ライブラリの定義、ECMAScriptとWHATWG仕様のPolyfill、prototype汚染回避など。
 */
function polyfill() {
	/**
	 * DOM関連のメソッド等。
	 * @version 1.0.0
	 */
	window.DOMUtils = {
		/**
		 * Atom名前空間。
		 * @constant {string}
		 */
		ATOM_NAMESPACE: 'http://www.w3.org/2005/Atom',
		
		/**
		 * XMLの特殊文字を文字参照に置換します。
		 * @param {string} str - プレーンな文字列。
		 * @returns {string} HTMLとして扱われる文字列。
		 */
		convertSpecialCharactersToCharacterReferences: function (str) {
			return String(str).replace(/[&<>"']/g, function (specialCharcter) {
				return '&#x' + specialCharcter.charCodeAt(0).toString(16) + ';'
			});
		},
		
		/**
		 * テンプレート文字列のタグとして用いることで、式内にあるXMLの特殊文字を文字参照に置換します。
		 * @param {string[]} htmlTexts
		 * @param {...string} plainText
		 * @returns {string} HTMLとして扱われる文字列。
		 */
		escapeTemplateStrings() {
			return String.raw.apply(null, Array.prototype.map.call(arguments, function (plainText, i) {
				return i === 0 ? plainText : this.convertSpecialCharactersToCharacterReferences(plainText);
			}, this));
		},
	};
	
	/**
	 * {@link DOMUtils.escapeTemplateStrings}、または {@link DOMUtils.convertSpecialCharactersToCharacterReferences} の短縮表記。
	 */
	window.h = function () {
		return Array.isArray(arguments[0])
			? DOMUtils.escapeTemplateStrings.apply(DOMUtils, arguments)
			: DOMUtils.convertSpecialCharactersToCharacterReferences(arguments[0]);
	};
	
	/**
	 * HTTPリクエスト。
	 * @param {HTTPRequestInit} init
	 * @constructor
	 * @version 1.0.0
	 */
	window.HTTPRequest = function (init) {
		/** @access private */
		this.details = init;
		
		/**
		 * @param {Object} client
		 * @param {Function} resolve
		 * @param {Function} reject
		 * @param {string} [responseType]
		 * @access private
		 */
		this.onload = function (client, resolve, reject, responseType) {
			if (client.status === 200) {
				var response;
				var errorMessage;
				if (client instanceof XMLHttpRequest) {
					if (client.response) {
						resolve(client.response);
					} else {
						reject(new SyntaxError('Parsing HTTP response body was failed.'));
					}
				} else if (client.responseText === '') {
					reject(new SyntaxError('HTTP response body was empty.'));
				} else {
					switch (this.details.responseType) {
						case 'json':
							resolve(JSON.parse(client.responseText));
							break;
						
						case 'document':
							var result = /^content-type\s*:\s*(text\/html|text\/xml|application\/xml|\S+\+xml)(?:;|\s|$)/im.exec(client.responseHeaders);
							if (result) {
								result = new DOMParser().parseFromString(
									client.responseText,
									result[1] === 'text/html' ? 'text/html' : 'application/xml'
								);
								
								var parsererror = result.getElementsByTagName('parsererror')[0];
								if (parsererror) {
									reject(new SyntaxError(parsererror.textContent));
								} else {
									if (result.head) {
										result.head.insertAdjacentHTML('beforeend', h`<base href="${client.finalUrl}" />`);
									}
									resolve(result);
								}
							}
							break;
						
						case 'text':
							resolve(client.responseText);
							break;
					}
				}
			} else {
				reject(new ErrorStatusException(client.status));
			}
		};
		
		/**
		 * @param {Function} reject
		 * @access private
		 */
		this.ontimeout = function (reject) {
			reject(new TimeoutException());
		};
		
		/**
		 * @param {Function} reject
		 * @access private
		 */
		this.onabort = function (reject) {
			reject(new AbortException());
		};
	};
	
	/**
	 * HTTPリクエストに必要な情報。
	 * @typedef {Object} HTTPRequestInit
	 * @see [GM_xmlhttpRequest - GreaseSpot Wiki]{@link http://wiki.greasespot.net/GM_xmlhttpRequest#Arguments}
	 * @see [Fetch Standard (日本語訳)]{@link http://www.hcn.zaq.ne.jp/___/WEB/Fetch-ja.html#requestinit}
	 * @property {string} method - 「GET」「POST」のいずれか。
	 * @property {string} url
	 * @property {string} responseType - 「document」「json」「text」のいずれか。
	 * @property {Object.<string>} headers
	 * @property {number} [timeout] - ミリ秒。
	 * @property {(string|Object)} [data]
	 * @property {string} mode - {@link XMLHttpRequest} を使用するなら「cors」、{@link GM_xmlhttpRequest} を使用する必要があれば「no-cors」を指定する。
	 */
	
	/**
	 * リクエストを送信します。
	 * @returns {Promise.<string|Object>}
	 */
	HTTPRequest.prototype.send = function () {
		return new Promise((resolve, reject) => {
			if (this.details.mode === 'cors') {
				/**
				 * abort() メソッドを持つオブジェクト。
				 * @type {Object}
				 */
				this.client = new XMLHttpRequest();
				this.client.open(this.details.method, this.details.url);
				this.client.responseType = this.details.responseType;
				this.client.addEventListener('load', event => {
					this.onload(event.target, resolve, reject);
				});
				this.client.addEventListener('timeout', () => {
					this.ontimeout(reject);
				});
				this.client.addEventListener('abort', () => {
					this.onabort(reject);
				});
				if (this.details.headers) {
					for (var name in this.details.headers) {
						this.client.setRequestHeader(name, this.details.headers[name]);
					}
				}
				if (this.details.timeout) {
					this.client.timeout = this.details.timeout;
				}
				if (this.details.data && typeof this.details.data === 'object') {
					this.client.setRequestHeader('content-type', 'application/json');
					this.client.send(JSON.stringify(this.details.data));
				} else {
					this.client.send(this.details.data);
				}
			} else {
				var details = {};
				for (var key in this.details) {
					details[key] = this.details[key];
				}
				delete details.responseType;
				details.onload = responseObject => {
					this.onload(responseObject, resolve, reject, details.responseType);
				};
				details.ontimeout = () => {
					this.ontimeout(reject);
				};
				details.onabort = () => {
					this.onabort(reject);
				};
				this.client = GM_xmlhttpRequest(details);
			}
		});
	};
	
	/**
	 * リクエストを取り消します。
	 */
	HTTPRequest.prototype.abort = function () {
		if (this.client) {
			this.client.abort();
		}
	};
	
	/**
	 * @param {string} message
	 * @constructor
	 * @see [Custom Error Types | Error - JavaScript | MDN]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#Custom_Error_Types}
	 */
	window.ConnectionException = function (message) {
		this.name = 'ConnectionException';
		this.message = message || 'Connection exception occured.';
		this.stack = (new Error()).stack;
	}
	ConnectionException.prototype = Object.create(Error.prototype);
	ConnectionException.prototype.constructor = ConnectionException;
	
	/**
	 * @param {string} message
	 * @constructor
	 */
	window.TimeoutException = function (message) {
		this.name = 'TimeoutException';
		this.message = message || 'Connection timed out.';
		this.stack = (new ConnectionException()).stack;
	}
	TimeoutException.prototype = Object.create(ConnectionException.prototype);
	TimeoutException.prototype.constructor = TimeoutException;
	
	/**
	 * @param {number} code - HTTPステータスコード。
	 * @param {string} [message]
	 * @constructor
	 */
	window.ErrorStatusException = function (code, message) {
		this.name = 'ErrorStatusException';
		this.message = message || ('HTTP status-code was %s.').replace('%s', code);
		this.stack = (new ConnectionException()).stack;
		/**
		 * HTTPステータスコード。
		 * @type {number}
		 */
		this.code = code;
	}
	ErrorStatusException.prototype = Object.create(ConnectionException.prototype);
	ErrorStatusException.prototype.constructor = ErrorStatusException;
	
	/**
	 * @param {string} message
	 * @constructor
	 */
	window.AbortException = function (message) {
		this.name = 'AbortException';
		this.message = message || 'Request was aborted.';
		this.stack = (new ConnectionException()).stack;
	}
	AbortException.prototype = Object.create(ConnectionException.prototype);
	AbortException.prototype.constructor = AbortException;

	/**
	 * 時間に関するユーティリティクラス。
	 * @version 1.0.0
	 */
	window.DateUtils = {
		/**
		 * 日をミリ秒に変換するときの乗数。
		 * @constant {number}
		 */
		DAYS_TO_MILISECONDS: 24 * 60 * 60 * 1000,
		
		/**
		 * 時間をミリ秒に変換するときの乗数。
		 * @constant {number}
		 */
		HOURS_TO_MILISECONDS: 60 * 60 * 1000,
		
		/**
		 * 分をミリ秒に変換するときの乗数。
		 * @constant {number}
		 */
		MINUTES_TO_MILISECONDS: 60 * 1000,
		
		/**
		 * 秒をミリ秒に変換するときの乗数。
		 * @constant {number}
		 */
		SECONDS_TO_MILISECONDS: 1000,
		
		/**
		 * 現在時刻から指定した時刻を引いた差を返します。
		 * @param {Date} value
		 * @returns {Object.<string>} dateTimeプロパティに ISO 8601 形式の文字列 (負になる場合は「PT0S」)、textプロパティに「○時間○分」のような形式の文字列。
		 */
		getDuration: function (value) {
			var milliseconds = Date.now() - value.getTime();
			var minutes = Math.round(milliseconds / this.MINUTES_TO_MILISECONDS);
			var sign = minutes >= 0 ? 1 : -1;
			minutes = Math.abs(minutes);
			var hours = Math.floor(minutes / 60);
			minutes = minutes % 60;
			return {
				dateTime: `PT${sign === -1 ? 0 : milliseconds / this.SECONDS_TO_MILISECONDS}S`,
				text: hours ? _('%d 時間 %u 分').replace('%d', sign * hours).replace('%u', minutes) : _('%d 分').replace('%d', sign * minutes),
			};
		},
		
		/**
		 * ISO 8601 形式の文字列からミリ秒数を取得します。
		 * @param {string} dateTime
		 * @returns {?number}
		 */
		parseDurationString: function (dateTime) {
			var duration = null;
			var result = /^P([0-9]+D)?(?:T([0-9]+H)?([0-9]+M)?([0-9]+(?:\.[0-9]{0,3})?S)?)?$/.exec(dateTime);
			if (result) {
				duration = 0;
				if (result[1]) {
					duration += Number.parseInt(result[1]) * this.DAYS_TO_MILISECONDS;
				}
				if (result[2]) {
					duration += Number.parseInt(result[2]) * this.HOURS_TO_MILISECONDS;
				}
				if (result[3]) {
					duration += Number.parseInt(result[3]) * this.MINUTES_TO_MILISECONDS;
				}
				if (result[4]) {
					duration += Number.parseFloat(result[4]) * this.SECONDS_TO_MILISECONDS;
				}
			}
			return duration;
		},
		
		/**
		 * 日本標準時の時刻文字列 (hh:mm) をDateインスタンスに変換します。
		 * @param {string} time - 過去を表す時刻。
		 * @returns {Date}
		 */
		parseJSTString: function (time) {
			/**
			 * 日本標準時 (+09:00) の時間帯 (UTCとの差)。ミリ秒。
			 * @constant {number}
			 */
			var TIMEZONE_JST = 9 * this.HOURS_TO_MILISECONDS;
			
			var hoursAndMinutes = time.split(':');
			var date = new Date();
			date.setUTCHours(Number.parseInt(hoursAndMinutes[0]), Number.parseInt(hoursAndMinutes[1]), 0, 0);
			date.setTime(date.getTime() - TIMEZONE_JST);
			if (date.getTime() > Date.now()) {
				date.setTime(date.getTime() - this.DAYS_TO_MILISECONDS);
			}
			return date;
		},
	};
	
	// For Firefox
	if (typeof cloneInto !== 'undefined' && typeof Proxy !== 'undefined' /* exclude Tampermonkey*/) {
		CustomEvent = new Proxy(CustomEvent, { construct: function (target, args) {
			if (args.length >= 2
				&& typeof args[1] === 'object' &&  typeof args[1].detail === 'object' && args[1].detail !== null) {
				args[1].detail = cloneInto(args[1].detail, window, {
					cloneFunctions: true,
					wrapReflectors: true,
				});
			}
			return eval('new target(...args)');
		} });
	}
	
	/**
	 * Webページ側のコンテキストでなければprototype拡張ができない問題に対処します。
	 * @param {*} value - prototype拡張を行ったインターフェースのインスタンス。
	 * @returns {*} FirefoxであればProxyインスタンス。
	 * @version 1.0.0
	 */
	window.p = function (value) {
		var element;
		if (typeof MozSettingsEvent !== 'undefined') {
			switch (typeof value) {
				case 'string':
					value = new String(value);
					break;
				case 'number':
					value = new Number(value);
					break;
				case 'boolean':
					value = new Boolean(value);
			}
			value = new Proxy(value, {
				get: function (target, name) {
					var value;
					if (name === 'dataset' && target instanceof HTMLElement) {
						// DOMStringMap deleter の実装
						element = target;
						value = new Proxy(target.dataset, {
							deleteProperty: function (dataset, name) {
								element.removeAttribute(
									'data-' + name.replace(/[A-Z]/g, upper => '-' + upper.toLowerCase())
								);
								return true;
							},
						});
					} else if (name in target) {
						value = target[name];
					} else {
						for (var proto = target.__proto__; proto; proto = proto.__proto__) {
							if (name in proto) {
								var descriptor = Object.getOwnPropertyDescriptor(proto, name);
								value = descriptor.value || descriptor.get.call(target);
								break;
							}
						}
					}
					return typeof value === 'function' ? value.bind(target) : value;
				},
				set: function (target, name, value) {
					if (name in target) {
						target[name] = value;
					} else {
						var set = false;
						for (var proto = target.__proto__; proto; proto = proto.__proto__) {
							if (name in proto) {
								Object.getOwnPropertyDescriptor(proto, name).set.call(target);
								set = true;
								break;
							}
						}
						if (!set) {
							target[name] = value;
						}
					}
					return true;
				},
			});
		}
		return value;
	};
	
	// Polyfill for Firefox, Opera, and Google Chrome
	if (!('createFor' in URL)) {
		/** @see [Bug 1062917 - Implement URL.createFor]{@link https://bugzilla.mozilla.org/show_bug.cgi?id=1062917} */
		Object.defineProperty(URL, 'createFor', {
			writable: true,
			enumerable: false,
			configurable: true,
			value: function (blob) {
				/**
				 * 分をミリ秒に変換するときの乗数。
				 * @constant {number}
				 */
				var MINUTES_TO_MILISECONDS = 60 * 1000;
				
				/**
				 * Blob URL を自動破棄するまでのミリ秒数。
				 * @constant {number}
				 */
				var MAX_LIFETIME = 10 * MINUTES_TO_MILISECONDS;
				
				var url = this.createObjectURL(blob);
				window.setTimeout(this.revokeObjectURL.bind(this), MAX_LIFETIME, url);
				return url;
			},
		});
	}
	
	// Polyfill for Firefox 38 ESR, Opera, and Google Chrome
	try {
		new DragEvent('drag');
	} catch (e) {
		/**
		 * @constructor
		 * @param {string} type
		 * @param {DragEventInit} [eventInitDict]
		 * @see [Bug 1135627 - DragEvent constructor throws "TypeError: Illegal constructor."]{@link https://bugzilla.mozilla.org/show_bug.cgi?id=1135627}
		 * @see [Issue 498504 - chromium - Implement DragEvent and move MouseEvent.dataTransfer to DragEvent]{@link https://code.google.com/p/chromium/issues/detail?id=498504}
		 * @see [The DragEvent interface]{@link https://html.spec.whatwg.org/multipage/interaction.html#the-dragevent-interface}
		 * @name DragEvent
		 */
		Object.defineProperty(window, 'DragEvent', {
			writable: true,
			enumerable: false,
			configurable: true,
			value: MouseEvent,
		});
	}
	
	// Polyfill for Firefox 38 ESR
	if (!''.includes) {
		/** @see [Bug 1102219 – Rename String.prototype.contains to String.prototype.includes]{https://bugzilla.mozilla.org/show_bug.cgi?id=1102219} */
		Object.defineProperty(String.prototype, 'includes', {
			writable: true,
			enumerable: false,
			configurable: true,
			value: String.prototype.contains,
		});
	}

	// Polyfill for Opera and Google Chrome
	// prototype汚染が行われる Prototype JavaScript Framework (prototype.js) 1.6.0.3 のバグを修正
	if (window.chrome) {
		Object.defineProperty(Array, 'from', { writable: false });
		Object.defineProperty(Object, 'extend', {
			writable: false,
			value: function (destination, source) {
				for (var property in source) {
					if (property in destination || property === 'toJSON') {
						continue;
					}
					destination[property] = source[property];
				}
				return destination;
			},
		});
	}
	
	if (!(Symbol.iterator in NodeList.prototype)) {
		Object.defineProperties(NodeList.prototype, /** @lends NodeList# */ {
			/**
			 * @see [Issue 401699 - chromium - Add iterator support to NodeList and friends]{@link https://code.google.com/p/chromium/issues/detail?id=401699}
			 * @version 1.1.0
			 * @returns {Iterator.<Array.<number, Node>>}
			 * @name NodeList#@@iterator
			 */
			[Symbol.iterator]: {
				writable: true,
				enumerable: false,
				configurable: true,
				value: function* () {
					for (var i = 0, l = this.length; i < l; i++) {
						yield this[i];
					}
				}
			},
			/**
			 * @returns {Iterator.<Array.<number, Node>>}
			 * @function
			 */
			entries: {
				writable: true,
				enumerable: false,
				configurable: true,
				value: function* () {
					for (var i = 0, l = this.length; i < l; i++) {
						yield [i, this[i]];
					}
				}
			},
			/**
			 * @returns {Iterator.<number>}
			 * @function
			 */
			keys: {
				writable: true,
				enumerable: false,
				configurable: true,
				value: function* () {
					for (var i = 0, l = this.length; i < l; i++) {
						yield i;
					}
				}
			},
			/**
			 * @returns {Iterator.<Node>}
			 * @function
			 */
			values: {
				writable: true,
				enumerable: false,
				configurable: true,
				value: function* () {
					for (var i = 0, l = this.length; i < l; i++) {
						yield this[i];
					}
				}
			},
		});
	}
	
	if (!CSS.escape) {
		var global = window;
		/*! https://mths.be/cssescape v1.1.0 by @mathias | MIT license */
		;(function(root) {
		
			if (!root.CSS) {
				root.CSS = {};
			}
		
			var CSS = root.CSS;
		
			var InvalidCharacterError = function(message) {
				this.message = message;
			};
			InvalidCharacterError.prototype = new Error;
			InvalidCharacterError.prototype.name = 'InvalidCharacterError';
		
			if (!CSS.escape) {
				// https://drafts.csswg.org/cssom/#serialize-an-identifier
				CSS.escape = function(value) {
					var string = String(value);
					var length = string.length;
					var index = -1;
					var codeUnit;
					var result = '';
					var firstCodeUnit = string.charCodeAt(0);
					while (++index < length) {
						codeUnit = string.charCodeAt(index);
						// Note: there’s no need to special-case astral symbols, surrogate
						// pairs, or lone surrogates.
		
						// If the character is NULL (U+0000), then throw an
						// `InvalidCharacterError` exception and terminate these steps.
						if (codeUnit == 0x0000) {
							throw new InvalidCharacterError(
								'Invalid character: the input contains U+0000.'
							);
						}
		
						if (
							// If the character is in the range [\1-\1F] (U+0001 to U+001F) or is
							// U+007F, […]
							(codeUnit >= 0x0001 && codeUnit <= 0x001F) || codeUnit == 0x007F ||
							// If the character is the first character and is in the range [0-9]
							// (U+0030 to U+0039), […]
							(index == 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
							// If the character is the second character and is in the range [0-9]
							// (U+0030 to U+0039) and the first character is a `-` (U+002D), […]
							(
								index == 1 &&
								codeUnit >= 0x0030 && codeUnit <= 0x0039 &&
								firstCodeUnit == 0x002D
							)
						) {
							// https://drafts.csswg.org/cssom/#escape-a-character-as-code-point
							result += '\\' + codeUnit.toString(16) + ' ';
							continue;
						}
		
						if (
							// If the character is the first character and is a `-` (U+002D), and
							// there is no second character, […]
							index == 0 &&
							length == 1 &&
							codeUnit == 0x002D
						) {
							result += '\\' + string.charAt(index);
							continue;
						}
		
						// If the character is not handled by one of the above rules and is
						// greater than or equal to U+0080, is `-` (U+002D) or `_` (U+005F), or
						// is in one of the ranges [0-9] (U+0030 to U+0039), [A-Z] (U+0041 to
						// U+005A), or [a-z] (U+0061 to U+007A), […]
						if (
							codeUnit >= 0x0080 ||
							codeUnit == 0x002D ||
							codeUnit == 0x005F ||
							codeUnit >= 0x0030 && codeUnit <= 0x0039 ||
							codeUnit >= 0x0041 && codeUnit <= 0x005A ||
							codeUnit >= 0x0061 && codeUnit <= 0x007A
						) {
							// the character itself
							result += string.charAt(index);
							continue;
						}
		
						// Otherwise, the escaped character.
						// https://drafts.csswg.org/cssom/#escape-a-character
						result += '\\' + string.charAt(index);
		
					}
					return result;
				};
			}
		
		}(typeof global != 'undefined' ? global : this));
	}


/*
 * jsen
 * https://github.com/bugventure/jsen
 *
 * Copyright (c) 2015 Veli Pehlivanov <bugventure@gmail.com>
 * Licensed under the MIT license <http://opensource.org/licenses/mit>
 */

(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.jsen = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
module.exports = require('./lib/jsen.js');
},{"./lib/jsen.js":5}],2:[function(require,module,exports){
'use strict';

function type(obj) {
    var str = Object.prototype.toString.call(obj);
    return str.substr(8, str.length - 9).toLowerCase();
}

function deepEqual(a, b) {
    var keysA = Object.keys(a).sort(),
        keysB = Object.keys(b).sort(),
        i, key;

    if (!equal(keysA, keysB)) {
        return false;
    }

    for (i = 0; i < keysA.length; i++) {
        key = keysA[i];

        if (!equal(a[key], b[key])) {
            return false;
        }
    }

    return true;
}

function equal(a, b) {  // jshint ignore: line
    var typeA = typeof a,
        typeB = typeof b,
        i;

    // get detailed object type
    if (typeA === 'object') {
        typeA = type(a);
    }

    // get detailed object type
    if (typeB === 'object') {
        typeB = type(b);
    }

    if (typeA !== typeB) {
        return false;
    }

    if (typeA === 'object') {
        return deepEqual(a, b);
    }

    if (typeA === 'regexp') {
        return a.toString() === b.toString();
    }

    if (typeA === 'array') {
        if (a.length !== b.length) {
            return false;
        }

        for (i = 0; i < a.length; i++) {
            if (!equal(a[i], b[i])) {
                return false;
            }
        }

        return true;
    }

    return a === b;
}

module.exports = equal;
},{}],3:[function(require,module,exports){
'use strict';

var formats = {};

// reference: http://dansnetwork.com/javascript-iso8601rfc3339-date-parser/
formats['date-time'] = /(\d\d\d\d)(-)?(\d\d)(-)?(\d\d)(T)?(\d\d)(:)?(\d\d)(:)?(\d\d)(\.\d+)?(Z|([+-])(\d\d)(:)?(\d\d))/;
// reference: https://github.com/mafintosh/is-my-json-valid/blob/master/formats.js#L7
formats.uri = /^([a-zA-Z][a-zA-Z0-9+-.]*:){0,1}\/\/[^\s]*$/;
// reference: http://stackoverflow.com/questions/201323/using-a-regular-expression-to-validate-an-email-address#answer-8829363
//            http://www.w3.org/TR/html5/forms.html#valid-e-mail-address (search for 'willful violation')
formats.email = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
// reference: https://www.safaribooksonline.com/library/view/regular-expressions-cookbook/9780596802837/ch07s16.html
formats.ipv4 = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
// reference: http://stackoverflow.com/questions/53497/regular-expression-that-matches-valid-ipv6-addresses
formats.ipv6 = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|[fF][eE]80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::([fF]{4}(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
// reference: http://stackoverflow.com/questions/106179/regular-expression-to-match-dns-hostname-or-ip-address#answer-3824105
formats.hostname = /^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9])(\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]))*$/;

module.exports = formats;
},{}],4:[function(require,module,exports){
'use strict';

module.exports = function func() {
    var name = arguments[0] || '',
        args = [].join.call([].slice.call(arguments, 1), ', '),
        lines = '',
        vars = '',
        ind = 1,
        tab = '  ',
        bs = '{[',  // block start
        be = '}]',  // block end
        space = function () {
            return new Array(ind + 1).join(tab);
        },
        push = function (line) {
            lines += space() + line + '\n';
        },
        builder = function (line) {
            var first = line[0],
                last = line[line.length - 1];

            if (be.indexOf(first) > -1 && bs.indexOf(last) > -1) {
                ind--;
                push(line);
                ind++;
            }
            else if (bs.indexOf(last) > -1) {
                push(line);
                ind++;
            }
            else if (be.indexOf(first) > -1) {
                ind--;
                push(line);
            }
            else {
                push(line);
            }

            return builder;
        };

    builder.def = function (id, def) {
        vars += space() + 'var ' + id + (def !== undefined ? ' = ' + def : '') + '\n';

        return builder;
    };

    builder.toSource = function () {
        return 'function ' + name + '(' + args + ') {\n' + vars + '\n' + lines + '\n}';
    };

    builder.compile = function (scope) {
        var src = 'return (' + builder.toSource() + ')',
            scp = scope || {},
            keys = Object.keys(scp),
            vals = keys.map(function (key) { return scp[key]; });

        return Function.apply(null, keys.concat(src)).apply(null, vals);
    };

    return builder;
};
},{}],5:[function(require,module,exports){
'use strict';

var PATH_REPLACE_EXPR = /\[.+?\]/g,
    PATH_PROP_REPLACE_EXPR = /\[?(.*?)?\]/,
    REGEX_ESCAPE_EXPR = /[\/]/g,
    VALID_IDENTIFIER_EXPR = /^[a-z_$][0-9a-z]*$/gi,
    INVALID_SCHEMA = 'jsen: invalid schema object',
    browser = typeof window === 'object' && !!window.navigator,   // jshint ignore: line
    func = require('./func.js'),
    equal = require('./equal.js'),
    unique = require('./unique.js'),
    SchemaResolver = require('./resolver.js'),
    formats = require('./formats.js'),
    types = {},
    keywords = {};

function inlineRegex(regex) {
    var str = regex instanceof RegExp ? regex.toString() : new RegExp(regex).toString();

    if (browser) {
        return str;
    }

    str = str.substr(1, str.length - 2);
    str = '/' + str.replace(REGEX_ESCAPE_EXPR, '\\$&') + '/';

    return str;
}

function appendToPath(path, key) {
    VALID_IDENTIFIER_EXPR.lastIndex = 0;

    return VALID_IDENTIFIER_EXPR.test(key) ?
        path + '.' + key :
        path + '["' + key + '"]';
}

function type(obj) {
    var str = Object.prototype.toString.call(obj);
    return str.substr(8, str.length - 9).toLowerCase();
}

function isInteger(obj) {
    return (obj | 0) === obj;   // jshint ignore: line
}

types['null'] = function (path) {
    return path + ' === null';
};

types.boolean = function (path) {
    return 'typeof ' + path + ' === "boolean"';
};

types.string = function (path) {
    return 'typeof ' + path + ' === "string"';
};

types.number = function (path) {
    return 'typeof ' + path + ' === "number"';
};

types.integer = function (path) {
    return 'typeof ' + path + ' === "number" && !(' + path + ' % 1)';
};

types.array = function (path) {
    return path + ' !== undefined && Array.isArray(' + path + ')';
};

types.object = function (path) {
    return path + ' !== undefined && typeof ' + path + ' === "object" && ' + path + ' !== null && !Array.isArray(' + path + ')';
};

types.date = function (path) {
    return path + ' !== undefined && ' + path + ' instanceof Date';
};

keywords.type = function (context) {
    if (!context.schema.type) {
        return;
    }

    var specified = Array.isArray(context.schema.type) ? context.schema.type : [context.schema.type],
        src = specified.map(function mapType(type) {
            return types[type] ? types[type](context.path) || 'true' : 'true';
        }).join(' || ');

    if (src) {
        context.code('if (!(' + src + ')) {');

        context.error('type');

        context.code('}');
    }
};

keywords['enum'] = function (context) {
    var arr = context.schema['enum'],
        clauses = [],
        value, enumType, i;

    if (!Array.isArray(arr)) {
        return;
    }

    for (i = 0; i < arr.length; i++) {
        value = arr[i];
        enumType = typeof value;

        if (value === null || ['boolean', 'number', 'string'].indexOf(enumType) > -1) {
            // simple equality check for simple data types
            if (enumType === 'string') {
                clauses.push(context.path + ' === "' + value + '"');
            }
            else {
                clauses.push(context.path + ' === ' + value);
            }
        }
        else {
            // deep equality check for complex types or regexes
            clauses.push('equal(' + context.path + ', ' + JSON.stringify(value) + ')');
        }
    }

    context.code('if (!(' + clauses.join(' || ') + ')) {');
    context.error('enum');
    context.code('}');
};

keywords.minimum = function (context) {
    if (typeof context.schema.minimum === 'number') {
        context.code('if (' + context.path + ' < ' + context.schema.minimum + ') {');
        context.error('minimum');
        context.code('}');
    }
};

keywords.exclusiveMinimum = function (context) {
    if (context.schema.exclusiveMinimum === true && typeof context.schema.minimum === 'number') {
        context.code('if (' + context.path + ' === ' + context.schema.minimum + ') {');
        context.error('exclusiveMinimum');
        context.code('}');
    }
};

keywords.maximum = function (context) {
    if (typeof context.schema.maximum === 'number') {
        context.code('if (' + context.path + ' > ' + context.schema.maximum + ') {');
        context.error('maximum');
        context.code('}');
    }
};

keywords.exclusiveMaximum = function (context) {
    if (context.schema.exclusiveMaximum === true && typeof context.schema.maximum === 'number') {
        context.code('if (' + context.path + ' === ' + context.schema.maximum + ') {');
        context.error('exclusiveMaximum');
        context.code('}');
    }
};

keywords.multipleOf = function (context) {
    if (typeof context.schema.multipleOf === 'number') {
        var mul = context.schema.multipleOf,
            decimals = mul.toString().length - mul.toFixed(0).length - 1,
            pow = decimals > 0 ? Math.pow(10, decimals) : 1,
            path = context.path;

        if (decimals > 0) {
            context.code('if (+(Math.round((' + path + ' * ' + pow + ') + "e+" + ' + decimals + ') + "e-" + ' + decimals + ') % ' + (mul * pow) + ' !== 0) {');
        } else {
            context.code('if (((' + path + ' * ' + pow + ') % ' + (mul * pow) + ') !== 0) {');
        }

        context.error('multipleOf');
        context.code('}');
    }
};

keywords.minLength = function (context) {
    if (isInteger(context.schema.minLength)) {
        context.code('if (' + context.path + '.length < ' + context.schema.minLength + ') {');
        context.error('minLength');
        context.code('}');
    }
};

keywords.maxLength = function (context) {
    if (isInteger(context.schema.maxLength)) {
        context.code('if (' + context.path + '.length > ' + context.schema.maxLength + ') {');
        context.error('maxLength');
        context.code('}');
    }
};

keywords.pattern = function (context) {
    var regex = typeof context.schema.pattern === 'string' ?
        new RegExp(context.schema.pattern) :
        context.schema.pattern;

    if (type(regex) === 'regexp') {
        context.code('if (!(' + inlineRegex(regex) + ').test(' + context.path + ')) {');
        context.error('pattern');
        context.code('}');
    }
};

keywords.format = function (context) {
    if (typeof context.schema.format !== 'string' || !formats[context.schema.format]) {
        return;
    }

    context.code('if (!(' + formats[context.schema.format] + ').test(' + context.path + ')) {');
    context.error('format');
    context.code('}');
};

keywords.minItems = function (context) {
    if (isInteger(context.schema.minItems)) {
        context.code('if (' + context.path + '.length < ' + context.schema.minItems + ') {');
        context.error('minItems');
        context.code('}');
    }
};

keywords.maxItems = function (context) {
    if (isInteger(context.schema.maxItems)) {
        context.code('if (' + context.path + '.length > ' + context.schema.maxItems + ') {');
        context.error('maxItems');
        context.code('}');
    }
};

keywords.additionalItems = function (context) {
    if (context.schema.additionalItems === false && Array.isArray(context.schema.items)) {
        context.code('if (' + context.path + '.length > ' + context.schema.items.length + ') {');
        context.error('additionalItems');
        context.code('}');
    }
};

keywords.uniqueItems = function (context) {
    if (context.schema.uniqueItems) {
        context.code('if (unique(' + context.path + ').length !== ' + context.path + '.length) {');
        context.error('uniqueItems');
        context.code('}');
    }
};

keywords.items = function (context) {
    var index = context.declare(0),
        i = 0;

    if (type(context.schema.items) === 'object') {
        context.code('for (' + index + '; ' + index + ' < ' + context.path + '.length; ' + index + '++) {');

        context.validate(context.path + '[' + index + ']', context.schema.items, context.noFailFast);

        context.code('}');
    }
    else if (Array.isArray(context.schema.items)) {
        for (; i < context.schema.items.length; i++) {
            context.code('if (' + context.path + '.length - 1 >= ' + i + ') {');

            context.validate(context.path + '[' + i + ']', context.schema.items[i], context.noFailFast);

            context.code('}');
        }

        if (type(context.schema.additionalItems) === 'object') {
            context.code('for (' + index + ' = ' + i + '; ' + index + ' < ' + context.path + '.length; ' + index + '++) {');

            context.validate(context.path + '[' + index + ']', context.schema.additionalItems, context.noFailFast);

            context.code('}');
        }
    }
};

keywords.maxProperties = function (context) {
    if (isInteger(context.schema.maxProperties)) {
        context.code('if (Object.keys(' + context.path + ').length > ' + context.schema.maxProperties + ') {');
        context.error('maxProperties');
        context.code('}');
    }
};

keywords.minProperties = function (context) {
    if (isInteger(context.schema.minProperties)) {
        context.code('if (Object.keys(' + context.path + ').length < ' + context.schema.minProperties + ') {');
        context.error('minProperties');
        context.code('}');
    }
};

keywords.required = function (context) {
    if (!Array.isArray(context.schema.required)) {
        return;
    }

    for (var i = 0; i < context.schema.required.length; i++) {
        context.code('if (' + appendToPath(context.path, context.schema.required[i]) + ' === undefined) {');
        context.error('required', context.schema.required[i]);
        context.code('}');
    }
};

keywords.properties = function (context) {
    if (context.validatedProperties) {
        // prevent multiple generations of property validation
        return;
    }

    var props = context.schema.properties,
        propKeys = type(props) === 'object' ? Object.keys(props) : [],
        patProps = context.schema.patternProperties,
        patterns = type(patProps) === 'object' ? Object.keys(patProps) : [],
        addProps = context.schema.additionalProperties,
        addPropsCheck = addProps === false || type(addProps) === 'object',
        prop, i, nestedPath;

    // do not use this generator if we have patternProperties or additionalProperties
    // instead, the generator below will be used for all three keywords
    if (!propKeys.length || patterns.length || addPropsCheck) {
        return;
    }

    for (i = 0; i < propKeys.length; i++) {
        prop = propKeys[i];
        nestedPath = appendToPath(context.path, prop);

        context.code('if (' + nestedPath + ' !== undefined) {');

        context.validate(nestedPath, props[prop], context.noFailFast);

        context.code('}');
    }

    context.validatedProperties = true;
};

keywords.patternProperties = keywords.additionalProperties = function (context) {
    if (context.validatedProperties) {
        // prevent multiple generations of this function
        return;
    }

    var props = context.schema.properties,
        propKeys = type(props) === 'object' ? Object.keys(props) : [],
        patProps = context.schema.patternProperties,
        patterns = type(patProps) === 'object' ? Object.keys(patProps) : [],
        addProps = context.schema.additionalProperties,
        addPropsCheck = addProps === false || type(addProps) === 'object',
        keys, key, n, found,
        propKey, pattern, i;

    if (!propKeys.length && !patterns.length && !addPropsCheck) {
        return;
    }

    keys = context.declare('[]');
    key = context.declare('""');
    n = context.declare(0);

    if (addPropsCheck) {
        found = context.declare(false);
    }

    context.code(keys + ' = Object.keys(' + context.path + ')');

    context.code('for (' + n + '; ' + n + ' < ' + keys + '.length; ' + n + '++) {')
        (key + ' = ' + keys + '[' + n + ']')

        ('if (' + context.path + '[' + key + '] === undefined) {')
            ('continue')
        ('}');

    if (addPropsCheck) {
        context.code(found + ' = false');
    }

    // validate regular properties
    for (i = 0; i < propKeys.length; i++) {
        propKey = propKeys[i];

        context.code((i ? 'else ' : '') + 'if (' + key + ' === "' + propKey + '") {');

        if (addPropsCheck) {
            context.code(found + ' = true');
        }

        context.validate(appendToPath(context.path, propKey), props[propKey], context.noFailFast);

        context.code('}');
    }

    // validate pattern properties
    for (i = 0; i < patterns.length; i++) {
        pattern = patterns[i];

        context.code('if ((' + inlineRegex(pattern) + ').test(' + key + ')) {');

        if (addPropsCheck) {
            context.code(found + ' = true');
        }

        context.validate(context.path + '[' + key + ']', patProps[pattern], context.noFailFast);

        context.code('}');
    }

    // validate additional properties
    if (addPropsCheck) {
        context.code('if (!' + found + ') {');

        if (addProps === false) {
            // do not allow additional properties
            context.error('additionalProperties');
        }
        else {
            // validate additional properties
            context.validate(context.path + '[' + key + ']', addProps, context.noFailFast);
        }

        context.code('}');
    }

    context.code('}');

    context.validatedProperties = true;
};

keywords.dependencies = function (context) {
    if (type(context.schema.dependencies) !== 'object') {
        return;
    }

    var key, dep, i = 0;

    for (key in context.schema.dependencies) {
        dep = context.schema.dependencies[key];

        context.code('if (' + appendToPath(context.path, key) + ' !== undefined) {');

        if (type(dep) === 'object') {
            //schema dependency
            context.validate(context.path, dep, context.noFailFast);
        }
        else {
            // property dependency
            for (i; i < dep.length; i++) {
                context.code('if (' + appendToPath(context.path, dep[i]) + ' === undefined) {');
                context.error('dependencies', dep[i]);
                context.code('}');
            }
        }

        context.code('}');
    }
};

keywords.allOf = function (context) {
    if (!Array.isArray(context.schema.allOf)) {
        return;
    }

    for (var i = 0; i < context.schema.allOf.length; i++) {
        context.validate(context.path, context.schema.allOf[i], context.noFailFast);
    }
};

keywords.anyOf = function (context) {
    if (!Array.isArray(context.schema.anyOf)) {
        return;
    }

    var errCount = context.declare(0),
        initialCount = context.declare(0),
        found = context.declare(false),
        i = 0;

    context.code(initialCount + ' = errors.length');

    for (; i < context.schema.anyOf.length; i++) {
        context.code('if (!' + found + ') {');

        context.code(errCount + ' = errors.length');

        context.validate(context.path, context.schema.anyOf[i], true);

        context.code(found + ' = errors.length === ' + errCount)
        ('}');
    }

    context.code('if (!' + found + ') {');

    context.error('anyOf');

    context.code('} else {')
        ('errors.length = ' + initialCount)
    ('}');
};

keywords.oneOf = function (context) {
    if (!Array.isArray(context.schema.oneOf)) {
        return;
    }

    var matching = context.declare(0),
        initialCount = context.declare(0),
        errCount = context.declare(0),
        i = 0;

    context.code(initialCount + ' = errors.length');

    for (; i < context.schema.oneOf.length; i++) {
        context.code(errCount + ' = errors.length');

        context.validate(context.path, context.schema.oneOf[i], true);

        context.code('if (errors.length === ' + errCount + ') {')
            (matching + '++')
        ('}');
    }

    context.code('if (' + matching + ' !== 1) {');

    context.error('oneOf');

    context.code('} else {')
        ('errors.length = ' + initialCount)
    ('}');
};

keywords.not = function (context) {
    if (type(context.schema.not) !== 'object') {
        return;
    }

    var errCount = context.declare(0);

    context.code(errCount + ' = errors.length');

    context.validate(context.path, context.schema.not, true);

    context.code('if (errors.length === ' + errCount + ') {');

    context.error('not');

    context.code('} else {')
        ('errors.length = ' + errCount)
    ('}');
};

['minimum', 'exclusiveMinimum', 'maximum', 'exclusiveMaximum', 'multipleOf']
    .forEach(function (keyword) { keywords[keyword].type = 'number'; });

['minLength', 'maxLength', 'pattern', 'format']
    .forEach(function (keyword) { keywords[keyword].type = 'string'; });

['minItems', 'maxItems', 'additionalItems', 'uniqueItems', 'items']
    .forEach(function (keyword) { keywords[keyword].type = 'array'; });

['maxProperties', 'minProperties', 'required', 'properties', 'patternProperties', 'additionalProperties', 'dependencies']
    .forEach(function (keyword) { keywords[keyword].type = 'object'; });

function getGenerators(schema) {
    var keys = Object.keys(schema),
        start = [],
        perType = {},
        gen, i;

    for (i = 0; i < keys.length; i++) {
        gen = keywords[keys[i]];

        if (!gen) {
            continue;
        }

        if (gen.type) {
            if (!perType[gen.type]) {
                perType[gen.type] = [];
            }

            perType[gen.type].push(gen);
        }
        else {
            start.push(gen);
        }
    }

    return start.concat(Object.keys(perType).reduce(function (arr, key) {
        return arr.concat(perType[key]);
    }, []));
}

function replaceIndexedProperty(match) {
    var index = match.replace(PATH_PROP_REPLACE_EXPR, '$1');

    if (!isNaN(+index)) {
        // numeric index in array
        return '.' + index;
    }
    else if (index[0] === '"') {
        // string key for an object property
        return '[\\"' + index.substr(1, index.length - 2) + '\\"]';
    }

    // variable containing the actual key
    return '." + ' + index + ' + "';
}

function getPathExpression(path) {
    return '"' + path.replace(PATH_REPLACE_EXPR, replaceIndexedProperty).substr(5) + '"';
}

function clone(obj) {
    var cloned = obj,
        objType = type(obj),
        key, i;

    if (objType === 'object') {
        cloned = {};

        for (key in obj) {
            cloned[key] = clone(obj[key]);
        }
    }
    else if (objType === 'array') {
        cloned = [];

        for (i = 0; i < obj.length; i++) {
            cloned[i] = clone(obj[i]);
        }
    }
    else if (objType === 'regexp') {
        return new RegExp(obj);
    }
    else if (objType === 'date') {
        return new Date(obj.toJSON());
    }

    return cloned;
}

function build(schema, def, additional, resolver) {
    var defType, defValue, key, i;

    if (type(schema) !== 'object') {
        return def;
    }

    schema = resolver.resolve(schema);

    if (def === undefined && schema.hasOwnProperty('default')) {
        def = clone(schema['default']);
    }

    defType = type(def);

    if (defType === 'object' && type(schema.properties) === 'object') {
        for (key in schema.properties) {
            defValue = build(schema.properties[key], def[key], additional, resolver);

            if (defValue !== undefined) {
                def[key] = defValue;
            }
        }

        for (key in def) {
            if (!(key in schema.properties) &&
                (schema.additionalProperties === false ||
                (additional === false && !schema.additionalProperties))) {
                delete def[key];
            }
        }
    }
    else if (defType === 'array' && schema.items) {
        if (type(schema.items) === 'array') {
            for (i = 0; i < schema.items.length; i++) {
                defValue = build(schema.items[i], def[i], additional, resolver);

                if (defValue !== undefined || i < def.length) {
                    def[i] = defValue;
                }
            }
        }
        else if (def.length) {
            for (i = 0; i < def.length; i++) {
                def[i] = build(schema.items, def[i], additional, resolver);
            }
        }
    }

    return def;
}

function jsen(schema, options) {
    if (type(schema) !== 'object') {
        throw new Error(INVALID_SCHEMA);
    }

    options = options || {};

    var missing$Ref = options.missing$Ref || false,
        resolver = new SchemaResolver(schema, options.schemas, missing$Ref),
        counter = 0,
        id = function () { return 'i' + (counter++); },
        funcache = {},
        compiled,
        refs = {
            errors: []
        },
        scope = {
            equal: equal,
            unique: unique,
            refs: refs
        };

    function cache(schema) {
        var deref = resolver.resolve(schema),
            ref = schema.$ref,
            cached = funcache[ref],
            func;

        if (!cached) {
            cached = funcache[ref] = {
                key: id(),
                func: function (data) {
                    return func(data);
                }
            };

            func = compile(deref);

            Object.defineProperty(cached.func, 'errors', {
                get: function () {
                    return func.errors;
                }
            });

            refs[cached.key] = cached.func;
        }

        return 'refs.' + cached.key;
    }

    function compile(schema) {
        function declare(def) {
            var variname = id();

            code.def(variname, def);

            return variname;
        }

        function validate(path, schema, noFailFast) {
            var context,
                cachedRef,
                pathExp,
                index,
                lastType,
                format,
                gens,
                gen,
                i;

            function error(keyword, key) {
                var varid,
                    errorPath = path,
                    message = (key && schema.properties && schema.properties[key] && schema.properties[key].requiredMessage) ||
                        schema.invalidMessage;

                if (!message) {
                    message = key && schema.properties && schema.properties[key] && schema.properties[key].messages &&
                        schema.properties[key].messages[keyword] ||
                        schema.messages && schema.messages[keyword];
                }

                if (path.indexOf('[') > -1) {
                    // create error objects dynamically when path contains indexed property expressions
                    errorPath = getPathExpression(path);

                    if (key) {
                        errorPath = errorPath ? errorPath + ' + ".' + key + '"' : key;
                    }

                    code('errors.push({')
                        ('path: ' +  errorPath + ', ')
                        ('keyword: "' + keyword + '"' + (message ? ',' : ''));

                    if (message) {
                        code('message: "' + message + '"');
                    }

                    code('})');
                }
                else {
                    // generate faster code when no indexed properties in the path
                    varid = id();

                    errorPath = errorPath.substr(5);

                    if (key) {
                        errorPath = errorPath ? errorPath + '.' + key : key;
                    }

                    refs[varid] = {
                        path: errorPath,
                        keyword: keyword
                    };

                    if (message) {
                        refs[varid].message = message;
                    }

                    code('errors.push(refs.' + varid + ')');
                }

                if (!noFailFast && !options.greedy) {
                    code('return (validate.errors = errors) && false');
                }
            }

            if (schema.$ref !== undefined) {
                cachedRef = cache(schema);
                pathExp = getPathExpression(path);
                index = declare(0);

                code('if (!' + cachedRef + '(' + path + ')) {')
                    ('if (' + cachedRef + '.errors) {')
                        ('errors.push.apply(errors, ' + cachedRef + '.errors)')
                        ('for (' + index + ' = 0; ' + index + ' < ' + cachedRef + '.errors.length; ' + index + '++) {')
                            ('if (' + cachedRef + '.errors[' + index + '].path) {')
                                ('errors[errors.length - ' + cachedRef + '.errors.length + ' + index + '].path = ' + pathExp +
                                    ' + "." + ' + cachedRef + '.errors[' + index + '].path')
                            ('} else {')
                                ('errors[errors.length - ' + cachedRef + '.errors.length + ' + index + '].path = ' + pathExp)
                            ('}')
                        ('}')
                    ('}')
                ('}');

                return;
            }

            context = {
                path: path,
                schema: schema,
                code: code,
                declare: declare,
                validate: validate,
                error: error,
                noFailFast: noFailFast
            };

            gens = getGenerators(schema);

            for (i = 0; i < gens.length; i++) {
                gen = gens[i];

                if (gen.type && lastType !== gen.type) {
                    if (lastType) {
                        code('}');
                    }

                    lastType = gen.type;

                    code('if (' + types[gen.type](path) + ') {');
                }

                gen(context);
            }

            if (lastType) {
                code('}');
            }

            if (schema.format && options.formats) {
                format = options.formats[schema.format];

                if (format) {
                    if (typeof format === 'string' || format instanceof RegExp) {
                        code('if (!(' + inlineRegex(format) + ').test(' + context.path + ')) {');
                        error('format');
                        code('}');
                    }
                    else if (typeof format === 'function') {
                        (scope.formats || (scope.formats = {}))[schema.format] = format;
                        (scope.schemas || (scope.schemas = {}))[schema.format] = schema;

                        code('if (!formats["' + schema.format + '"](' + context.path + ', schemas["' + schema.format + '"])) {');
                        error('format');
                        code('}');
                    }
                }
            }
        }

        var code = func('validate', 'data')
            ('var errors = []');

        validate('data', schema);

        code('return (validate.errors = errors) && errors.length === 0');

        compiled = code.compile(scope);

        compiled.errors = [];

        compiled.build = function (initial, options) {
            return build(
                schema,
                (options && options.copy === false ? initial : clone(initial)),
                options && options.additionalProperties,
                resolver);
        };

        return compiled;
    }

    return compile(schema);
}

jsen.browser = browser;
jsen.clone = clone;
jsen.equal = equal;
jsen.unique = unique;

module.exports = jsen;

},{"./equal.js":2,"./formats.js":3,"./func.js":4,"./resolver.js":7,"./unique.js":8}],6:[function(require,module,exports){
module.exports={
    "id": "http://json-schema.org/draft-04/schema#",
    "$schema": "http://json-schema.org/draft-04/schema#",
    "description": "Core schema meta-schema",
    "definitions": {
        "schemaArray": {
            "type": "array",
            "minItems": 1,
            "items": { "$ref": "#" }
        },
        "positiveInteger": {
            "type": "integer",
            "minimum": 0
        },
        "positiveIntegerDefault0": {
            "allOf": [ { "$ref": "#/definitions/positiveInteger" }, { "default": 0 } ]
        },
        "simpleTypes": {
            "anyOf": [
                { "enum": [ "array", "boolean", "integer", "null", "number", "object", "string", "any" ] },
                { "type": "string" }
            ]
        },
        "stringArray": {
            "type": "array",
            "items": { "type": "string" },
            "minItems": 1,
            "uniqueItems": true
        }
    },
    "type": "object",
    "properties": {
        "id": {
            "type": "string",
            "format": "uri"
        },
        "$schema": {
            "type": "string",
            "format": "uri"
        },
        "title": {
            "type": "string"
        },
        "description": {
            "type": "string"
        },
        "default": {},
        "multipleOf": {
            "type": "number",
            "minimum": 0,
            "exclusiveMinimum": true
        },
        "maximum": {
            "type": "number"
        },
        "exclusiveMaximum": {
            "type": "boolean",
            "default": false
        },
        "minimum": {
            "type": "number"
        },
        "exclusiveMinimum": {
            "type": "boolean",
            "default": false
        },
        "maxLength": { "$ref": "#/definitions/positiveInteger" },
        "minLength": { "$ref": "#/definitions/positiveIntegerDefault0" },
        "pattern": {
            "type": "string",
            "format": "regex"
        },
        "additionalItems": {
            "anyOf": [
                { "type": "boolean" },
                { "$ref": "#" }
            ],
            "default": {}
        },
        "items": {
            "anyOf": [
                { "$ref": "#" },
                { "$ref": "#/definitions/schemaArray" }
            ],
            "default": {}
        },
        "maxItems": { "$ref": "#/definitions/positiveInteger" },
        "minItems": { "$ref": "#/definitions/positiveIntegerDefault0" },
        "uniqueItems": {
            "type": "boolean",
            "default": false
        },
        "maxProperties": { "$ref": "#/definitions/positiveInteger" },
        "minProperties": { "$ref": "#/definitions/positiveIntegerDefault0" },
        "required": { "$ref": "#/definitions/stringArray" },
        "additionalProperties": {
            "anyOf": [
                { "type": "boolean" },
                { "$ref": "#" }
            ],
            "default": {}
        },
        "definitions": {
            "type": "object",
            "additionalProperties": { "$ref": "#" },
            "default": {}
        },
        "properties": {
            "type": "object",
            "additionalProperties": { "$ref": "#" },
            "default": {}
        },
        "patternProperties": {
            "type": "object",
            "additionalProperties": { "$ref": "#" },
            "default": {}
        },
        "dependencies": {
            "type": "object",
            "additionalProperties": {
                "anyOf": [
                    { "$ref": "#" },
                    { "$ref": "#/definitions/stringArray" }
                ]
            }
        },
        "enum": {
            "type": "array",
            "minItems": 1,
            "uniqueItems": true
        },
        "type": {
            "anyOf": [
                { "$ref": "#/definitions/simpleTypes" },
                {
                    "type": "array",
                    "items": { "$ref": "#/definitions/simpleTypes" },
                    "minItems": 1,
                    "uniqueItems": true
                }
            ]
        },
        "allOf": { "$ref": "#/definitions/schemaArray" },
        "anyOf": { "$ref": "#/definitions/schemaArray" },
        "oneOf": { "$ref": "#/definitions/schemaArray" },
        "not": { "$ref": "#" }
    },
    "dependencies": {
        "exclusiveMaximum": [ "maximum" ],
        "exclusiveMinimum": [ "minimum" ]
    },
    "default": {}
}

},{}],7:[function(require,module,exports){
'use strict';

var metaschema = require('./metaschema.json'),
    refRegex = /#?(\/?\w+)*$/,
    INVALID_SCHEMA_REFERENCE = 'jsen: invalid schema reference';

function get(obj, key) {
    var parts = key.split('.'),
        subobj,
        remaining;

    if (parts.length === 1) {
        // simple key
        return obj[key];
    }

    // compound and nested properties
    // e.g. key('nested.key', { nested: { key: 123 } }) === 123
    // e.g. key('compount.key', { 'compound.key': 456 }) === 456
    while (parts.length && obj !== undefined && obj !== null) {
        // take a part from the front
        remaining = parts.slice(0);
        subobj = undefined;

        // try to match larger compound keys containing dots
        while (remaining.length && subobj === undefined) {
            subobj = obj[remaining.join('.')];

            if (subobj === undefined) {
                remaining.pop();
            }
        }

        // if there is a matching larger compount key, use that
        if (subobj !== undefined) {
            obj = subobj;

            // remove keys from the parts, respectively
            while (remaining.length) {
                remaining.shift();
                parts.shift();
            }
        }
        else {
            // treat like normal simple keys
            obj = obj[parts.shift()];
        }
    }

    return obj;
}

// http://tools.ietf.org/html/draft-ietf-appsawg-json-pointer-08#section-3
function unescape(pointer) {
    return decodeURIComponent(pointer)
        .replace(/~1/g, '/')
        .replace(/~0/g, '~');
}

function refToPath(ref) {
    if (ref.indexOf('#') < 0) {
        return ref;
    }

    var path = ref.split('#')[1];

    if (path) {
        path = path
            .split('/')
            .map(unescape)
            .join('.');

        if (path[0] === '.') {
            path = path.substr(1);
        }
    }

    return path;
}

function refFromId(obj, ref) {
    if (obj && typeof obj === 'object') {
        if (obj.id === ref) {
            return obj;
        }

        return Object.keys(obj).reduce(function (resolved, key) {
            return resolved || refFromId(obj[key], ref);
        }, undefined);
    }

    return undefined;
}

function getResolvers(schemas) {
    var keys = Object.keys(schemas),
        resolvers = {},
        key, i;

    for (i = 0; i < keys.length; i++) {
        key = keys[i];
        resolvers[key] = new SchemaResolver(schemas[key]);
    }

    return resolvers;
}

function SchemaResolver(rootSchema, external, missing$Ref) {  // jshint ignore: line
    this.rootSchema = rootSchema;
    this.cache = {};
    this.resolved = null;
    this.missing$Ref = missing$Ref;

    this.resolvers = external && typeof external === 'object' ?
        getResolvers(external) :
        null;
}

SchemaResolver.prototype.resolveRef = function (ref) {
    var err = new Error(INVALID_SCHEMA_REFERENCE + ' ' + ref),
        root = this.rootSchema,
        externalResolver,
        path,
        dest;

    if (!ref || typeof ref !== 'string' || !refRegex.test(ref)) {
        throw err;
    }

    if (ref === metaschema.id) {
        dest = metaschema;
    }

    if (!dest) {
        dest = refFromId(root, ref);
    }

    if (!dest) {
        path = refToPath(ref);

        dest = path ? get(root, path) : root;
    }

    if (!dest && path && this.resolvers) {
        externalResolver = get(this.resolvers, path);

        if (externalResolver) {
            dest = externalResolver.resolve(externalResolver.rootSchema);
        }
    }

    if (!dest || typeof dest !== 'object') {
        if (this.missing$Ref) {
            dest = {};
        } else {
            throw err;
        }
    }

    if (this.cache[ref] === dest) {
        return dest;
    }

    this.cache[ref] = dest;

    if (dest.$ref !== undefined) {
        dest = this.cache[ref] = this.resolveRef(dest.$ref);
    }

    return dest;
};

SchemaResolver.prototype.resolve = function (schema) {
    if (!schema || typeof schema !== 'object') {
        return schema;
    }

    var ref = schema.$ref,
        resolved = this.cache[ref];

    if (ref === undefined) {
        return schema;
    }

    if (resolved) {
        return resolved;
    }

    resolved = this.resolveRef(ref);

    if (schema === this.rootSchema && schema !== resolved) {
        // substitute the resolved root schema
        this.rootSchema = resolved;
    }

    return resolved;
};

module.exports = SchemaResolver;
},{"./metaschema.json":6}],8:[function(require,module,exports){
'use strict';

var equal = require('./equal.js');

function findIndex(arr, value, comparator) {
    for (var i = 0, len = arr.length; i < len; i++) {
        if (comparator(arr[i], value)) {
            return i;
        }
    }

    return -1;
}

module.exports = function unique(arr) {
    return arr.filter(function uniqueOnly(value, index, self) {
        return findIndex(self, value, equal) === index;
    });
};

module.exports.findIndex = findIndex;
},{"./equal.js":2}]},{},[1])(1)
});
	
/**
 * @license MIT
 * @fileOverview Favico animations
 * @author Miroslav Magda, http://blog.ejci.net
 * @version 0.3.9
 */

/**
 * Create new favico instance
 * @param {Object} Options
 * @return {Object} Favico object
 * @example
 * var favico = new Favico({
 *    bgColor : '#d00',
 *    textColor : '#fff',
 *    fontFamily : 'sans-serif',
 *    fontStyle : 'bold',
 *    position : 'down',
 *    type : 'circle',
 *    animation : 'slide',
 *    dataUrl: function(url){},
 *    win: top
 * });
 */
(function() {

	var Favico = (function(opt) {
		'use strict';
		opt = (opt) ? opt : {};
		var _def = {
			bgColor : '#d00',
			textColor : '#fff',
			fontFamily : 'sans-serif', //Arial,Verdana,Times New Roman,serif,sans-serif,...
			fontStyle : 'bold', //normal,italic,oblique,bold,bolder,lighter,100,200,300,400,500,600,700,800,900
			type : 'circle',
			position : 'down', // down, up, left, leftup (upleft)
			animation : 'slide',
			elementId : false,
			dataUrl : false,
			win: window
		};
		var _opt, _orig, _h, _w, _canvas, _context, _img, _ready, _lastBadge, _running, _readyCb, _stop, _browser, _animTimeout, _drawTimeout, _doc;

		_browser = {};
		_browser.ff = typeof InstallTrigger != 'undefined';
		_browser.chrome = !!window.chrome;
		_browser.opera = !!window.opera || navigator.userAgent.indexOf('Opera') >= 0;
		_browser.ie = /*@cc_on!@*/false;
		_browser.safari = Object.prototype.toString.call(window.HTMLElement).indexOf('Constructor') > 0;
		_browser.supported = (_browser.chrome || _browser.ff || _browser.opera);

		var _queue = [];
		_readyCb = function() {
		};
		_ready = _stop = false;
		/**
		 * Initialize favico
		 */
		var init = function() {
			//merge initial options
			_opt = merge(_def, opt);
			_opt.bgColor = hexToRgb(_opt.bgColor);
			_opt.textColor = hexToRgb(_opt.textColor);
			_opt.position = _opt.position.toLowerCase();
			_opt.animation = (animation.types['' + _opt.animation]) ? _opt.animation : _def.animation;

			_doc = _opt.win.document;

			var isUp = _opt.position.indexOf('up') > -1;
			var isLeft = _opt.position.indexOf('left') > -1;

			//transform animation
			if (isUp || isLeft) {
				for (var i = 0; i < animation.types['' + _opt.animation].length; i++) {
					var step = animation.types['' + _opt.animation][i];

					if (isUp) {
						if (step.y < 0.6) {
							step.y = step.y - 0.4;
						} else {
							step.y = step.y - 2 * step.y + (1 - step.w);
						}
					}

					if (isLeft) {
						if (step.x < 0.6) {
							step.x = step.x - 0.4;
						} else {
							step.x = step.x - 2 * step.x + (1 - step.h);
						}
					}

					animation.types['' + _opt.animation][i] = step;
				}
			}
			_opt.type = (type['' + _opt.type]) ? _opt.type : _def.type;

			_orig = link.getIcon();
			//create temp canvas
			_canvas = document.createElement('canvas');
			//create temp image
			_img = document.createElement('img');
			if (_orig.hasAttribute('href')) {
				_img.setAttribute('crossOrigin', 'anonymous');
				_img.setAttribute('src', _orig.getAttribute('href'));
				//get width/height
				_img.onload = function() {
					_h = (_img.height > 0) ? _img.height : 32;
					_w = (_img.width > 0) ? _img.width : 32;
					_canvas.height = _h;
					_canvas.width = _w;
					_context = _canvas.getContext('2d');
					icon.ready();
				};
			} else {
				_img.setAttribute('src', '');
				_h = 32;
				_w = 32;
				_img.height = _h;
				_img.width = _w;
				_canvas.height = _h;
				_canvas.width = _w;
				_context = _canvas.getContext('2d');
				icon.ready();
			}

		};
		/**
		 * Icon namespace
		 */
		var icon = {};
		/**
		 * Icon is ready (reset icon) and start animation (if ther is any)
		 */
		icon.ready = function() {
			_ready = true;
			icon.reset();
			_readyCb();
		};
		/**
		 * Reset icon to default state
		 */
		icon.reset = function() {
			//reset
			if (!_ready) {
				return;
			}
			_queue = [];
			_lastBadge = false;
			_running = false;
			_context.clearRect(0, 0, _w, _h);
			_context.drawImage(_img, 0, 0, _w, _h);
			//_stop=true;
			link.setIcon(_canvas);
			//webcam('stop');
			//video('stop');
			window.clearTimeout(_animTimeout);
			window.clearTimeout(_drawTimeout);
		};
		/**
		 * Start animation
		 */
		icon.start = function() {
			if (!_ready || _running) {
				return;
			}
			var finished = function() {
				_lastBadge = _queue[0];
				_running = false;
				if (_queue.length > 0) {
					_queue.shift();
					icon.start();
				} else {

				}
			};
			if (_queue.length > 0) {
				_running = true;
				var run = function() {
					// apply options for this animation
					['type', 'animation', 'bgColor', 'textColor', 'fontFamily', 'fontStyle'].forEach(function(a) {
						if ( a in _queue[0].options) {
							_opt[a] = _queue[0].options[a];
						}
					});
					animation.run(_queue[0].options, function() {
						finished();
					}, false);
				};
				if (_lastBadge) {
					animation.run(_lastBadge.options, function() {
						run();
					}, true);
				} else {
					run();
				}
			}
		};

		/**
		 * Badge types
		 */
		var type = {};
		var options = function(opt) {
			opt.n = (( typeof opt.n) === 'number') ? Math.abs(opt.n | 0) : opt.n;
			opt.x = _w * opt.x;
			opt.y = _h * opt.y;
			opt.w = _w * opt.w;
			opt.h = _h * opt.h;
			opt.len = ("" + opt.n).length;
			return opt;
		};
		/**
		 * Generate circle
		 * @param {Object} opt Badge options
		 */
		type.circle = function(opt) {
			opt = options(opt);
			var more = false;
			if (opt.len === 2) {
				opt.x = opt.x - opt.w * 0.4;
				opt.w = opt.w * 1.4;
				more = true;
			} else if (opt.len >= 3) {
				opt.x = opt.x - opt.w * 0.65;
				opt.w = opt.w * 1.65;
				more = true;
			}
			_context.clearRect(0, 0, _w, _h);
			_context.drawImage(_img, 0, 0, _w, _h);
			_context.beginPath();
			_context.font = _opt.fontStyle + " " + Math.floor(opt.h * (opt.n > 99 ? 0.85 : 1)) + "px " + _opt.fontFamily;
			_context.textAlign = 'center';
			if (more) {
				_context.moveTo(opt.x + opt.w / 2, opt.y);
				_context.lineTo(opt.x + opt.w - opt.h / 2, opt.y);
				_context.quadraticCurveTo(opt.x + opt.w, opt.y, opt.x + opt.w, opt.y + opt.h / 2);
				_context.lineTo(opt.x + opt.w, opt.y + opt.h - opt.h / 2);
				_context.quadraticCurveTo(opt.x + opt.w, opt.y + opt.h, opt.x + opt.w - opt.h / 2, opt.y + opt.h);
				_context.lineTo(opt.x + opt.h / 2, opt.y + opt.h);
				_context.quadraticCurveTo(opt.x, opt.y + opt.h, opt.x, opt.y + opt.h - opt.h / 2);
				_context.lineTo(opt.x, opt.y + opt.h / 2);
				_context.quadraticCurveTo(opt.x, opt.y, opt.x + opt.h / 2, opt.y);
			} else {
				_context.arc(opt.x + opt.w / 2, opt.y + opt.h / 2, opt.h / 2, 0, 2 * Math.PI);
			}
			_context.fillStyle = 'rgba(' + _opt.bgColor.r + ',' + _opt.bgColor.g + ',' + _opt.bgColor.b + ',' + opt.o + ')';
			_context.fill();
			_context.closePath();
			_context.beginPath();
			_context.stroke();
			_context.fillStyle = 'rgba(' + _opt.textColor.r + ',' + _opt.textColor.g + ',' + _opt.textColor.b + ',' + opt.o + ')';
			//_context.fillText((more) ? '9+' : opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15));
			if (( typeof opt.n) === 'number' && opt.n > 999) {
				_context.fillText(((opt.n > 9999) ? 9 : Math.floor(opt.n / 1000) ) + 'k+', Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.2));
			} else {
				_context.fillText(opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15));
			}
			_context.closePath();
		};
		/**
		 * Generate rectangle
		 * @param {Object} opt Badge options
		 */
		type.rectangle = function(opt) {
			opt = options(opt);
			var more = false;
			if (opt.len === 2) {
				opt.x = opt.x - opt.w * 0.4;
				opt.w = opt.w * 1.4;
				more = true;
			} else if (opt.len >= 3) {
				opt.x = opt.x - opt.w * 0.65;
				opt.w = opt.w * 1.65;
				more = true;
			}
			_context.clearRect(0, 0, _w, _h);
			_context.drawImage(_img, 0, 0, _w, _h);
			_context.beginPath();
			_context.font = _opt.fontStyle + " " + Math.floor(opt.h * (opt.n > 99 ? 0.9 : 1)) + "px " + _opt.fontFamily;
			_context.textAlign = 'center';
			_context.fillStyle = 'rgba(' + _opt.bgColor.r + ',' + _opt.bgColor.g + ',' + _opt.bgColor.b + ',' + opt.o + ')';
			_context.fillRect(opt.x, opt.y, opt.w, opt.h);
			_context.fillStyle = 'rgba(' + _opt.textColor.r + ',' + _opt.textColor.g + ',' + _opt.textColor.b + ',' + opt.o + ')';
			//_context.fillText((more) ? '9+' : opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15));
			if (( typeof opt.n) === 'number' && opt.n > 999) {
				_context.fillText(((opt.n > 9999) ? 9 : Math.floor(opt.n / 1000) ) + 'k+', Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.2));
			} else {
				_context.fillText(opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15));
			}
			_context.closePath();
		};

		/**
		 * Set badge
		 */
		var badge = function(number, opts) {
			opts = (( typeof opts) === 'string' ? {
				animation : opts
			} : opts) || {};
			_readyCb = function() {
				try {
					if ( typeof (number) === 'number' ? (number > 0) : (number !== '')) {
						var q = {
							type : 'badge',
							options : {
								n : number
							}
						};
						if ('animation' in opts && animation.types['' + opts.animation]) {
							q.options.animation = '' + opts.animation;
						}
						if ('type' in opts && type['' + opts.type]) {
							q.options.type = '' + opts.type;
						}
						['bgColor', 'textColor'].forEach(function(o) {
							if ( o in opts) {
								q.options[o] = hexToRgb(opts[o]);
							}
						});
						['fontStyle', 'fontFamily'].forEach(function(o) {
							if ( o in opts) {
								q.options[o] = opts[o];
							}
						});
						_queue.push(q);
						if (_queue.length > 100) {
							throw new Error('Too many badges requests in queue.');
						}
						icon.start();
					} else {
						icon.reset();
					}
				} catch(e) {
					throw new Error('Error setting badge. Message: ' + e.message);
				}
			};
			if (_ready) {
				_readyCb();
			}
		};

		/**
		 * Set image as icon
		 */
		var image = function(imageElement) {
			_readyCb = function() {
				try {
					var w = imageElement.width;
					var h = imageElement.height;
					var newImg = document.createElement('img');
					var ratio = (w / _w < h / _h) ? (w / _w) : (h / _h);
					newImg.setAttribute('crossOrigin', 'anonymous');
					newImg.setAttribute('src', imageElement.getAttribute('src'));
					newImg.height = (h / ratio);
					newImg.width = (w / ratio);
					_context.clearRect(0, 0, _w, _h);
					_context.drawImage(newImg, 0, 0, _w, _h);
					link.setIcon(_canvas);
				} catch(e) {
					throw new Error('Error setting image. Message: ' + e.message);
				}
			};
			if (_ready) {
				_readyCb();
			}
		};
		/**
		 * Set video as icon
		 */
		var video = function(videoElement) {
			_readyCb = function() {
				try {
					if (videoElement === 'stop') {
						_stop = true;
						icon.reset();
						_stop = false;
						return;
					}
					//var w = videoElement.width;
					//var h = videoElement.height;
					//var ratio = (w / _w < h / _h) ? (w / _w) : (h / _h);
					videoElement.addEventListener('play', function() {
						drawVideo(this);
					}, false);

				} catch(e) {
					throw new Error('Error setting video. Message: ' + e.message);
				}
			};
			if (_ready) {
				_readyCb();
			}
		};
		/**
		 * Set video as icon
		 */
		var webcam = function(action) {
			//UR
			if (!window.URL || !window.URL.createObjectURL) {
				window.URL = window.URL || {};
				window.URL.createObjectURL = function(obj) {
					return obj;
				};
			}
			if (_browser.supported) {
				var newVideo = false;
				navigator.getUserMedia = navigator.getUserMedia || navigator.oGetUserMedia || navigator.msGetUserMedia || navigator.mozGetUserMedia || navigator.webkitGetUserMedia;
				_readyCb = function() {
					try {
						if (action === 'stop') {
							_stop = true;
							icon.reset();
							_stop = false;
							return;
						}
						newVideo = document.createElement('video');
						newVideo.width = _w;
						newVideo.height = _h;
						navigator.getUserMedia({
							video : true,
							audio : false
						}, function(stream) {
							newVideo.src = URL.createObjectURL(stream);
							newVideo.play();
							drawVideo(newVideo);
						}, function() {
						});
					} catch(e) {
						throw new Error('Error setting webcam. Message: ' + e.message);
					}
				};
				if (_ready) {
					_readyCb();
				}
			}

		};

		/**
		 * Draw video to context and repeat :)
		 */
		function drawVideo(video) {
			if (video.paused || video.ended || _stop) {
				return false;
			}
			//nasty hack for FF webcam (Thanks to Julian Ćwirko, kontakt@redsunmedia.pl)
			try {
				_context.clearRect(0, 0, _w, _h);
				_context.drawImage(video, 0, 0, _w, _h);
			} catch(e) {

			}
			_drawTimeout = setTimeout(function() {
				drawVideo(video);
			}, animation.duration);
			link.setIcon(_canvas);
		}

		var link = {};
		/**
		 * Get icon from HEAD tag or create a new <link> element
		 */
		link.getIcon = function() {
			var elm = false;
			//get link element
			var getLink = function() {
				var link = _doc.getElementsByTagName('head')[0].getElementsByTagName('link');
				for (var l = link.length, i = (l - 1); i >= 0; i--) {
					if ((/(^|\s)icon(\s|$)/i).test(link[i].getAttribute('rel'))) {
						return link[i];
					}
				}
				return false;
			};
			if (_opt.element) {
				elm = _opt.element;
			} else if (_opt.elementId) {
				//if img element identified by elementId
				elm = _doc.getElementById(_opt.elementId);
				elm.setAttribute('href', elm.getAttribute('src'));
			} else {
				//if link element
				elm = getLink();
				if (elm === false) {
					elm = _doc.createElement('link');
					elm.setAttribute('rel', 'icon');
					_doc.getElementsByTagName('head')[0].appendChild(elm);
				}
			}
			elm.setAttribute('type', 'image/png');
			return elm;
		};
		link.setIcon = function(canvas) {
			var url = canvas.toDataURL('image/png');
			if (_opt.dataUrl) {
				//if using custom exporter
				_opt.dataUrl(url);
			}
			if (_opt.element) {
				_opt.element.setAttribute('href', url);
				_opt.element.setAttribute('src', url);
			} else if (_opt.elementId) {
				//if is attached to element (image)
				var elm = _doc.getElementById(_opt.elementId);
				elm.setAttribute('href', url);
				elm.setAttribute('src', url);
			} else {
				//if is attached to fav icon
				if (_browser.ff || _browser.opera) {
					//for FF we need to "recreate" element, atach to dom and remove old <link>
					//var originalType = _orig.getAttribute('rel');
					var old = _orig;
					_orig = _doc.createElement('link');
					//_orig.setAttribute('rel', originalType);
					if (_browser.opera) {
						_orig.setAttribute('rel', 'icon');
					}
					_orig.setAttribute('rel', 'icon');
					_orig.setAttribute('type', 'image/png');
					_doc.getElementsByTagName('head')[0].appendChild(_orig);
					_orig.setAttribute('href', url);
					if (old.parentNode) {
						old.parentNode.removeChild(old);
					}
				} else {
					_orig.setAttribute('href', url);
				}
			}
		};

		//http://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb#answer-5624139
		//HEX to RGB convertor
		function hexToRgb(hex) {
			var shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
			hex = hex.replace(shorthandRegex, function(m, r, g, b) {
				return r + r + g + g + b + b;
			});
			var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
			return result ? {
				r : parseInt(result[1], 16),
				g : parseInt(result[2], 16),
				b : parseInt(result[3], 16)
			} : false;
		}

		/**
		 * Merge options
		 */
		function merge(def, opt) {
			var mergedOpt = {};
			var attrname;
			for (attrname in def) {
				mergedOpt[attrname] = def[attrname];
			}
			for (attrname in opt) {
				mergedOpt[attrname] = opt[attrname];
			}
			return mergedOpt;
		}

		/**
		 * Cross-browser page visibility shim
		 * http://stackoverflow.com/questions/12536562/detect-whether-a-window-is-visible
		 */
		function isPageHidden() {
			return _doc.hidden || _doc.msHidden || _doc.webkitHidden || _doc.mozHidden;
		}

		/**
		 * @namespace animation
		 */
		var animation = {};
		/**
		 * Animation "frame" duration
		 */
		animation.duration = 40;
		/**
		 * Animation types (none,fade,pop,slide)
		 */
		animation.types = {};
		animation.types.fade = [{
			x : 0.4,
			y : 0.4,
			w : 0.6,
			h : 0.6,
			o : 0.0
		}, {
			x : 0.4,
			y : 0.4,
			w : 0.6,
			h : 0.6,
			o : 0.1
		}, {
			x : 0.4,
			y : 0.4,
			w : 0.6,
			h : 0.6,
			o : 0.2
		}, {
			x : 0.4,
			y : 0.4,
			w : 0.6,
			h : 0.6,
			o : 0.3
		}, {
			x : 0.4,
			y : 0.4,
			w : 0.6,
			h : 0.6,
			o : 0.4
		}, {
			x : 0.4,
			y : 0.4,
			w : 0.6,
			h : 0.6,
			o : 0.5
		}, {
			x : 0.4,
			y : 0.4,
			w : 0.6,
			h : 0.6,
			o : 0.6
		}, {
			x : 0.4,
			y : 0.4,
			w : 0.6,
			h : 0.6,
			o : 0.7
		}, {
			x : 0.4,
			y : 0.4,
			w : 0.6,
			h : 0.6,
			o : 0.8
		}, {
			x : 0.4,
			y : 0.4,
			w : 0.6,
			h : 0.6,
			o : 0.9
		}, {
			x : 0.4,
			y : 0.4,
			w : 0.6,
			h : 0.6,
			o : 1.0
		}];
		animation.types.none = [{
			x : 0.4,
			y : 0.4,
			w : 0.6,
			h : 0.6,
			o : 1
		}];
		animation.types.pop = [{
			x : 1,
			y : 1,
			w : 0,
			h : 0,
			o : 1
		}, {
			x : 0.9,
			y : 0.9,
			w : 0.1,
			h : 0.1,
			o : 1
		}, {
			x : 0.8,
			y : 0.8,
			w : 0.2,
			h : 0.2,
			o : 1
		}, {
			x : 0.7,
			y : 0.7,
			w : 0.3,
			h : 0.3,
			o : 1
		}, {
			x : 0.6,
			y : 0.6,
			w : 0.4,
			h : 0.4,
			o : 1
		}, {
			x : 0.5,
			y : 0.5,
			w : 0.5,
			h : 0.5,
			o : 1
		}, {
			x : 0.4,
			y : 0.4,
			w : 0.6,
			h : 0.6,
			o : 1
		}];
		animation.types.popFade = [{
			x : 0.75,
			y : 0.75,
			w : 0,
			h : 0,
			o : 0
		}, {
			x : 0.65,
			y : 0.65,
			w : 0.1,
			h : 0.1,
			o : 0.2
		}, {
			x : 0.6,
			y : 0.6,
			w : 0.2,
			h : 0.2,
			o : 0.4
		}, {
			x : 0.55,
			y : 0.55,
			w : 0.3,
			h : 0.3,
			o : 0.6
		}, {
			x : 0.50,
			y : 0.50,
			w : 0.4,
			h : 0.4,
			o : 0.8
		}, {
			x : 0.45,
			y : 0.45,
			w : 0.5,
			h : 0.5,
			o : 0.9
		}, {
			x : 0.4,
			y : 0.4,
			w : 0.6,
			h : 0.6,
			o : 1
		}];
		animation.types.slide = [{
			x : 0.4,
			y : 1,
			w : 0.6,
			h : 0.6,
			o : 1
		}, {
			x : 0.4,
			y : 0.9,
			w : 0.6,
			h : 0.6,
			o : 1
		}, {
			x : 0.4,
			y : 0.9,
			w : 0.6,
			h : 0.6,
			o : 1
		}, {
			x : 0.4,
			y : 0.8,
			w : 0.6,
			h : 0.6,
			o : 1
		}, {
			x : 0.4,
			y : 0.7,
			w : 0.6,
			h : 0.6,
			o : 1
		}, {
			x : 0.4,
			y : 0.6,
			w : 0.6,
			h : 0.6,
			o : 1
		}, {
			x : 0.4,
			y : 0.5,
			w : 0.6,
			h : 0.6,
			o : 1
		}, {
			x : 0.4,
			y : 0.4,
			w : 0.6,
			h : 0.6,
			o : 1
		}];
		/**
		 * Run animation
		 * @param {Object} opt Animation options
		 * @param {Object} cb Callabak after all steps are done
		 * @param {Object} revert Reverse order? true|false
		 * @param {Object} step Optional step number (frame bumber)
		 */
		animation.run = function(opt, cb, revert, step) {
			var animationType = animation.types[isPageHidden() ? 'none' : _opt.animation];
			if (revert === true) {
				step = ( typeof step !== 'undefined') ? step : animationType.length - 1;
			} else {
				step = ( typeof step !== 'undefined') ? step : 0;
			}
			cb = (cb) ? cb : function() {
			};
			if ((step < animationType.length) && (step >= 0)) {
				type[_opt.type](merge(opt, animationType[step]));
				_animTimeout = setTimeout(function() {
					if (revert) {
						step = step - 1;
					} else {
						step = step + 1;
					}
					animation.run(opt, cb, revert, step);
				}, animation.duration);

				link.setIcon(_canvas);
			} else {
				cb();
				return;
			}
		};
		//auto init
		init();
		return {
			badge : badge,
			video : video,
			image : image,
			webcam : webcam,
			reset : icon.reset,
			browser : {
				supported : _browser.supported
			}
		};
	});

	// AMD / RequireJS
	if ( typeof define !== 'undefined' && define.amd) {
		define([], function() {
			return Favico;
		});
	}
	// CommonJS
	else if ( typeof module !== 'undefined' && module.exports) {
		module.exports = Favico;
	}
	// included directly via <script> tag
	else {
		window.Favico = Favico;
	}

})();
}

})();