Greasy Fork is available in English.

绅士仓库历史记录可视化

绅士仓库历史记录可视化.

// ==UserScript==
// @name		绅士仓库历史记录可视化
// @name:en		CangkuHistoryRecord
// @description		绅士仓库历史记录可视化.
// @description:en		绅士仓库历史记录可视化.
// @author		Yiero
// @version		2.5.2
// @match		https://cangku.moe/*
// @icon		https://cangku.moe/favicon.ico
// @namespace		https://github.com/AliubYiero/TamperMonkeyScripts/
// @license		GPL
// @run-at		document-idle
// @grant		GM_addStyle
// @grant		GM_setValue
// @grant		GM_getValue
// @grant		GM_addElement
// @grant		GM_registerMenuCommand
// @grant		GM_unregisterMenuCommand
// ==/UserScript==

const globalConfig = {
	// 已看帖子透明度
	opacity: 0.3,
	// 按钮显示 (true表示显示按钮, false表示关闭按钮)
	displayBtn: true,
	// 获取到元素之后的相应延时
	getElementDelayPerSecond: 0.5
};

var __defProp = Object.defineProperty;

var __defNormalProp = ( obj, key, value ) => key in obj ? __defProp( obj, key, {
	enumerable: true,
	configurable: true,
	writable: true,
	value: value
} ) : obj[key] = value;

var __publicField = ( obj, key, value ) => {
	__defNormalProp( obj, typeof key !== "symbol" ? key + "" : key, value );
	return value;
};

class GMStorage {
	constructor( key ) {
		__publicField( this, "key" );
		this.key = key;
	}
	
	set( value ) {
		dispatchEvent( new CustomEvent( "GMStorageUpdate", {
			detail: {
				newValue: value,
				oldValue: this.get(),
				target: this.key
			}
		} ) );
		GM_setValue( this.key, value );
	}
	
	get( defaultValue = null ) {
		return GM_getValue( this.key, defaultValue );
	}
	
	remove() {
		dispatchEvent( new CustomEvent( "GMStorageUpdate", {
			detail: {
				newValue: null,
				oldValue: this.get(),
				target: this.key
			}
		} ) );
		GM_deleteValue( this.key );
	}
}

const globalConfigStorage = function uploadConfig() {
	let config = {
		opacity: new GMStorage( "opacity" ),
		displayBtn: new GMStorage( "displayBtn" ),
		getElementDelayPerSecond: new GMStorage( "getElementDelayPerSecond" )
	};
	config.opacity.set( globalConfig.opacity );
	config.displayBtn.set( globalConfig.displayBtn );
	config.getElementDelayPerSecond.set( globalConfig.getElementDelayPerSecond );
	return config;
}();

const readStyle = {
	readBtnSign: {
		".is-read": {
			transition: "opacity .25s ease-in-out",
			opacity: globalConfigStorage.opacity.get()
		},
		".is-read:hover": {
			opacity: 1
		},
		".is-read, .is-not-read": {
			position: "relative"
		},
		".is-read::before": {
			content: '"已看"',
			"background-color": "hsla(0, 0%, 60%, .77)",
			"font-size": "13px",
			padding: "3px 8px",
			color: "#fff",
			"font-weight": 700,
			"border-radius": "4px",
			"z-index": 1,
			display: globalConfigStorage.displayBtn.get() ? "block" : "none"
		},
		".is-not-read::before": {
			content: '"未看"',
			"background-color": "rgba(3, 169, 244, .77)",
			"font-size": "13px",
			padding: "3px 8px",
			color: "#fff",
			"font-weight": 700,
			"border-radius": "4px",
			"z-index": 1,
			display: globalConfigStorage.displayBtn.get() ? "block" : "none"
		},
		[`.post-list .is-read::before, .post-list .is-not-read::before,\n\t\t\t.category-post .is-read::before, .category-post .is-not-read::before,\n\t\t\t.rank-post .is-read::before, .rank-post .is-not-read::before `]: {
			position: "absolute",
			top: "5px",
			left: "calc(15px + 5px)"
		},
		".related-post .is-read::before, .related-post .is-not-read::before": {
			position: "absolute",
			top: "5px",
			left: "calc(6px + 5px)"
		},
		".card-body .is-read::before, .card-body .is-not-read::before": {
			position: "absolute",
			top: "5px",
			right: "5px"
		}
	}
};

function transformedPrefSelectorList( prefSelectorList ) {
	return Object.entries( Object.values( prefSelectorList ).flat().reduce( ( ( result, current ) => ( {
		...result,
		...current
	} ) ) ) ).map( ( ( [ selector, rule ] ) => ( {
		selector: selector,
		rule: rule
	} ) ) );
}

function addReadStyle( cssRule2 ) {
	cssRule2.pushList( transformedPrefSelectorList( readStyle ) ).submit();
}

class Info {
	constructor( projectName ) {
		__publicField( this, "projectName" );
		__publicField( this, "header" );
		this.projectName = projectName;
		this.header = `[${ projectName }]`;
	}
	
	log( ...msg ) {
		( () => {} )( ...this.contentInfo( ...msg ) );
	}
	
	info( ...msg ) {
		console.info( ...this.contentInfo( ...msg ) );
	}
	
	warn( ...msg ) {
		console.warn( ...this.contentInfo( ...msg ) );
	}
	
	error( ...msg ) {
		console.error( ...this.contentInfo( ...msg ) );
	}
	
	contentInfo( ...msg ) {
		return [ this.header, ...msg ];
	}
}

class EntryBranch {
	constructor() {
		__publicField( this, "branchList", [] );
	}
	
	add( condition, callback ) {
		if ( typeof condition === "boolean" ) {
			this.branchList.push( [ () => condition, callback ] );
		}
		else {
			this.branchList.push( [ condition, callback ] );
		}
		return this;
	}
	
	default( callback ) {
		this.add( true, callback );
		return this;
	}
	
	run() {
		const entry = this.branchList.find( ( entry2 => entry2[0]() ) );
		if ( entry ) {
			new Info( "EntryBranch" ).log( "进入分支", entry );
			entry[1]();
		}
	}
}

function isMatchURL( ...regExpList ) {
	const matchResultList = [];
	regExpList.forEach( ( regExp => {
		if ( typeof regExp === "string" ) {
			regExp = new RegExp( regExp );
		}
		matchResultList.push( !!document.URL.match( regExp ) );
	} ) );
	return matchResultList.includes( true );
}

function freshListenerPushState( callback, s = 1 ) {
	let _pushState = window.history.pushState;
	window.history.pushState = function () {
		setTimeout( callback, s * 1e3 );
		return _pushState.apply( this, arguments );
	};
}

const getEl = document.querySelector.bind( document );

const getEls = document.querySelectorAll.bind( document );

function parsePost( postList, postCard ) {
	const domList = {
		postCard: postCard,
		link: postCard.querySelector( ".post-card a" ) || postCard.querySelector( ".sidebar-rank-post-wrap a" ) || postCard.querySelector( ".related-post-card a" )
	};
	if ( !domList.postCard.innerText ) {
		return;
	}
	postList.push( {
		target: postCard,
		achieveId: Number( domList.link.href.slice( domList.link.href.lastIndexOf( "/" ) + 1 ) )
	} );
}

function writeHistoryBtn( postList ) {
	for ( const post of postList ) {
		if ( post.isRead ) {
			print.log( post.achieveId, "is-read" );
			post.target.classList.remove( "is-not-read" );
			post.target.classList.add( "is-read" );
		}
		else {
			print.log( post.achieveId, "is-not-read" );
			post.target.classList.remove( "is-read" );
			post.target.classList.add( "is-not-read" );
		}
	}
}

function bindNotReadBtnEvent( postList ) {
	postList.forEach( ( post => {
		if ( !post.isRead ) {
			let bindPostClickEvent = function ( e ) {
				if ( e.button !== 2 ) {
					post.target.classList.remove( "is-not-read" );
					post.target.classList.add( "is-read" );
				}
				else {
					post.target.addEventListener( "mousedown", bindPostClickEvent, {
						once: true
					} );
				}
			};
			post.target.addEventListener( "mousedown", bindPostClickEvent, {
				once: true
			} );
		}
	} ) );
}

async function getReadHistory( postList ) {
	const postDataList = [];
	for ( const post of postList ) {
		postDataList.push( {
			...post,
			isRead: await readHistory.has( post.achieveId )
		} );
	}
	return postDataList;
}

async function parseMainPost() {
	const postList = [];
	const postCardList = getEls( "span.row .post" );
	postCardList == null ? void 0 : postCardList.forEach( ( post => {
		parsePost( postList, post );
	} ) );
	const postDataList = await getReadHistory( postList );
	writeHistoryBtn( postDataList );
	bindNotReadBtnEvent( postDataList );
}

async function parseAsidePost() {
	const postList = [];
	const postCardList = getEls( ".sidebar-rank-post-card li" );
	postCardList == null ? void 0 : postCardList.forEach( ( post => {
		parsePost( postList, post );
	} ) );
	const postDataList = await getReadHistory( postList );
	writeHistoryBtn( postDataList );
}

function getElement( parent = document.body, selector, timeoutPerSecond = 0, getElementDelayPerSecond = 0 ) {
	return new Promise( ( resolve => {
		let result = parent.querySelector( selector );
		if ( result ) {
			return resolve( result );
		}
		let timer;
		const mutationObserver = window.MutationObserver || window.WebkitMutationObserver || window.MozMutationObserver;
		if ( mutationObserver ) {
			const observer = new mutationObserver( ( mutations => {
				for ( let mutation of mutations ) {
					for ( let addedNode of mutation.addedNodes ) {
						if ( addedNode instanceof Element ) {
							result = addedNode.matches( selector ) ? addedNode : addedNode.querySelector( selector );
							if ( result ) {
								observer.disconnect();
								timer && clearTimeout( timer );
								setTimeout( ( () => resolve( result ) ), getElementDelayPerSecond * 1e3 );
							}
						}
					}
				}
			} ) );
			observer.observe( parent, {
				childList: true,
				subtree: true
			} );
			if ( timeoutPerSecond > 0 ) {
				timer = setTimeout( ( () => {
					observer.disconnect();
					return resolve( null );
				} ), timeoutPerSecond * 1e3 );
			}
		}
	} ) );
}

async function HomePageEntry() {
	var _a;
	await getElement( document.body, ".post-card-wrap .cover", 20, globalConfig.getElementDelayPerSecond );
	await parseMainPost();
	await getElement( document.body, ".sidebar-rank-post-wrap .cover", 20, globalConfig.getElementDelayPerSecond );
	await parseAsidePost();
	( _a = getEl( ".nav-pills" ) ) == null ? void 0 : _a.addEventListener( "click", ( () => {
		setTimeout( ( () => {
			HomePageEntry();
		} ), 500 );
	} ), {
		once: true
	} );
}

function promisifyRequest( request ) {
	return new Promise( ( ( resolve, reject ) => {
		request.oncomplete = request.onsuccess = () => resolve( request.result );
		request.onabort = request.onerror = () => reject( request.error );
	} ) );
}

function createStore( dbName, storeName ) {
	const request = indexedDB.open( dbName );
	request.onupgradeneeded = () => request.result.createObjectStore( storeName );
	const dbp = promisifyRequest( request );
	return ( txMode, callback ) => dbp.then( ( db => callback( db.transaction( storeName, txMode ).objectStore( storeName ) ) ) );
}

let defaultGetStoreFunc;

function defaultGetStore() {
	if ( !defaultGetStoreFunc ) {
		defaultGetStoreFunc = createStore( "keyval-store", "keyval" );
	}
	return defaultGetStoreFunc;
}

function get( key, customStore = defaultGetStore() ) {
	return customStore( "readonly", ( store => promisifyRequest( store.get( key ) ) ) );
}

function setMany( entries, customStore = defaultGetStore() ) {
	return customStore( "readwrite", ( store => {
		entries.forEach( ( entry => store.put( entry[1], entry[0] ) ) );
		return promisifyRequest( store.transaction );
	} ) );
}

function eachCursor( store, callback ) {
	store.openCursor().onsuccess = function () {
		if ( !this.result ) return;
		callback( this.result );
		this.result.continue();
	};
	return promisifyRequest( store.transaction );
}

function values( customStore = defaultGetStore() ) {
	return customStore( "readonly", ( store => {
		if ( store.getAll ) {
			return promisifyRequest( store.getAll() );
		}
		const items = [];
		return eachCursor( store, ( cursor => items.push( cursor.value ) ) ).then( ( () => items ) );
	} ) );
}

class ReadHistory {
	constructor() {
		__publicField( this, "store" );
		this.store = createStore( "animax-post", "post-view-history" );
	}
	
	async has( key ) {
		return !!( await get( key, this.store ) );
	}
	
	getAll() {
		return values( this.store );
	}
	
	async setList( historyList ) {
		const entries = [];
		historyList.forEach( ( history => {
			entries.push( [ void 0, history ] );
		} ) );
		await setMany( entries, this.store );
	}
}

async function categoryPageEntry() {
	await getElement( document.body, ".post-card-wrap .cover", 20, globalConfig.getElementDelayPerSecond );
	await parseMainPost();
}

class CSSRule {
	constructor() {
		__publicField( this, "cssRuleSet", new Set );
		__publicField( this, "styleDom", document.createElement( "style" ) );
	}
	
	push( selector, rule ) {
		let ruleString = "";
		for ( let ruleKey in rule ) {
			const ruleValue = rule[ruleKey];
			ruleString += `${ ruleKey }:${ ruleValue };`;
		}
		this.cssRuleSet.add( `${ selector } {${ ruleString }}` );
		return this;
	}
	
	pushImportant( selector, rule ) {
		let ruleString = "";
		for ( let ruleKey in rule ) {
			let ruleValue = rule[ruleKey];
			if ( typeof ruleValue === "string" ) {
				ruleValue = ruleValue.replace( "!important", "" );
			}
			ruleString += `${ ruleKey }:${ ruleValue } !important;`;
		}
		this.cssRuleSet.add( `${ selector } {${ ruleString }}` );
		return this;
	}
	
	pushHide( selector ) {
		this.pushImportant( selector, {
			display: "none"
		} );
		return this;
	}
	
	pushHideList( selectorList ) {
		selectorList.forEach( ( selector => {
			this.pushImportant( selector, {
				display: "none"
			} );
		} ) );
		return this;
	}
	
	pushList( ruleList ) {
		ruleList.forEach( ( ( { selector: selector, rule: rule } ) => {
			this.push( selector, rule );
		} ) );
		return this;
	}
	
	pushImportantList( ruleList ) {
		ruleList.forEach( ( ( { selector: selector, rule: rule } ) => {
			this.pushImportant( selector, rule );
		} ) );
		return this;
	}
	
	submit() {
		this.removeAll();
		new Info( "AddStyle" ).log( Array.from( this.cssRuleSet ).join( " " ) );
		this.styleDom = GM_addStyle( Array.from( this.cssRuleSet ).join( " " ) );
	}
	
	removeAll() {
		if ( this.styleDom ) {
			this.styleDom.remove();
		}
		return this;
	}
}

async function rankPageEntry() {
	var _a;
	await getElement( document.body, ".post-card-content > .cover", 20, globalConfig.getElementDelayPerSecond );
	await parseMainPost();
	( _a = getEl( ".rank-wrapper" ) ) == null ? void 0 : _a.addEventListener( "click", ( () => {
		setTimeout( rankPageEntry, 500 );
	} ), {
		once: true
	} );
}

async function parseAchieveBottomPost() {
	const postList = [];
	const postCardList = getEls( ".related-post .related-post-card" );
	postCardList == null ? void 0 : postCardList.forEach( ( post => {
		parsePost( postList, post );
	} ) );
	const postDataList = await getReadHistory( postList );
	writeHistoryBtn( postDataList );
}

async function achievePageEntry() {
	var _a;
	await getElement( document.body, ".related-post-card .cover", 20, globalConfig.getElementDelayPerSecond );
	await parseAchieveBottomPost();
	await getElement( document.body, ".sidebar-rank-post-wrap .cover", 20, globalConfig.getElementDelayPerSecond );
	await parseAsidePost();
	( _a = getEl( ".nav-pills" ) ) == null ? void 0 : _a.addEventListener( "click", ( () => {
		setTimeout( ( () => {
			achievePageEntry();
		} ), 500 );
	} ), {
		once: true
	} );
}

async function historyBackup() {
	const historyList = await readHistory.getAll();
	const GMHistory = new GMStorage( "history" );
	const GMHistoryList = GMHistory.get( [] );
	print.info( "正在比较备份记录条目: ", `脚本储存条目(${ GMHistoryList.length })`, `仓库本地储存条目(${ historyList.length })` );
	if ( GMHistoryList.length > historyList.length ) {
		print.info( `历史记录丢失, 正在将备份写入历史记录: `, GMHistoryList );
		await readHistory.setList( GMHistoryList );
	}
	else if ( GMHistoryList.length < historyList.length ) {
		print.info( "正在备份历史记录到脚本储存中. " );
		GMHistory.set( [ ...new Set( [ ...GMHistoryList, ...historyList ] ) ] );
	}
}

async function pushNewReadAchieve() {
	const achieveId = document.URL.slice( document.URL.lastIndexOf( "/" ) + 1 );
	if ( await readHistory.has( Number( achieveId ) ) ) {
		localStorage.setItem( "history", achieveId );
	}
}

const downloadFile = ( blob, fileName ) => {
	const url = URL.createObjectURL( blob );
	const a = document.createElement( "a" );
	a.href = url;
	a.download = fileName;
	a.click();
	URL.revokeObjectURL( url );
	a.remove();
};

function importJson() {
	const importInput = GM_addElement( "input", {
		type: "file",
		accept: ".json"
	} );
	importInput.click();
	return new Promise( ( resolve => {
		importInput.addEventListener( "change", ( e => {
			const inputDom = e.target;
			if ( inputDom.files ) {
				const fileReader = new FileReader;
				fileReader.readAsText( inputDom.files[0] );
				fileReader.onload = () => {
					resolve( fileReader.result || "" );
				};
			}
		} ) );
	} ) );
}

class SaveBtn {
	constructor() {
		__publicField( this, "domList", {
			ulList: getEl( ".menu-group-list" )
		} );
	}
	
	download() {
		var _a;
		const downloadBtn = GM_addElement( this.domList.ulList, "li", {
			class: "menu-list-item"
		} );
		downloadBtn.innerHTML = `\n\t\t\t<a class="router-link-exact-active download-btn" href="javascript:;">保存历史记录</a>\n\t\t`;
		( _a = downloadBtn.querySelector( ".download-btn" ) ) == null ? void 0 : _a.addEventListener( "click", ( async () => {
			downloadFile( new Blob( [ JSON.stringify( await readHistory.getAll() ) ], {
				type: "application/json"
			} ), "ReadHistoryBackUp.json" );
		} ) );
	}
	
	import() {
		var _a;
		const downloadBtn = GM_addElement( this.domList.ulList, "li", {
			class: "menu-list-item"
		} );
		downloadBtn.innerHTML = `\n\t\t\t<a class="router-link-exact-active import-btn" href="javascript:;">导入历史记录</a>\n\t\t`;
		( _a = downloadBtn.querySelector( ".import-btn" ) ) == null ? void 0 : _a.addEventListener( "click", ( async () => {
			const historyJsonString = await importJson();
			const historyArray = JSON.parse( historyJsonString );
			if ( Array.isArray( historyArray ) ) {
				const isRightHistoryArray = !historyArray.find( ( ( { id: id, date: date } ) => !( id && date ) ) );
				if ( isRightHistoryArray ) {
					await readHistory.setList( historyArray );
					alert( "导入历史记录成功. " );
					return;
				}
			}
			else {
				alert( "导入Json文件为错误的历史记录文件. " );
			}
		} ) );
	}
}

async function addFreshBtn( entryBranch ) {
	var _a;
	await getElement( document.body, ".navbar-nav", 0, .5 );
	const freshBtn = GM_addElement( getEl( ".navbar-nav" ), "li", {
		class: "nav-item"
	} );
	const dataHash = getEl( ".navbar-nav > .nav-item" ).getAttributeNames().find( ( attribute => attribute.startsWith( "data-v" ) ) );
	freshBtn.innerHTML = `\n\t\t<a class="text-truncate fresh-btn" ${ dataHash } href="javascript:;">刷新</a>\n\t`;
	( _a = freshBtn.querySelector( ".fresh-btn" ) ) == null ? void 0 : _a.addEventListener( "click", ( () => {
		entryBranch.run();
	} ) );
}

const print = new Info( "postCardList" );

const cssRule = new CSSRule;

const readHistory = new ReadHistory;

( async () => {
	addReadStyle( cssRule );
	await historyBackup();
	const entryBranch = new EntryBranch;
	entryBranch.add( ( () => isMatchURL( "^https://cangku.moe/$" ) ), HomePageEntry ).add( ( () => isMatchURL( "^https://cangku.moe/\\?page" ) ), ( async () => {
		await HomePageEntry();
		cssRule.pushHide( ".global-announce" ).submit();
	} ) ).add( ( () => isMatchURL( /https:\/\/cangku.moe\/archives\/\d+/ ) ), ( async () => {
		await achievePageEntry();
		await pushNewReadAchieve();
	} ) ).add( ( () => isMatchURL( /https:\/\/cangku.moe\/category\/\d+/ ) ), categoryPageEntry ).add( ( () => isMatchURL( "https://cangku.moe/rank" ) ), rankPageEntry ).add( ( () => isMatchURL( "https://cangku.moe/account/" ) ), ( async () => {
		await getElement( document.body, ".menu-group-list", 0, .5 );
		const saveBtn = new SaveBtn;
		saveBtn.download();
		saveBtn.import();
	} ) ).run();
	freshListenerPushState( ( () => {
		entryBranch.run();
	} ) );
	await addFreshBtn( entryBranch );
} )();