PttChrome Add-on (Ptt)

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

目前为 2018-09-29 提交的版本。查看 最新版本

// ==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.3.6
// @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, configDebugStatus = 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.1 // 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': function() {
		'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': function() {
		'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;");
			configDebugStatus = true;
		},
		'close': () => { configDebugStatus = false;},
	},
	'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 chkBlackSpan = () => {
	let blackSpan = document.querySelectorAll('span[style="opacity:0.2"]');
	let whenHideAllShowInfoCss = document.querySelector('#whenHideAllShowInfo');
	if (blackSpan.length > 0) {
		writeDebugLog(`黑名單筆數:${blackSpan.length}`);
		if (gmc.get('isHideAll')) {
			if (gmc.get('whenHideAllShowInfo').length > 0) {
				if (whenHideAllShowInfoCss) {
					whenHideAllShowInfoCss.remove();
				}
				const cssLinkEl = document.createElement('link');
				cssLinkEl.setAttribute('rel', 'stylesheet');
				cssLinkEl.setAttribute('id', 'whenHideAllShowInfo');
				cssLinkEl.setAttribute('type', 'text/css');
				cssLinkEl.setAttribute('href', 'data:text/css;charset=UTF-8,' + encodeURIComponent(`
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')}';
}
`));
				document.head.appendChild(cssLinkEl);
			} else {
				if (whenHideAllShowInfoCss) {
					whenHideAllShowInfoCss.remove();
				}
				hide(blackSpan);
			}
		} else {
			if (whenHideAllShowInfoCss) {
				whenHideAllShowInfoCss.remove();
			}
			gmc.get('isHideViewImg') && hide(findAll(blackSpan, 'img:not([style*="display: none"])'));
			gmc.get('isHideViewVideo') && hide(findAll(blackSpan, '.easyReadingVideo:not([style*="display: none"])'));
			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;
	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;
	element = element.nextElementSibling;
	if (!element) return;
	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 = {};
const excute = async () => {
	//console.log("do excute");
	const currentTS = Math.floor(Date.now() / 1000);

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

	let firstNode, isHasFirst;

	chkBlackSpan();

	let allNode = document.querySelectorAll('span[type="bbsrow"]');
	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) => {
		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 => {
			const checkNode = document.querySelector('span.q2');
			if (checkNode && checkNode.innerHTML.length > 10) {
				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();
	} else if (configStatus) {
		configStatus = false;
		gmc.close();
	}
	if (gmc.get('isShowDebug')) {
		if (!configDebugStatus) {
			configDebugStatus = true;
			gmcDebug.open();
		} else if (configDebugStatus) {
			configDebugStatus = false;
			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);