// ==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);
}
})();