// ==UserScript==
// @name Pixiv tag bookmark
// @namespace http://tampermonkey.net/
// @version 0.2
// @description Pixivの作品ページにタグありのブックマーク機能を追加します
// @author y_kahou
// @match https://www.pixiv.net/*
// @grant GM_setValue
// @grant GM_getValue
// @noframes
// @require https://greasyfork.org/scripts/419955-y-method/code/y_method.js?version=890440
// @downloadURL none
// ==/UserScript==
const __CSS__ = `
:root {
--bookmarked-color: rgb(255, 64, 96);
--checked-color: #5596e6;
}
.selectable:hover {
background-color: rgb(50, 73, 90);
color: white;
cursor: pointer;
}
.selectable.selected {
background-color: rgb(85, 150, 230);
color: white;
}
/* 自分用タグ */
#mytag ul {
list-style: none;
padding: 0;
margin: 0 0 5px 0;
max-width: 600px;
overflow: hidden;
transition: 300ms ease-in-out;
}
#mytag li {
display: inline-block;
margin-right: 5px;
line-height: 1.5;
}
#mytag span {
padding: 2px;
user-select: none;
}
.o0, .o10 {
color: rgb(103, 164, 209);
}
.o30, .o50, .o100 {
color: rgb(132, 162, 183);
font-weight: bold;
}
.o30 { font-size: 16px; }
.o50 { font-size: 18px; }
.o100 { font-size: 22px; }
/* ブクマボタン */
#tb-submit {
display: block;
background: none;
outline: none;
border: solid 1.5px var(--checked-color);
border-radius: 4px;
cursor: pointer;
transition: 500ms;
}
#tb-submit::before {
content: '選択したタグでお気に入り';
display: inline-block;
position: relative;
transform: translateY(40%);
color: var(--checked-color);
}
#tb-submit.already {
border-color: var(--bookmarked-color);
}
#tb-submit.already::before {
content: 'お気に入りタグを編集';
color: var(--bookmarked-color);
}
#tb-submit.already path {
fill: #dfdfdf;
}
#tb-submit.already path {
fill: var(--bookmarked-color);
}
/* タグテキスト */
#tb-text {
width: 92%;
height: 1.5em;
text-indent: 0.5em;
margin-bottom: 5px;
background-color: white;
border: solid 1px gray;
}
#tb-cnt {
margin-left: 5px;
}
#tb-cnt:after {
content: '/10';
}
`;
(function() {
'use strict';
addStyle('tagbookmark', __CSS__);
let canonical = document.querySelector('link[rel="canonical"]')
if (canonical) {
// ページロード時に作品ページだった場合
// link[rel="canonical"]は削除されず使い回されるので
// 上記を対象に監視する
new MutationObserver(addMytag)
.observe(canonical, { attributes: true })
}
else {
// 作品ページ以外から始まった場合
// link[rel="canonical"]は毎回削除追加されるので
// headを監視しlink[rel="canonical"]の追加を検知する
new MutationObserver(async (records) => {
for (let record of records) {
for (let node of record.addedNodes) {
if (node.tagName == 'LINK' && node.getAttribute('rel') == 'canonical') {
addMytag()
return
}
}
}
})
.observe(document.head, { childList: true })
}
})();
async function addMytag() {
// イラスト 作品ページ以外は終了
if (!location.pathname.match(/artworks\/\d+$/)) {
// 小説 && !location.pathname.match(/\/novel\/show\.php$/)
return
}
// footer取得
let footer = (await repeatGetElements('figcaption footer'))[0]
// ブクマ済みならタグとか取得
let already = document.querySelector('a[href^="/bookmark_add.php"]')
let detail = !already ? null : await request.getArtworkData(already.href)
let existTag = function(tag) {
if (detail) return detail.tags.find(e => e == tag) != null
return false
}
// 既存タグに機能追加
for (let tag of footer.querySelectorAll('li a')) {
tag.classList.add('selectable')
tag.classList.toggle('selected', existTag(tag.textContent))
tag.addEventListener('click', listener.tag)
}
// wrap作成
let wrap = document.querySelector('#tb-wrap')
if (wrap) wrap.outerHTML = ''
wrap = document.createElement('div')
wrap.id = 'tb-wrap'
// 自分用タグ
let mytag = document.createElement('div')
mytag.id = 'mytag'
mytag.innerHTML = ``
let ul = document.createElement('ul')
let tags = await request.getMyTags()
for (let tag in tags) {
let c = 'o0'
if (tags[tag] >= 10) c = 'o10'
if (tags[tag] >= 30) c = 'o30'
if (tags[tag] >= 50) c = 'o50'
if (tags[tag] >= 100) c = 'o100'
c += existTag(tag) ? ' selected' : ''
let li = document.createElement('li')
li.innerHTML = `${tag}`
li.querySelector('span').addEventListener('click', listener.tag)
li.dataset.cnt = tags[tag]
ul.appendChild(li)
}
if (tags == null || tags.length == 0) {
ul.innerHTML = 'ブックマークタグがありません'
}
mytag.appendChild(ul)
// 高さ取得してスタイル化
let style = document.querySelector('#tagbookmark-ul')
if (style) style.outerHTML = ''
document.body.appendChild(mytag)
const mytagHeight = mytag.clientHeight
document.body.removeChild(mytag)
addStyle('tagbookmark-ul', `#mytag ul { height: 0; } #mytag ul.show { height: ${mytagHeight}px }`)
wrap.appendChild(mytag)
// 登録用text
let tagText = document.createElement('input')
tagText.id = 'tb-text'
tagText.placeholder = '登録タグ'
tagText.value = detail ? detail.tags.join(' ') : ''
tagText.addEventListener('keyup', listener.text)
wrap.appendChild(tagText)
// タグ数
let tagCnt = document.createElement('span')
tagCnt.id = 'tb-cnt'
tagCnt.textContent = '1'
wrap.appendChild(tagCnt)
// ブックマークボタン作成
let tbm = document.createElement('button')
tbm.id = 'tb-submit'
if (already) {
tbm.className = 'already'
tbm.appendChild(already.querySelector('svg').cloneNode(true))
} else {
tbm.appendChild(document.querySelector('.gtm-main-bookmark svg').cloneNode(true))
}
tbm.addEventListener('click', listener.bookmark)
wrap.appendChild(tbm)
// footer下に追加
footer.parentNode.insertBefore(wrap, footer.nextElementSibling)
setTagcnt()
}
function setTagcnt() {
let cnt = 0
let text = document.querySelector('#tb-text').value
if (text) cnt = text.replace(' ', ' ').split(' ').filter(t => t != '').length
let cntText = document.querySelector('#tb-cnt')
cntText.textContent = cnt
cntText.style.color = cnt > 10 ? 'red' : ''
}
const listener = {
tag: function(e) {
if (e.ctrlKey) return
e.preventDefault()
let tag = e.target.textContent
let text = document.querySelector('#tb-text');
// 自分AND同じ名前のタグをtoggle
[...document.querySelectorAll('.selectable')]
.filter(e => e.textContent == tag)
.forEach(e => e.classList.toggle('selected'))
// テキストへの変換(toggle)
if (e.target.classList.contains('selected')) {
text.value += (text.value ? ' ' : '') + e.target.textContent
} else {
text.value = text.value.replace(' ', ' ').split(' ').filter(t => t != e.target.textContent).join(' ')
}
setTagcnt()
},
text: function(e) {
// テキスト変更でタグの選択も変更
for (let tag of document.querySelectorAll('.selectable')) {
let match = false
for (let text of e.target.value.replace(' ', ' ').split(' ')) {
if (text == tag.textContent) {
match = true
break
}
}
tag.classList.toggle('selected', match)
}
setTagcnt()
},
bookmark: async function(e) {
let url = document.querySelector('link[rel="canonical"]').getAttribute('href')
let work_id = url.match(/artworks\/(\d+)$/)[1]
let tags = document.querySelector('#tb-text').value.replace(' ', ' ').split(' ')
let comment = ''
let hide = 0
let token = await request.getToken(url)
request.addBookmark('illusts', work_id, comment, tags, hide, token)
.then(data => {
// 成功
let btn = document.querySelector('#tb-submit')
if (!btn.classList.contains('already')) {
btn.classList.add('already')
} else {
// 何度も連続でクリックされないように
alert('タグを編集しました')
}
})
.catch(error => {
// 失敗
console.error(error)
alert('ブックマークに失敗しました')
})
}
}
const request = {
getToken: async function(url) {
return new Promise((resolve, reject) => {
fetch(url)
.then(response => response.text())
.then(data => {
let result = data.match(/token":"(\w+)"/)
if (!result) reject(null)
else resolve(result[1])
})
})
},
getMyTags: async function(marge = true) {
// pixiv側の変数 dataLayer[0].user_id
console.log('pixiv側の変数 user_id: ' + dataLayer[0].user_id);
return new Promise((resolve, reject) => {
fetch(`https://www.pixiv.net/ajax/user/${dataLayer[0].user_id}/illusts/bookmark/tags`)
.then(response => response.text())
.then(data => {
let json = JSON.parse(data)
let tags = json.body
let ret = {}
for (let tag of tags.public) {
ret[tag.tag] = tag.cnt
}
if (marge)
for (let tag of tags.private) {
if (tag.tag in ret)
ret[tag.tag] += tag.cnt;
else ret[tag.tag] = tag.cnt;
}
resolve(ret)
})
})
},
addBookmark: async function(type, work_id, comment, tags, hide, token) {
let body = {
comment: comment,
tags: tags,
restrict: (hide ? 1 : 0),
}
body[type == 'illusts' ? 'illust_id' : 'novel_id'] = work_id
return new Promise((resolve, reject) => {
fetch(`https://www.pixiv.net/ajax/${type}/bookmarks/add`, {
method: 'POST',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json; charset=utf-8',
'x-csrf-token': token,
},
body: JSON.stringify(body)
})
.then(response => response.json())
.then(data => {
if (data.error) reject(data)
else resolve(data)
})
})
},
getArtworkData: async function(url) {
return new Promise((resolve, reject) => {
fetch(url)
.then(response => response.text())
.then(text => {
let html = new DOMParser().parseFromString(text, "text/html")
let detail = html.querySelector('.bookmark-detail-unit form')
resolve({
comment: detail.comment.value,
tags: detail.tag.value.split(' '),
hide: detail.restrict.value
})
})
})
}
}