PttChrome Add-on (Ptt)

new features for PttChrome (show flags features code by osk2/ptt-comment-flag)

当前为 2018-09-30 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         PttChrome Add-on (Ptt)
// @namespace    https://greasyfork.org/zh-TW/scripts/372391-pttchrome-add-on-ptt
// @description  new features for PttChrome (show flags features code by osk2/ptt-comment-flag)
// @version      1.4
// @author       avan
// @match        iamchucky.github.io/PttChrome/*
// @match        term.ptt.cc/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/axios/0.18.0/axios.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/tippy.js/2.5.4/tippy.min.js
// @require      https://greasyfork.org/scripts/372675-flags-css/code/Flags-CSS.js?version=632757
// @require      https://openuserjs.org/src/libs/sizzle/GM_config.js
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==
let configStatus = false, flagMap = {};
const gmc = new GM_configStruct({
	'id': 'PttChromeAddOnConfig', // The id used for this instance of GM_config
	'title': 'PttChrome Add-on Settings', // Panel Title
	'fields': { // Fields object
		'isHideAll': {
			'label': '是否隱藏黑名單推文', // Appears next to field
			'type': 'checkbox', // Makes this setting a checkbox input
			'default': false // Default value if user doesn't change it
		},
		'whenHideAllShowInfo': {
			'label': '當隱藏黑名單推文顯示提示訊息', // Appears next to field
			'type': 'text', // Makes this setting a text input
			'size': 35, // Limit length of input (default is 25)
			'default': '<本文作者已被列黑名單>' // Default value if user doesn't change it
		},
		'whenHideAllShowInfoColor': {
			'label': '上述提示訊息之顏色', // Appears next to field
			'type': 'text', // Makes this setting a text input
			'size': 10, // Limit length of input (default is 25)
			'default': '#c0c0c0' // Default value if user doesn't change it
		},
		'isHideViewImg': {
			'label': '是否隱藏黑名單圖片預覽', // Appears next to field
			'type': 'checkbox', // Makes this setting a checkbox input
			'default': true // Default value if user doesn't change it
		},
		'isHideViewVideo': {
			'label': '是否隱藏黑名單影片預覽', // Appears next to field
			'type': 'checkbox', // Makes this setting a checkbox input
			'default': true // Default value if user doesn't change it
		},
		'isReduceHeight': {
			'label': '是否調降黑名單推文高度', // Appears next to field
			'type': 'checkbox', // Makes this setting a checkbox input
			'default': true // Default value if user doesn't change it
		},
		'reduceHeight': {
			'label': '設定高度值(單位em)', // Appears next to field
			'type': 'float', // Makes this setting a text input
			'min': 0, // Optional lower range limit
			'max': 10, // Optional upper range limit
			'size': 10, // Limit length of input (default is 25)
			'default': 0.4 // Default value if user doesn't change it
		},
		'isReduceOpacity': {
			'label': '是否調降黑名單推文透明值', // Appears next to field
			'type': 'checkbox', // Makes this setting a checkbox input
			'default': false // Default value if user doesn't change it
		},
		'reduceOpacity': {
			'label': '設定透明值', // Appears next to field
			'type': 'float', // Makes this setting a text input
			'min': 0, // Optional lower range limit
			'max': 1, // Optional upper range limit
			'size': 10, // Limit length of input (default is 25)
			'default': 0.05 // Default value if user doesn't change it
		},
		'isAddFloorNum': {
			'label': '是否顯示推文樓層', // Appears next to field
			'type': 'checkbox', // Makes this setting a checkbox input
			'default': true // Default value if user doesn't change it
		},
		'isShowFlags': {
			'label': '看板內若有IP(ex.Gossiping),是否依IP顯示國旗', // Appears next to field
			'type': 'checkbox', // Makes this setting a checkbox input
			'default': true // Default value if user doesn't change it
		},
		'whenShowFlagsIgnoreSpecificCountrys': {
			'label': '指定國家不顯示 ex.「tw;jp」(ISO 3166-1 alpha-2)', // Appears next to field
			'type': 'text', // Makes this setting a text input
			'size': 35, // Limit length of input (default is 25)
			'default': '' // Default value if user doesn't change it
		},
		'isShowDebug': {
			'label': '是否顯示DeBug紀錄', // Appears next to field
			'type': 'checkbox', // Makes this setting a checkbox input
			'default': false // Default value if user doesn't change it
		},
	},
	'events': { // Callback functions object
		'open': () => {
			gmc.frame.setAttribute('style', "border: 1px solid #AAA;color: #999;background-color: #111; width: 23em; height: 35em; position: fixed; top: 2.5em; right: 0.5em; z-index: 9999;");
			configStatus = true;
		},
		'close': () => { configStatus = false;},
	},
	'css': `#PttChromeAddOnConfig * { color: #999 !important;background-color: #111 !important; } body#PttChromeAddOnConfig { background-color: #111}`
});

const gmcDebug = new GM_configStruct({
	'id': 'PttChromeAddOnConfigDebug', // The id used for this instance of GM_config
	'title': 'PttChrome Add-on DeBugLog', // Panel Title
	'fields': { // Fields object
		'showLog': {
			'label': 'Show log of debug text',
			'type': 'textarea',
			'default': ''
		},
	},
	'events': { // Callback functions object
		'open': () => {
			gmcDebug.frame.setAttribute('style', "border: 1px solid #AAA;color: #999;background-color: #111; width: 26em; height: 35em; position: fixed; top: 2.5em; left: 0.5em; z-index: 9999;");
		},
	},
	'css': `#PttChromeAddOnConfigDebug * { color: #999 !important;background-color: #111 !important; } body#PttChromeAddOnConfigDebug { background-color: #111} #PttChromeAddOnConfigDebug_field_showLog { width:26em; height: 24em;}`
});
const HOST = 'https://osk2.me:9977',
	  ipValidation = /(\d{1,3}\.){3}\d{1,3}/,
	  timerArray = [];

let timestamp = Math.floor(Date.now() / 1000);

const execInterval = () => {
	if (timerArray.length === 0) {
		//excute();
		timerArray.push(setInterval(excute, 1000));
	}
}
const stopInterval = () => {
	while (timerArray.length > 0) {
		clearInterval(timerArray .shift());
	}
}
const css = (elements, styles) => {
	elements = elements.length ? elements : [elements];
	elements.forEach(element => {
		for (var property in styles) {
			element.style[property] = styles[property];
		}
	});
}
const findAll = (elements, selectors) => {
	let rtnElements = [];
	elements = elements.length ? elements : [elements];
	elements.forEach(element => rtnElements.push.apply(rtnElements, element.querySelectorAll(selectors)));
	return rtnElements;
}
const innerHTMLAll = (elements) => {
	let rtn = "";
	elements = elements.length ? elements : [elements];
	elements.forEach(element => {element.innerHTML ? rtn += element.innerHTML : ""});
	return rtn;
}
const show = (elements, specifiedDisplay = 'block') => {
	elements = elements.length ? elements : [elements];
	elements.forEach(element => {
		if (!element.style) return;
		element.style.display = specifiedDisplay;
	});
}
const hide = (elements) => {
	elements = elements.length ? elements : [elements];
	elements.forEach(element => {
		if (!element.style) return;
		element.style.display = 'none';
	});
}
const generateImageHTML = (ip, flag) => {
	if (!flag) return;
	flag.countryCode = flag.countryCode ? flag.countryCode : "unknown";
	const ignoreCountrys = gmc.get('whenShowFlagsIgnoreSpecificCountrys').match(new RegExp(flag.countryCode, 'i'));
	if (ignoreCountrys && ignoreCountrys.length > 0) return;
	const imageTitile = `${flag.locationName || 'N/A'}<br><a href='https://www.google.com/search?q=${ip}' target='_blank'>${ip}</a>`;

	return `<div data-flag title="${imageTitile}" class="flag-${flag.countryCode}" style="background-repeat:no-repeat;background-position:left;float:right;height:0.8em;width:0.8em;cursor:pointer !important;"></div>`;
};
const addCssLink = (id, cssStr) => {
	let checkEl = document.querySelector(`#${id}`);
	if (checkEl) {
		checkEl.remove();
	}
	const cssLinkEl = document.createElement('link');
	cssLinkEl.setAttribute('rel', 'stylesheet');
	cssLinkEl.setAttribute('id', id);
	cssLinkEl.setAttribute('type', 'text/css');
	cssLinkEl.setAttribute('href', 'data:text/css;charset=UTF-8,' + encodeURIComponent(cssStr));
	document.head.appendChild(cssLinkEl);
}
const chkBlackSpan = (isListPage) => {
	let blackSpan = document.querySelectorAll('span[style="opacity:0.2"]');
	let whenHideAllShowInfoCss = document.querySelector('#whenHideAllShowInfo');
	if (blackSpan.length > 0) {
		writeDebugLog(`黑名單筆數:${blackSpan.length}`);
		if (whenHideAllShowInfoCss) whenHideAllShowInfoCss.remove();
		if (gmc.get('isHideAll')) {
			if (gmc.get('whenHideAllShowInfo').length > 0 || isListPage) {
				addCssLink('whenHideAllShowInfo', `
span[type="bbsrow"][style="opacity:0.2"] {opacity:1 !important;visibility: hidden;}
span[type="bbsrow"][style="opacity:0.2"]:before {
visibility: visible;color: ${gmc.get('whenHideAllShowInfoColor')};
content: '                 -            ${gmc.get('whenHideAllShowInfo')}';
}`);
			} else {
				hide(blackSpan);
			}
		} else {
			gmc.get('isHideViewImg') && hide(findAll(blackSpan, 'img:not([style*="display: none"])'));
			gmc.get('isHideViewVideo') && hide(findAll(blackSpan, '.easyReadingVideo:not([style*="display: none"])'));
			!isListPage && gmc.get('isReduceHeight') && css(blackSpan, {
				'height': gmc.get('reduceHeight') + 'em',
				'font-size': (gmc.get('reduceHeight')/2) + 'em',
				'line-height': gmc.get('reduceHeight') + 'em'
			});
			gmc.get('isReduceOpacity') && css(blackSpan, {'opacity': gmc.get('reduceOpacity')});
		}
	}
}
const findPrevious = (element, selectors) => {
	if (!element) return;
	if (element.dataset.type === 'bbsline') { //for term.ptt.cc
		element = element.closest('span[type="bbsrow"]');
		element = element.parentElement;
	}
	element = element.previousElementSibling;
	if (!element) return;
	let rtnElement = element.querySelectorAll(selectors)
	if (rtnElement && rtnElement.length > 0) {
		return rtnElement;
	} else {
		return findPrevious(element, selectors);
	}
}
const firstEl = (element) => {
	if (!element) return;
	if (element.dataset.type === 'bbsline') { //for term.ptt.cc
		element = element.closest('span[type="bbsrow"]');
		element = element.parentElement;
	}
	element = element.nextElementSibling;
	if (!element) return;
	if (element.querySelector('span[data-type="bbsline"]')) { //for term.ptt.cc
		return element.querySelector('span[data-type="bbsline"]');
	} else if (element.classList.toString().match(/blu_[^ ]+/)) {
		return element;
	} else {
		return firstEl(element);
	}
}
const queryPage = (node) => {
	let rtnPage;
	if (node && node.length > 0) {
		rtnPage = node[node.length -1].querySelector('span');
		if (!rtnPage) return;
		rtnPage = rtnPage.innerText.match(/瀏覽[^\d]+(\d+)\/(\d+)/);
		if (rtnPage && rtnPage.length === 3) {
			rtnPage = rtnPage[1];
			writeDebugLog(`警告:未啟用文章好讀模式,結果會不正確`);
			return rtnPage;
		}
	}
}
let currentNum, currentPage, pageData = {};
//document.querySelectorAll(`span[data-type="bbsline"]`)[20].innerHTML.match(/請輸入代號,或以 guest 參觀,或以 new 註冊: /)
//document.querySelectorAll(`span[data-type="bbsline"]`)[20].querySelectorAll(`span[class^="q"]`)[1].innerHTML
//document.querySelectorAll('span[data-type="bbsline"] span[class^="q11"]')
const excute = async () => {
	//console.log("do excute");
	const currentTS = Math.floor(Date.now() / 1000);

	if ((currentTS - timestamp) > 2) {
		stopInterval();
	}

	const checkNode = document.querySelector('span.q2');
	if (checkNode && checkNode.innerHTML.length <= 10) {
		chkBlackSpan(true);
		return;
	} else {
		chkBlackSpan();
	}

	let firstNode, isHasFirst, allNode = document.querySelectorAll('span[type="bbsrow"]'), bbsline = document.querySelectorAll('span[data-type="bbsline"]');
	bbsline && bbsline.length > 0 ? allNode = bbsline : null;

	currentPage = queryPage(allNode);

	let count = {author:0, comment:0, authorCnt:0, commentCnt:0, authorIp:0, commentIp:0, completed: 0};
	allNode = [].filter.call(allNode, (element, index) => {
		if (element.dataset.type === 'bbsline') { //for term.ptt.cc
			let user = element.querySelector('span[class^="q11"]')
			if (user && user.innerHTML.match(/^[^ ]+$/)) {
				user = user ? user.innerText.toLowerCase() : "";
				user ? element.classList.add(`blu_${user}`) : null;
			}
		}
		let node = element.innerHTML.match('※ 文章網址:');
		if (node && node.length > 0) {
			isHasFirst = true;
			firstNode = firstEl(element);
			if (firstNode && !firstNode.innerHTML.match(/data-floor/)) {
				pageData = [];
				currentNum = -1;
			}
		}
		if (innerHTMLAll(findAll(element, "span.q2")).match(ipValidation)) {
			count.author++;
			return true;
		}
		if (element.classList && element.classList.toString().match(/blu_[^ ]+/)) {
			count.comment++;
			return true;
		}
	});
	writeDebugLog(`偵測 作者筆數:${count.author}、留言筆數:${count.comment}`);
	let allIpList = allNode.map(c => {
		const ip = c.innerHTML.match(ipValidation);
		if (ip && !flagMap[ip[0]]) return ip[0];
	});
	allIpList = new Set(allIpList);
	allIpList.delete(undefined);
	allIpList.delete(null);
	allIpList = Array.from(allIpList);
	if (allIpList && allIpList.length > 0 && allIpList[0]) {
		try {
			const flagsResponse = await axios.post(`${HOST}/ip`, { ip: allIpList}, {headers: {'Content-Type': 'application/json',}}),
				  flags = flagsResponse.data;
			if (flags && flags.length > 0) {
				flags.forEach((flag, index) => {
					const ip = allIpList[index];
					if (!flag) {
						flag = [];
					} else if (flag.imagePath) {
						flag.countryCode = flag.imagePath.toLowerCase().replace('assets/','').replace('.png','');;
					}
					flag.ip = ip;
					flagMap[ip] = flag;
				});
			}
		} catch (ex) {
			writeDebugLog(`查詢IP失敗...${ex}`);
			console.log(ex);
		}
	}

	allNode.some((comment, index) => {
		const test = comment.innerHTML.match(/^[ \t]*\d+/);
		if (test && test.length > 0) return true;

		if (gmc.get('isAddFloorNum') && comment.classList && comment.classList.toString().match(/blu_[^ ]+/) && !comment.innerHTML.match(/data-floor/)) {
			let upstairs = null;
			if (currentNum > 0) {
				upstairs = findPrevious(allNode[index], 'div[data-floor]');
				if (upstairs && upstairs.length > 0) {
					let upstairsNum = Number(upstairs[0].innerHTML);
					if (upstairsNum) {
						currentNum = Number(upstairs[0].innerHTML) + 1;
					}
				} else if (currentPage) { //非好讀模式才有頁數
					if (!pageData[currentPage]) pageData[currentPage] = currentNum;
					currentNum = pageData[currentPage];
				} else {
					currentNum = 1;
				}
			} else if (isHasFirst && comment === firstNode) {
				currentNum = 1;
			} else if (!isHasFirst) {
				currentNum = 1;
			}
			if (currentNum > 0) {
				count.commentCnt++
				const divCnt = `<div data-floor style="float:left;margin-left: 2.2%;height: 0em;width: 1.5em;font-size: 0.4em;font-weight:bold;text-align: right;">${currentNum}</div>`;
				comment.innerHTML = divCnt + comment.innerHTML.trim();
			} else {
				const divCnt = `<div data-floor></div>`;
				comment.innerHTML = divCnt + comment.innerHTML.trim();
			}
		} else if ((gmc.get('isAddFloorNum') && comment.classList && !comment.querySelector('.q2') && !comment.classList.toString().match(/blu_[^ ]+/))) {
			writeDebugLog(`警告 推文資料格式錯誤:${comment.innerHTML}`);
		} else if (comment.innerHTML.match(/data-floor/)) {
			count.completed++;
		}

		if (!gmc.get('isShowFlags')) return;

		const ip = comment.innerHTML.match(ipValidation);

		if (!ip) return;
		if (comment.innerHTML.match(/data-flag/)) return;
		const imageHTML = generateImageHTML(ip[0], flagMap[ip[0]]);
		if (!imageHTML) return;

		const authorNode = comment.querySelector("span.q2");
		if (authorNode) {
			count.authorIp++;
			authorNode.innerHTML = imageHTML + authorNode.innerHTML.trim()
		} else {
			count.commentIp++;
			comment.innerHTML = imageHTML + comment.innerHTML.trim();
		}
		timestamp = Math.floor(Date.now() / 1000);
	});

	if (count.comment !== count.completed) {
		writeDebugLog(`寫入 作者IP數:${count.authorIp}、留言樓層:${count.commentCnt}、留言IP數:${count.commentIp}`);
	}

	tippy('[data-flag]', {
		arrow: true,
		size: 'large',
		placement: 'left',
		interactive: true
	});
}

const CreateMutationObserver = () => {
	const container = document.querySelector('#mainContainer');
	if (!container) {
		setTimeout(CreateMutationObserver, 2000);
		return;
	}
	const observer = new MutationObserver(mutations => {
		mutations.forEach(mutation => execInterval());
	})
	observer.observe(container, {childList: true,});
}

const writeDebugLog = (log) => {
	if (gmc.get('isShowDebug')) {
		let showLogTextarea = document.querySelector('#PttChromeAddOnConfigDebug');
		if (!showLogTextarea) {
			setTimeout(writeDebugLog.bind(null, log), 2000);
			return;
		}
		showLogTextarea = document.querySelector('#PttChromeAddOnConfigDebug').contentWindow.document.querySelector('textarea');
		if (!showLogTextarea) {
			setTimeout(writeDebugLog.bind(null, log), 2000);
			return;
		}
		showLogTextarea.value = `${log}\n` + showLogTextarea.value;
	}
}

try {
	window.addEventListener("load", function(event) {
		CreateMutationObserver();
	});
} catch (ex) {
	writeDebugLog(`出現錯誤...${ex}`);
	console.error(ex);
}

const _button = document.createElement("div");
_button.innerHTML = 'Settings';
_button.onclick = event => {
	if (!configStatus) {
		configStatus = true;
		gmc.open();
		if (gmc.get('isShowDebug')) gmcDebug.open();
	} else if (configStatus) {
		configStatus = false;
		gmc.close();
		if (gmc.get('isShowDebug')) gmcDebug.close();
	}
	event.preventDefault();
	event.stopPropagation();
	return false;
}
_button.style = "border: 1px solid #AAA;color: #999;background-color: #111;position: fixed; top: 0.5em; right: 0.5em; z-index: 9999;cursor:pointer !important;"

document.body.appendChild(_button)

const el = document.createElement('link');
el.rel = 'stylesheet';
el.type = 'text/css';
el.href = "https://cdnjs.cloudflare.com/ajax/libs/tippy.js/2.5.4/tippy.css";
document.head.appendChild(el);