pixivイラストページ改善

pixivイラストページのタグに作者マーカーと百科事典アイコン、ユーザー名の列に作品タグを復活させます

// ==UserScript==
// @name        pixivイラストページ改善
// @description pixivイラストページのタグに作者マーカーと百科事典アイコン、ユーザー名の列に作品タグを復活させます
// @namespace   Aime
// @match       https://www.pixiv.net/*
// @version     1.2.7
// @grant       none
// @run-at      document-end
// @noframes
// @note        2018/06/22 1.0.1 作者アイコンを大サイズに差し替え
// @note        2018/07/18 1.0.2 pixiv側のclass変更に対応
// @note        2018/07/26 1.0.3 アイコン差し替えを修正
// @note        2018/09/23 1.1.0 新プロフィールページのサムネイルをトリミングなしに差し替え。作品タグをapiから取得
// @note        2018/10/03 1.1.1 サムネイル差し替え修正
// @note        2018/12/28 1.1.2 アイコン差し替え修正
// @note        2019/01/16 1.1.3 アイコン差し替え修正等
// @note        2019/01/16 1.1.4 タグクラウドの広告ブロックフィルタ誤爆対策
// @note        2019/02/20 1.1.5 article→main
// @note        2019/04/23 1.2.0 いろいろ修正
// @note        2019/04/23 1.2.2 アイコン修正
// @note        2019/09/15 1.2.3
// @note        2019/09/24 1.2.4 member_illust.php?mode=medium&illust_id= → artworks/
// @note        2019/09/24 1.2.5 custom_thumb差し替え
// @note        2020/01/08 1.2.6 member.php?id= → users/ 等
// @note        2020/07/07 1.2.7 ページ遷移検出簡素化、ダークテーマ対応等
// ==/UserScript==
// jshint esversion:6
(function() {
"use strict";
const pixivService = {
	PixpediaIcon			: 1,		// 百科事典アイコンを付けるか? (0:付けない, 1:付ける, 2:記事の有無でアイコンを変える)
	UseTagCloud				: true,		// 作品タグを表示するか?
	UseLargeIcon			: true,		// 作者アイコンを大きくする
	NonTrimThumbnail		: true,		// サムネイルをトリミングなしにする

	tagCloudDisplayCount	: 30,		// 作品タグの表示数(目安)
	tagCloudSortByName		: true,		// 作品タグのソート順 (true:名前順, false:多い順)

	_illustTagCache			: {},
	_existsPixpedia			: {},
	_currentAuthorId		: -1,
	_tagCloud				: null,


	run() {
		const root = document.getElementById("root");
		if (!root) return;

		const style = $C("style");
		style.textContent = this._style;
		document.querySelector("head").appendChild(style);

		root.addEventListener("click", this, true);

		this.delayTagMarking();

		delayExec(() => {
			return !!window.__ankpixiv_pushstate_override;
		}, 200, 5).then(() => {
			console.log("use AnkPixiv 3.0.13+ history message");
			window.addEventListener("message", event => {
				if (event.data.type === "AnkPixiv.onPushState" || event.data.type === "AnkPiviv.onReplaceState") {
					this.delayTagMarking();
				}
			});
			window.addEventListener("popstate", event => this.delayTagMarking());
		}).catch(() => {
			new HistoryChangeEmitter(type => this.delayTagMarking());
		});


		this._themeObserver = null;

		const options = {
			childList		: true,
			subtree			: true,
			attributes		: true,
			attributeFilter	: [ /*"href",*/ "src" ]
		};

		const ob = new MutationObserver(records => {
			let thumbs = [], iconChange = false;

			const parseImg = target => {
				if (target.nodeName === "IMG") {
					if (this.NonTrimThumbnail && (target.src.includes("_square1200.jpg") || target.src.includes("_custom1200.jpg"))) {
						thumbs.push(target);
					} else if (target.getAttribute("width") == 40 && (target.src.includes("/user-profile/") || target.src.includes("no_profile")) && target.closest("ASIDE")) {
						iconChange = true;
					}
				}
			};

			records.forEach(record => {
				switch (record.type) {
				case "attributes": {
					const target = record.target;
					switch (record.attributeName) {
/*					case "href":
						if (target.href.includes("/users/") && target.querySelector(':scope > [role="img"]') && target.closest("ASIDE")) {
							iconChange = true;
						}
						break;*/
					case "src":
						parseImg(target);
						break;
					}
					break; }

				default:
					record.addedNodes.forEach(node => parseImg(node));
					break;
				}
			});

			if (!this._themeObserver) this.installThemeObserver();
			if (thumbs.length || iconChange) {
				ob.disconnect();
				if (thumbs.length) this.replaceThumbnail(thumbs);
				if (iconChange) this.onAuthorChange();
				ob.observe(root, options);
			}
		});
		ob.observe(root, options);
	},

	handleEvent(event) {
		switch (event.type) {
			case "mouseover":
				event.stopPropagation();
				break;
			case "click":
				if (event.target.classList.contains("gm-profile-work-list-tag-filter-click")) {
					this.openTagPage(event);
				}
				break;
			default:
				console.log(event);
				break;
		}
	},

	openTagPage(event) {
		const url = location.href;
		for (let p of [
			{ re: /users\/(\d+)\/novels|novel\/member\.php\?id=(\d+)/,	url: "https://www.pixiv.net/novel/member_tag_all.php?id=" },
			{ re: /users\/(\d+)\/|member_illust\.php\?id=(\d+)/,	url: "https://www.pixiv.net/member_tag_all.php?id=" },
		]) {
			const match = p.re.exec(url);
			if (match) {
				event.stopPropagation();
				event.preventDefault();
				location.href = p.url + match[1];
				return;
			}
		}
	},

	installThemeObserver() {
		const theme = document.getElementById("gtm-var-theme-kind");
		if (theme) {
			this._themeObserver = new MutationObserver(records => this.checkTheme());
			this._themeObserver.observe(theme, { characterData: true, subtree: true });
			this.checkTheme();
		}
	},

	checkTheme() {
		const theme = document.getElementById("gtm-var-theme-kind");
		if (theme) {
			const html = document.documentElement;
			if (theme.textContent === "dark") {
				html.setAttribute("dark-theme", true);
			} else {
				html.removeAttribute("dark-theme");
			}
		}
	},

	getIllustId() {
		const m = /(?:\/artworks\/|illust_id=)(\d+)/.exec(location.href);
		return m? parseInt(m[1], 10): null;
	},
	getAuthorId() {
		const a = document.querySelector("main + aside h2 a");
		if (!a) return null;
		const m = /(?:\/users\/|\/member\.php\?id=)(\d+)/.exec(a.href);
		return m? parseInt(m[1], 10): null;
	},

	async delayTagMarking() {
		const url = location.href;
		setTimeout(() => {
			if (url === location.href) this.tagMarking();
		}, 1000);
	},
	async tagMarking() {
		const illustId = this.getIllustId();
		if (!illustId) return;

		const displayedTags = await delayExec(() => {
			const nodes =document.body.querySelectorAll("figcaption footer > ul a.gtm-new-work-tag-event-click");
			return nodes.length > 0? nodes: false;
		});

//		const displayedTags = document.body.querySelectorAll("figcaption footer > ul a.gtm-new-work-tag-event-click");
		if (displayedTags.length === 0) return;

		if (!(illustId in this._illustTagCache)) {
			try {
				this._illustTagCache[illustId] = await fetchJSON("https://www.pixiv.net/ajax/tags/illust/" + illustId);
			} catch (e) {
				console.error(e);
			}
		}

		let tagData = this._illustTagCache[illustId];
		if (!tagData) tagData = { authorId: 0, tags: [] };
		const authorId = tagData.authorId;

		for (let node of displayedTags) {
			const cls = node.parentElement.classList;
			const tag = node.textContent.trim();
			const find = tagData.tags.find(t => t.tag == tag);
			if (find && find.userId == authorId) {
				cls.add("author-tag-marker");
			} else {
				cls.remove("author-tag-marker");
			}

			this.appendPixpediaIcon(node);
		}
	},

	async appendPixpediaIcon(node) {
		if (!this.PixpediaIcon || node.hasAttribute("pixpedia")) return;

		node.setAttribute("pixpedia", true);
		node.addEventListener("mouseover", this, true);
		const eTag = encodeURIComponent(node.textContent.trim());
		let cls = "pixpedia-icon";

		if (this.PixpediaIcon === 2) {
			try {
				if (!(eTag in this._existsPixpedia)) {
					this._existsPixpedia[eTag] = !!await fetchJSON("https://www.pixiv.net/ajax/tag/info?tag=" + eTag);
				}
				if (!this._existsPixpedia[eTag]) cls += " pixpedia-icon-no-item";
			} catch (e) {
				console.error(e);
			}
		}

		$C("a", {
			class	: cls,
			href	: "https://dic.pixiv.net/a/" + eTag
		}, node.parentElement);
	},

	onAuthorChange() {
		if (this.UseLargeIcon) this.largeAuthorIcon();
		if (this.UseTagCloud) this.appendTagCloud();
	},

	async appendTagCloud() {
		const aside = document.querySelector("main + aside");
		if (!aside) return;

		const authorId = this.getAuthorId();
		if (!authorId) return;

		const tagAllUrl = "https://www.pixiv.net/member_tag_all.php?id=" + authorId;

		if (this._currentAuthorId !== authorId) {
			this._currentAuthorId = authorId;
			try {
				let tags = await fetchJSON("https://www.pixiv.net/ajax/user/" + authorId + "/illustmanga/tags");
				tags.sort(this.compareTagByCount);	// 多い順にソート

				const dispCnt = this.tagCloudDisplayCount;
				if (tags.length > dispCnt) {
					// とりあえず目安位置以下の値を破棄
					const lastCnt = tags[dispCnt - 1].cnt;
					tags = tags.filter(v => v.cnt >= lastCnt);
					const tags2 = tags.filter(v => v.cnt > lastCnt);
					// 目安位置と同数とそれより多いのがどちらが目安位置に近いか
					if (dispCnt - tags2.length < tags.length - dispCnt && tags2.length > 5) {
						tags = tags2;
					}
				}

				if (tags.length) {
					let lv = 1,
						cur = tags[0].cnt;
					tags.forEach(tag => {
						// レベル付け
						if (lv < 6 && cur !== tag.cnt) {
							cur = tag.cnt;
							lv++;
						}

						// <li class="level1"><a href="/member_illust.php?id=${authorId}&amp;tag=${tag.tag}">${tag.tag}<span class="cnt">(${tag.cnt})</span></a></li>
						tag.dom = $C("li", { class: "level" + lv, "data-cnt": tag.cnt, "data-tag": tag.tag });
						const a = $C("a", { href: "/member_illust.php?id=" + authorId + "&tag=" + encodeURIComponent(tag.tag) }, tag.dom);
						a.textContent = tag.tag;
						const span = $C("span", { class: "cnt" }, a);
						span.textContent = "(" + tag.cnt + ")";
					});
				}

				if (this.tagCloudSortByName) {
					tags.sort(this.compareTagByName);
				}

				const tagCloud = $C("ul", { class: "tagCloud" });
				tags.forEach(tag => tagCloud.appendChild(tag.dom));
				this._tagCloud = tagCloud;

			} catch (e) {
				console.error(e);
				this._tagCloud = null;
			}
		}

		let container = document.getElementById("author-tags");
		if (container) {
			container.parentElement.removeChild(container);
		}
		if (this._tagCloud) {
			container = $C("nav", {
				id:		"author-tags",
				class:	"sc-",
			});

			const header = $C("div", { class: "tags-header" }, container);
			header.innerHTML = `<h2><a href="${tagAllUrl}">作品タグ</a></h2>`;

			const sortBtn = $C("button", { class: "sort-button" }, header);
			sortBtn.textContent = "▼";
			sortBtn.addEventListener("click", event => {
				const tags = document.querySelector("#author-tags .tagCloud");
				if (tags) {
					const byName = this.tagCloudSortByName = !this.tagCloudSortByName;
					Array.from(tags.querySelectorAll("li"))
					.map(v => { return { dom: v, cnt: v.dataset.cnt, tag: v.dataset.tag }; })
					.sort(byName? this.compareTagByName: this.compareTagByCount)
					.forEach(v => tags.appendChild(v.dom));
				}
			});

			container.appendChild(this._tagCloud);

			/* 前後の作品の次に挿入 */
			let next = aside.querySelector("main + aside > section > nav");
			if (next) {
				next = next.parentElement;
				if (next) next = next.nextSibling;
			}
			aside.insertBefore(container, next);
		}
	},
	compareTagByCount(a, b) {
		const r = b.cnt - a.cnt;
		return r? r: pixivService.compareTagByName(a, b);
	},
	compareTagByName(a, b) {
		return a.tag.localeCompare(b.tag, {}, { numeric: true });
	},

	largeAuthorIcon() {
		const icon = document.querySelector('main + aside > section > h2 [role="img"] > img');
		if (icon) {
			const img = icon.src.replace("_50.", "_170.").replace("_s.", ".");
			if (icon.src !== img) {
				icon.src = img;
			}
			const p = icon.parentElement.closest("A");
			if (p) {
				p.parentElement.classList.add("icon170");
			}
		}
	},

	replaceThumbnail(elems) {
		elems.forEach(elem => {
			let img = elem.src;
			let img_r = img.replace(/(?:250x250_80_a2|360x360_70)\/(?:img-master|custom-thumb)(.+)(?:_square1200|_custom1200)/, "240x240/img-master$1_master1200");
			if (img_r.includes("_master1200.jpg") /* /240x240.+_master1200/.test(img_r) */) {
				elem.style.setProperty("object-fit", "contain");
				if (img !== img_r) {
					elem.src = img_r;
				}
			}
		});
	},


	_style: `
/* 百科事典 */
.pixpedia-icon {
	display: inline-block;
	margin-left: 2px;
	width: 15px;
	height: 14px;
	vertical-align: -2px;
	text-decoration: none;
	background: url(https://s.pximg.net/www/images/inline/pixpedia.png) no-repeat;
}
.pixpedia-icon-no-item {
	background: url(https://s.pximg.net/www/images/inline/pixpedia-no-item.png) no-repeat;
}
.pixpedia-icon::before {
	display: none;
}

/* 作者タグ */
.author-tag-marker::before {
	content: "*" !important;
	color: #E66;
}
/* "#"を消す */
figcaption footer > ul > li a.gtm-new-work-tag-event-click::before {
	display: none !important;
}

/* tag cloud */
#author-tags {
	padding: 8px;
	margin-bottom: 8px;
	background-color: #FFF;
	border-radius: 8px;
}
#author-tags .tags-header {
	display: flex;
	justify-content: space-between;
	align-items: center;
	margin-bottom: 8px;
}
#author-tags h2 {
	color: #333;
	font-size: 14px;
	margin: 0;
}
#author-tags h2 a {
	color: inherit;
	text-decoration: none;
}
#author-tags .sort-button {
	padding: 0;
	font-size: 14px;
	background: none;
	border: none;
	color: inherit;
	cursor: pointer;
}
.tagCloud {
	font-size: 12px;
	line-height: 1.6;
	padding: 0;
	margin: 0;
	word-break: break-all;
}
.tagCloud li {
	display: inline;
	font-size: 12px;
	padding: 0px 2px;
	margin: 0px;
}
.tagCloud li a {
	color: inherit;
	text-decoration: none;
}
.tagCloud li.level1 {
	font-size: 20px;
	font-weight: bold;
}
.tagCloud li.level1 a {
	color: #3E5B71;
}
.tagCloud li.level2 {
	font-size: 18px;
	font-weight: bold;
}
.tagCloud li.level2 a {
	color: #3E5B71;
}
.tagCloud li.level3 {
	font-size: 17px;
	font-weight: bold;
}
.tagCloud li.level3 a {
	color: #587C97;
}
.tagCloud li.level4 {
	font-size: 16px;
	font-weight: bold;
}
.tagCloud li.level4 a {
	color: #587C97;
}
.tagCloud li.level5 {
	font-size: 14px;
	font-weight: bold;
}
.tagCloud li.level5 a {
	color: #587C97;
}
.tagCloud li.level6 a {
	color: #5E9ECE;
}
.tagCloud li a:hover {
	background-color: #3E5B71;
	color: #FFF;
}
.tagCloud li .cnt {
	font-size: 11px;
	font-weight: normal;
	color: #999999;
}
[dark-theme] #author-tags { background-color: #333; }
[dark-theme] #author-tags h2 { color: #DDD; }
[dark-theme] #author-tags .level1 > a { color: Lightpink; }
[dark-theme] #author-tags .level2 > a { color: Coral; }
[dark-theme] #author-tags .level3 > a { color: Gold; }
[dark-theme] #author-tags .level4 > a { color: DarkTurquoise; }
[dark-theme] #author-tags .level5 > a { color: Lightskyblue; }
[dark-theme] #author-tags .level6 > a { color: Silver; }

/* 作者アイコンを大きく */
main + aside section {
	margin-top: 0;
}
.icon170 {
	display: block !important;
	text-align: center !important;
	margin-left: auto !important;
	margin-right: auto !important;
}
.icon170 [role="img"] {
	width: 170px !important;
	height: 170px !important;
	margin: 0 auto 4px !important;
	border-radius: 4px !important;
	background-position: center !important;
	background-repeat: no-repeat !important;
	background-size: contain !important;
}
.icon170 [role="img"] > img {
	width: 170px;
	height: 170px;
	object-position: center !important;
}
`
};

function fetchSameOrigin(url) {
	return fetch(url, { mode: "same-origin", credentials: "same-origin" });
}

async function fetchJSON(url) {
	const response = await fetchSameOrigin(url);
	const data = await response.json();
	if (data.error) throw new Error(url + " : " + data.message);
	return data.body;
}

function $C(tag, attrs, parent, before) {
	const e = document.createElement(tag);
	if (attrs) Object.entries(attrs).forEach(([key, value]) => e.setAttribute(key, value));
	if (parent) parent.insertBefore(e, before);
	return e;
}

async function delayExec(func, delay = 500, count = 10) {
	const ret = func();
	if (ret) return ret;
	return new Promise((resolve, reject) => {
		const t = setInterval(() => {
			const ret = func();
			if (ret) {
				clearInterval(t);
				resolve(ret);
			}
			if (--count <= 0) {
				clearInterval(t);
				reject(new Error("delayExec: retry over"));
			}
		}, delay);
	});
}

class HistoryChangeEmitter {
	constructor(callback) {
		this._callback = callback;
		this._pushState = window.history.pushState;
		this._replaceState = window.history.replaceState;
		window.history.pushState = this.pushState.bind(this);
		window.history.replaceState = this.replaceState.bind(this);
		window.addEventListener("popstate", this.popState.bind(this));
	}
	pushState(...args) {
		this._pushState.apply(window.history, args);
		this._callback("pushState", args);
	}
	replaceState(...args) {
		this._replaceState.apply(window.history, args);
		this._callback("replaceState", args);
	}
	popState(event) {
		this._callback("popState", [ event.state ]);
	}
}

pixivService.run();
})();