pixiv タグクラウドからピックアップ

Restores the tag cloud (illustration or novel tags column), and if there are tags attached to a work, this script brings those tags to the top of the tag cloud (illustration tags column).

  1. // ==UserScript==
  2. // @name pixiv タグクラウドからピックアップ
  3. // @name:ja pixiv タグクラウドからピックアップ
  4. // @name:en pixiv Tag Cloud Prioritizer
  5. // @description Restores the tag cloud (illustration or novel tags column), and if there are tags attached to a work, this script brings those tags to the top of the tag cloud (illustration tags column).
  6. // @description:ja 作品ページへタグクラウド (作品タグ・小説タグ) を復活させ、閲覧中の作品についているタグと同じものをピックアップします。
  7. // @namespace https://userscripts.org/users/347021
  8. // @version 3.1.2
  9. // @match https://www.pixiv.net/*
  10. // @require https://gitcdn.xyz/cdn/greasemonkey/gm4-polyfill/a834d46afcc7d6f6297829876423f58bb14a0d97/gm4-polyfill.js
  11. // @require https://unpkg.com/compare-versions@5.0.1/lib/umd/index.js
  12. // @require https://greasyfork.org/scripts/19616/code/utilities.js?version=752462
  13. // @license MPL-2.0
  14. // @contributionURL https://www.amazon.co.jp/registry/wishlist/E7PJ5C3K7AM2
  15. // @compatible Edge 最新安定版 / Latest stable
  16. // @compatible Firefox 推奨 (Recommended)
  17. // @compatible Opera
  18. // @compatible Chrome
  19. // @grant GM.setValue
  20. // @grant GM_setValue
  21. // @grant GM.getValue
  22. // @grant GM_getValue
  23. // @grant GM.deleteValue
  24. // @grant GM_deleteValue
  25. // @grant GM.listValues
  26. // @grant GM_listValues
  27. // @noframes
  28. // @run-at document-end
  29. // @icon https://www.pixiv.net/favicon.ico
  30. // @author 100の人
  31. // @homepageURL https://greasyfork.org/scripts/262
  32. // ==/UserScript==
  33. /*global compareVersions, h, DateUtils */
  34.  
  35. 'use strict';
  36.  
  37. /**
  38. * タグ一覧ページをキャッシュしておく期間 (秒数)。
  39. * @constant {number}
  40. */
  41. const CACHE_LIFETIME = 24 * 60 * 60;
  42.  
  43. /**
  44. * @typedef {Object} TagsData
  45. * @property {HTMLDivElement} tagCloudSection - タグクラウド。
  46. * @property {Object.<number>} tagsAndCounts - タグをキー、タグの出現数を値に持つ連想配列。
  47. */
  48.  
  49. if (typeof content !== 'undefined') {
  50. // For Greasemonkey 4
  51. XMLHttpRequest = content.XMLHttpRequest.bind(content); //eslint-disable-line no-global-assign, no-native-reassign, no-undef, max-len
  52. }
  53.  
  54. /**
  55. * 小説ページなら真。
  56. * @type {boolean}
  57. */
  58. const novel = location.pathname.startsWith('/novel/');
  59.  
  60. /**
  61. * 取得済みのタグクラウド。
  62. *
  63. * キーにユーザーID (小説ページなら「-novel」を後置) を持つ。
  64. * @type {Object.<TagsData>}
  65. */
  66. const tagsDataList = {};
  67.  
  68. /** @type {string} */
  69. let previousWorkId;
  70.  
  71. new MutationObserver(async function (mutations) {
  72. if (mutations.every(mutation => mutation.addedNodes.length === 0)) {
  73. return;
  74. }
  75.  
  76. const nodeBeforeInsertionPoint = getNodeBeforeInsertionPoint();
  77. if (!nodeBeforeInsertionPoint) {
  78. return;
  79. }
  80.  
  81. const workId = getWorkId();
  82.  
  83. if (novel) {
  84. const tag = document.querySelector('section [href*="/novels/"]');
  85. if (tag) {
  86. const section = tag.closest('section');
  87. if (section && !section.hidden) {
  88. // 小説ページにもともと含まれる「作者の作品タグ」を非表示に (削除するとページ移動時にエラー発生)
  89. section.hidden = true;
  90. }
  91. }
  92. }
  93.  
  94. if (previousWorkId) {
  95. if (previousWorkId === workId) {
  96. // 前回のタグクラウド挿入からからページを移動していなければ
  97. return;
  98. }
  99. previousWorkId = workId;
  100. } else {
  101. // 現在のページセッションで初回の実行なら
  102. previousWorkId = workId;
  103. await addStyleSheet();
  104. await cleanTagsData();
  105.  
  106. if (previousWorkId !== workId) {
  107. // 初期処理中に別のページへ移動していたら
  108. return;
  109. }
  110. }
  111.  
  112. const userId = getUserId();
  113. const userIdWithSuffix = userId + (novel ? '-novel' : '');
  114. if (!tagsDataList[userIdWithSuffix]) {
  115. // 現在のページセッションで取得を行っていないユーザーのタグクラウドなら
  116. tagsDataList[userIdWithSuffix] = await getTagsData(userId);
  117.  
  118. if (previousWorkId !== workId) {
  119. // 取得処理中に別のページへ移動していたら
  120. return;
  121. }
  122. }
  123.  
  124. const tagCloudSection = createTagCloudSection(tagsDataList[userIdWithSuffix]);
  125. const previousTagCloudSection = getInsertedTagCloudSection();
  126. if (previousTagCloudSection) {
  127. previousTagCloudSection.replaceWith(tagCloudSection);
  128. } else {
  129. nodeBeforeInsertionPoint.after(tagCloudSection);
  130. }
  131. }).observe(document.getElementById('root'), { childList: true, subtree: true });
  132. // #root直下のdivはdocument-end時点ではタイミングにより存在しない
  133.  
  134. async function cleanTagsData()
  135. {
  136. let version = await GM.getValue('version'); // v2.13.0以降
  137. let nextCleaningDate = await GM.getValue('next-cleaning-date'); // v1.1.0以降
  138. if (version && compareVersions.compare(version, '3.1.1', '>') && nextCleaningDate) {
  139. if (new Date(nextCleaningDate).getTime() < Date.now()) {
  140. // 予定時刻を過ぎていれば、古いキャッシュを削除
  141. for (const name of await GM.listValues()) {
  142. if (/-(?:tags|expire)$/.test(name)) {
  143. // バージョン2.2.0以前で生成されたデータの削除
  144. await GM.deleteValue(name);
  145. continue;
  146. }
  147. if (!/^[0-9]+(?:-novel)?$/.test(name)) {
  148. continue;
  149. }
  150. const data = await GM.getValue(name);
  151. if (new Date(data.expire).getTime() < Date.now()) {
  152. // キャッシュの有効期限が切れていれば
  153. await GM.deleteValue(name);
  154. }
  155. }
  156. nextCleaningDate = null;
  157. }
  158. } else {
  159. // v3.1.1以前に生成されたデータの削除
  160. await Promise.all((await GM.listValues()).map(GM.deleteValue));
  161. version = null;
  162. }
  163. if (!version || version !== GM.info.script.version) {
  164. await GM.setValue('version', GM.info.script.version);
  165. }
  166. if (!nextCleaningDate) {
  167. await GM.setValue(
  168. 'next-cleaning-date',
  169. new Date(Date.now() + CACHE_LIFETIME * DateUtils.SECONDS_TO_MILISECONDS).toISOString()
  170. );
  171. }
  172. }
  173.  
  174. /**
  175. * 「前後の作品」セクションを取得します。
  176. * @returns {?Node}
  177. */
  178. function getNodeBeforeInsertionPoint()
  179. {
  180. const a = document.querySelector(`main + aside section header [href$="/${novel ? 'novels' : 'artworks'}"]`);
  181. if (!a) {
  182. return;
  183. }
  184.  
  185. return a.closest('section');
  186. }
  187.  
  188. /**
  189. * 挿入済みのタグクラウドを取得します。
  190. * @returns {?HTMLElement}
  191. */
  192. function getInsertedTagCloudSection()
  193. {
  194. return document.getElementsByClassName('area_new')[0];
  195. }
  196.  
  197. async function addStyleSheet()
  198. {
  199. let tagCloudStyles = await GM.getValue('tag-cloud-styles');
  200. if (!tagCloudStyles) {
  201. tagCloudStyles = (await (await fetch('https://s.pximg.net/www/css/global.css')).text())
  202. .match(/^(?:\.area_(?:new|title|inside)|\.view_mypixiv|ul\.tagCloud) .+?$/umg).join('\n');
  203. GM.setValue('tag-cloud-styles', tagCloudStyles);
  204. }
  205. document.head.insertAdjacentHTML('beforeend', h`<style>
  206. ${tagCloudStyles}
  207. .area_new {
  208. width: unset;
  209. margin: 16px;
  210. }
  211. .tagCloud {
  212. padding: 0;
  213. }
  214. .tagCloud .last-current-tag::after {
  215. content: "";
  216. display: inline-block;
  217. height: 18px;
  218. border-right: solid 1px #999;
  219. width: 10px;
  220. margin-bottom: -3px;
  221. -webkit-transform: rotate(0.3rad);
  222. transform: rotate(0.3rad);
  223. }
  224. `);
  225. }
  226.  
  227. /**
  228. * ページへ挿入するタグクラウドを構築します。
  229. * @param {TagsData} tagsData
  230. * @returns {HTMLElement}
  231. */
  232. function createTagCloudSection(tagsData)
  233. {
  234. const tagCloudSection = tagsData.tagCloudSection.cloneNode(true);
  235.  
  236. /** @type {HTMLUListElement} */
  237. const tagCloud = tagCloudSection.getElementsByClassName('tagCloud')[0];
  238.  
  239. let tagCloudItemTemplate;
  240. let tagCloudItemTemplateAnchor;
  241.  
  242. const currentTags = [];
  243.  
  244. // 表示している作品のタグを取得する
  245. for (const tagItem of document.querySelectorAll('footer ul a')) {
  246. /**
  247. * RFC 3986にもとづいてパーセント符号化されたタグ。
  248. * @type {string}
  249. */
  250. const urlencodedTag = tagItem.pathname.split('/')[2];
  251.  
  252. let tagCloudItem;
  253.  
  254. const anchor = tagCloud.querySelector('[href$="/' + urlencodedTag + '"]');
  255. if (anchor) {
  256. // タグクラウドに同じタグが存在すれば、抜き出す
  257. tagCloudItem = anchor.parentElement;
  258. } else {
  259. // 存在しなければ、もっとも出現度の低いタグとして追加
  260. if (!tagCloudItemTemplate) {
  261. tagCloudItemTemplate = tagCloud.firstElementChild.cloneNode(true);
  262. tagCloudItemTemplate.className = 'level6';
  263. tagCloudItemTemplateAnchor = tagCloudItemTemplate.firstElementChild;
  264. }
  265.  
  266. tagCloudItemTemplateAnchor.pathname = tagCloudItemTemplateAnchor.pathname.replace(/[^/]+$/, urlencodedTag);
  267. const tag = tagItem.textContent;
  268. tagCloudItemTemplateAnchor.text = tag;
  269. if (tag in tagsData.tagsAndCounts) {
  270. // タグの数を表示
  271. tagCloudItemTemplateAnchor
  272. .insertAdjacentHTML('beforeend', `<span class="cnt">(${tagsData.tagsAndCounts[tag]})</span>`);
  273. }
  274. tagCloudItem = tagCloudItemTemplate.cloneNode(true);
  275. }
  276.  
  277. currentTags.push(' ', tagCloudItem);
  278. }
  279.  
  280. // 表示している作品のタグとそれ以外のタグとの区切りを示すクラスを設定
  281. currentTags[currentTags.length - 1].classList.add('last-current-tag');
  282.  
  283. // タグクラウドの先頭に挿入
  284. tagCloud.prepend(...currentTags);
  285.  
  286. return tagCloudSection;
  287. }
  288.  
  289. /**
  290. * ブックマークボタンから、表示している作品のIDを取得します。
  291. *
  292. * - 関連作品から別ユーザーの作品ページに移動する際、URLはページの内容に先行して替わる
  293. * - 同じユーザーの作品間で移動する場合、「前後の作品」セクションはタグに先行して切り替わる
  294. * @param {HTMLElement} otherWorksSection
  295. * @throws {Error} 作品のIDが取得できなかったとき。
  296. * @returns {string}
  297. */
  298. function getWorkId()
  299. {
  300. const id = new URLSearchParams(document.querySelector('[href*="/bookmark_detail.php"]').search)
  301. .get(novel ? 'id' : 'illust_id');
  302. if (!id) {
  303. throw new Error('作品のIDを取得できません。');
  304. }
  305. return id;
  306. }
  307.  
  308. /**
  309. * タグ下部のアイコンのリンクから、表示している作品の作者のユーザーIDを取得します。
  310. *
  311. * - 関連作品から別ユーザーの作品ページに移動する際、タグは「前後の作品」セクションに先行して切り替わる
  312. * @returns {string}
  313. */
  314. function getUserId()
  315. {
  316. // [href*="/users/"] のみだと、キャプションにユーザーへのリンクがある場合、それを拾ってしまう
  317. return document.querySelector('[href*="/users/"] *').closest('a').pathname.replace('/users/', '');
  318. }
  319.  
  320. /**
  321. * 指定したユーザーのタグクラウド、および出現数が2回以上のタグ一覧を取得します。
  322. * @param {string} userId
  323. * @returns {Promise.<TagsData>}
  324. */
  325. async function getTagsData(userId)
  326. {
  327. const serializedTagsData = await GM.getValue(userId);
  328. if (serializedTagsData && new Date(serializedTagsData.expire).getTime() > Date.now()) {
  329. const body = document.implementation.createHTMLDocument().body;
  330. body.innerHTML = serializedTagsData.tagCloudSection;
  331. return { tagCloudSection: body.firstElementChild, tagsAndCounts: serializedTagsData.tagsAndCounts };
  332. }
  333. return getTagsDataFromPage(userId);
  334. }
  335.  
  336. /**
  337. * 指定したユーザーのタグクラウド、および出現数が2回以上のタグ一覧をページから取得し、キャッシュとして保存します。
  338. * @param {string} userId
  339. * @returns {Promise.<TagsData>}
  340. */
  341. function getTagsDataFromPage(userId)
  342. {
  343. return new Promise(function (resolve) {
  344. const client = new XMLHttpRequest();
  345. client.open('GET', new URL((novel ? '/novel' : '') + '/member_tag_all.php?id=' + userId, location));
  346. client.responseType = 'document';
  347. client.addEventListener('load', function (event) {
  348. const doc = event.target.response;
  349.  
  350. const tagCloudSection = doc.getElementsByClassName(novel
  351. ? 'area_new'
  352. : /* class="area_new promotion-comic" の回避 */'user-tags')[0];
  353. const tagCouldAnchors = {};
  354. for (const anchor of tagCloudSection.querySelectorAll('li a')) {
  355. tagCouldAnchors[anchor.firstChild.data] = anchor;
  356. }
  357.  
  358. const counts = doc.querySelectorAll('.tag-list > dt');
  359. const tagsAndCounts = {};
  360. for (const dt of counts) {
  361. const count = Number.parseInt(dt.textContent);
  362. for (const anchor of dt.nextElementSibling.getElementsByTagName('a')) {
  363. const tag = anchor.text;
  364. if (count > 1) {
  365. tagsAndCounts[tag] = count;
  366. }
  367.  
  368. // タグクラウドのリンクが旧URLになっている不具合を修正
  369. if (tag in tagCouldAnchors) {
  370. tagCouldAnchors[tag].href = anchor;
  371. }
  372. }
  373. }
  374.  
  375. GM.setValue(userId + (novel ? '-novel' : ''), {
  376. expire: new Date(Date.now() + CACHE_LIFETIME * DateUtils.SECONDS_TO_MILISECONDS).toISOString(),
  377. tagCloudSection: tagCloudSection.outerHTML,
  378. tagsAndCounts,
  379. });
  380.  
  381. resolve({ tagCloudSection, tagsAndCounts });
  382. });
  383. client.send();
  384. });
  385. }