CatalogTagging

カタログをてきとうにタグ分けします

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        CatalogTagging
// @description カタログをてきとうにタグ分けします
// @namespace   http://pussy.CatalogTagging/
// @include     *://*.2chan.net/*/futaba.php?mode=cat*
// @version     5.2
// @grant       none
// ==/UserScript==

(function() {

'use strict';
let doc = document;

// ---------------------------------------------------------------------------
// 設定
// ---------------------------------------------------------------------------
let TAGS, CATALOGTAG_CSS, CATALOGTAG_TEXT_CSS, USE_CACHE;
let setup = () => {
	// タグの設定
	TAGS = [
		{ name: '未分類', default: true },
		{ name: 'お外', expr: /http/ },
		{ name: 'お題', imgChecker: odaiChecker },
		{ name: 'Abema', expr: /https:\/\/ab/ },
		{ name: '実況', expr: /そろそろ|午後ロー|鉄腕|DASH/ },
		{ name: 'マケドニア', imgChecker: macedoniaChecker },
		{ name: '引用', expr: /^>/ }
	];
	// タグのスタイル
	CATALOGTAG_CSS = `
		.catalogtag {
			background: #ea8;
			font-size: 12px;
			max-width: 4em;
			overflow:hidden;
			padding: 0;
			text-align: center;
		}
	`;
	// 本文のスタイル(カタログに本文を出したくない人は「display: none;」とか入れればいいよ)
	CATALOGTAG_TEXT_CSS = `
		.catalogtag-text {
		}
	`;
	// タグ分け結果をキャッシュするか(画像解析を微調整するときはfalseにしておく)
	USE_CACHE = true;
};
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// 画像解析
let canvas = doc.createElement('CANVAS');
canvas.width = 50;
canvas.height = 50;
let ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;

/**
 * @param x 0から49
 * @param y 0から49
 * @return サムネの色を配列[R,G,B]で返します
 */
let getRGB = (x, y) => {
	return ctx.getImageData(x, y, 1, 1).data;
};

/** @return 色がだいたい同じならtrueを返します */
let isLike = (c1, c2) => {
	for (let i = 0; i < 3; i ++) {
		if (Math.abs(c1[i] - c2[i]) > 32) return false;
	}
	return true;
};

let macedoniaChecker = () => {
	let checkOK = 0;
	let r = [223, 32, 32];
	let y = [223, 223, 32];
	if (isLike(y, getRGB( 0, 1))) [r, y] = [y, r];
	if (isLike(r, getRGB( 0, 0))) checkOK++;
	if (isLike(y, getRGB(15, 0))) checkOK++;
	if (checkOK && isLike(getRGB(0, 0), getRGB(15, 0))) return false;
	if (isLike(r, getRGB(25,  0)) && ++checkOK >= 3) return true;
	if (isLike(y, getRGB(35, 15)) && ++checkOK >= 3) return true;
	if (isLike(r, getRGB(49,  0)) && ++checkOK >= 3) return true;
	if (isLike(y, getRGB( 0, 15)) && ++checkOK >= 3) return true;
	if (isLike(r, getRGB( 0, 25)) && ++checkOK >= 3) return true;
	if (isLike(y, getRGB( 0, 35)) && ++checkOK >= 3) return true;
	if (isLike(r, getRGB( 0, 49)) && ++checkOK >= 3) return true;
	if (isLike(y, getRGB(49, 15)) && ++checkOK >= 3) return true;
	if (isLike(r, getRGB(49, 25)) && ++checkOK >= 3) return true;
	if (isLike(y, getRGB(49, 35)) && ++checkOK >= 3) return true;
	if (isLike(r, getRGB(49, 49)) && ++checkOK >= 3) return true;
	return false;
};

let odaiChecker = () => {
	if (!isLike([255, 255, 255], getRGB(0, 0))) return false;
	if (!isLike([255, 255, 255], getRGB(49,0))) return false;
	for (let y = 5; y <=8; y++) {
		if (isLike([0, 0, 0], getRGB(4, y)) && isLike([0, 0, 0], getRGB(45, y))) return true;
	}
	return false;
};

// ---------------------------------------------------------------------------
// ここから本体
setup();
// タグ設定を整頓する
let NO_TAGGED;
let TAGS_BY_NAME = {};
TAGS.forEach(tag => {
	TAGS_BY_NAME[tag.name] = tag;
	if (tag.default) NO_TAGGED = tag;
});
if (!NO_TAGGED) {
	NO_TAGGED = { name: '未分類', default: true };
	TAGS.unshift(NO_TAGGED);
	TAGS_BY_NAME[NO_TAGGED.name] = NO_TAGGED;
}
// キャッシュを読み込む
let cacheOnStrage = sessionStorage.getItem('catalogtagging_cache');
let cache = cacheOnStrage && JSON.parse(cacheOnStrage) || {};

/** @return 本文と画像をつかって適当にタグを返します */
let findTag = (text, img) => {
	let needDraw = true;
	for (let tag of TAGS) {
		if (text && tag.expr && tag.expr.test(text)) return tag;
		if (!img) continue;
		if (needDraw) {
			ctx.drawImage(img, 0, 0, 50, 50);
			needDraw = false;
		}
		if (tag.imgChecker && tag.imgChecker()) return tag;
	}
	return NO_TAGGED;
};

/* カタログの<TABLE> */
let catalog;

/* タグ分け本体 */
let tagging = (retryCount = 0) => {
	doc.body.setAttribute('__catalogtagging_status', 'start');
	// カタログ情報を取得
	catalog = doc.querySelector('TABLE[border="1"][align="center"]');
	let maxCol = catalog.getElementsByTagName('TR')[0].getElementsByTagName('TD').length;
	let tdElements = catalog.getElementsByTagName('TD');
	let tdCount = tdElements.length;
	if (!tdCount || !(tdElements[0].getElementsByTagName('SMALL').length)) return false; // 本文表示無し
	let tds = [];
	for (let i = 0; i < tdCount; i ++) {
		tds[i] = tdElements[i];
	}
	// 初期化
	TAGS.forEach(tag => {
		tag.tds = [];
		tag.count = 0;
	});
	let cacheKeys = Object.keys(cache);
	for (let j = cacheKeys.length - Math.floor(tdCount * 1.5); 0 <= j; j --) {
		delete cache[cacheKeys[j]];
	}
	let retry = false; // 画像が読み込み中だったら後でもう1回実行する
	// 並び替え
	tds.forEach(td => {
		if (td.classList.contains('catalogtag')) return;
		let small = td.getElementsByTagName('SMALL')[0];
		small.classList.add('catalogtag-text');
		let a = td.getElementsByTagName('A')[0];
		if (!a || !a.href) return;
		let tag = USE_CACHE && TAGS_BY_NAME[cache[a.href]];
		if (!tag) {
			let img = td.getElementsByTagName('IMG')[0];
			if (!img || img.complete) {
				tag = findTag(small.textContent, img);
				cache[a.herf] = tag.name;
			} else {
				tag = findTag(small.textContent, null);
				retry = true;
			}
		}
		if (!tag.count && tag.name) {
			let tagLabelTd = doc.createElement('TD');
			tagLabelTd.textContent = tag.name;
			tagLabelTd.className = 'catalogtag';
			tag.tds = [];
			tag.tds.push(tagLabelTd);
			tag.count ++;
		}
		tag.tds.push(td);
		tag.count ++;
	});
	// カタログの要素を置き換えて完了
	let tbody = doc.createElement('TBODY');
	let count = 0;
	let tr = null;
	TAGS.forEach(tag => {
	if (count === 0 && tag == NO_TAGGED) {
		tag.tds.shift();
	}
	for (let td of tag.tds) {
		if (count % maxCol === 0) {
			tr = tbody.appendChild(doc.createElement('TR'));
		}
		tr.appendChild(td);
		count ++;
		}
	});
	catalog.replaceChild(tbody, catalog.firstChild);
	sessionStorage.setItem('catalogtagging_cache', JSON.stringify(cache));
	doc.body.setAttribute('__catalogtagging_status', 'done');
	if (retry && retryCount < 10) { // やり直しは10回まで
		setTimeout(() => { tagging(retryCount + 1); }, 100);
	}
};
// 念のためイベント呼び出し回数をカウントして無限ループを抑制しておく
let eventCount = 0;
let resetEventCount = () => { eventCount = 0; };
// START!
let onLoad = e => {
	doc.styleSheets.item(0).insertRule(CATALOGTAG_CSS, 0);
	doc.styleSheets.item(0).insertRule(CATALOGTAG_TEXT_CSS, 0);
	tagging();
	// MutationRecordをeventCheckerでチェックしてタグ分けしたりしなかったりする関数
	let onEvent = (m, eventChecker) => {
		if (eventCount > 10) {
			console.log('他の拡張と競合してるっぽい');
			return;
		}
		for (let i = m.length - 1; 0 <= i; i --) {
			if (!eventChecker(m[i])) continue;
			eventCount ++;
			setTimeout(resetEventCount, 500);
			tagging();
			return;
		}
	};
	// TABLEタグが再追加されたらタグ分けするオブザーバー
	let defaultObserver = new MutationObserver(m => {
		onEvent(m, n => {
			for (let i = n.addedNodes.length - 1; 0 <= i; i --) {
				let node = n.addedNodes[i];
				if (node.tagName === 'TABLE') return true; // 赤福
				if (node.id === 'catalog_loading') return true; // ふたクロ
			}
			return false;
		});
	});
	defaultObserver.observe(catalog.parentNode, { childList: true });
	// ねないこのソートが終わったらタグ分けするオブザーバー
	let nenaikoObserver = new MutationObserver(m => {
		onEvent(m, n => {
			if (n.attributeName !== '__nenaiko_catsort_status') return false;
			if (doc.body.getAttribute('__nenaiko_catsort_status') === 'start') return false;
			// ねないこのソートが有効になってるならデフォルトのオブザーバーは要らないので切断する
			if (doc.body.getAttribute('__nenaiko_catsort_status') === 'done') defaultObserver.disconnect();
			return true;
		});
	});
	nenaikoObserver.observe(doc.body, { attributes: true });
};
if (doc.readyState === 'complete') {
	onLoad();
} else {
	addEventListener('load', onLoad);
}
})();