// ==UserScript== // @name Patchouli // @description An image searching/browsing tool on Pixiv // @namespace https://github.com/FlandreDaisuki // @author FlandreDaisuki // @include http://www.pixiv.net/* // @require https://cdnjs.cloudflare.com/ajax/libs/vue/2.2.1/vue.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/axios/0.15.3/axios.min.js // @version 2017.03.02 // @icon http://i.imgur.com/VwoYc5w.png // @grant none // @noframes // @downloadURL none // ==/UserScript== const version = '2017.03.02'; class L10N { constructor() { this.lang = document.documentElement.lang; this.following = this._following(); this.bookmark = this._bookmark(); this.koakumaGo = this._koakumaGo(); this.koakumaPause = this._koakumaPause(); this.koakumaEnd = this._koakumaEnd(); this.koakumaFullwidth = this._koakumaFullwidth(); this.koakumaSort = this._koakumaSort(); } _following() { switch(this.lang) { case 'ja': return 'フォロー中'; case 'zh-tw': return '關注中'; case 'zh': return '关注中'; default: return 'following'; } } _bookmark() { switch(this.lang) { case 'ja': return 'ブックマーク'; case 'zh-tw': case 'zh': return '收藏'; default: return 'Bookmark'; } } _koakumaGo() { switch(this.lang) { case 'ja': return '捜す'; case 'zh-tw': case 'zh': return '找'; default: return 'Go'; } } _koakumaPause() { switch(this.lang) { case 'ja': return '中断'; case 'zh-tw': case 'zh': return '停'; default: return 'Pause'; } } _koakumaEnd() { switch(this.lang) { case 'ja': return '終了'; case 'zh-tw': case 'zh': return '完'; default: return 'End'; } } koakumaProcessed(n) { switch(this.lang) { case 'ja': return `${n} 件が処理された`; case 'zh-tw': return `已處理 ${n} 張` case 'zh': return `已处理 ${n} 张`; default: return `${n} pics processed`; } } _koakumaFullwidth() { switch(this.lang) { case 'ja': return '全幅'; case 'zh-tw': return '全寬'; case 'zh': return '全宽'; default: return 'fullwidth'; } } _koakumaSort() { switch(this.lang) { case 'ja': return 'ソート'; case 'zh-tw': case 'zh': return '排序'; default: return 'sorted'; } } bookmarkTooltip(n) { switch(this.lang) { case 'ja': return `${n}件のブックマーク`; case 'zh-tw': return `${n}個收藏` case 'zh': return `${n}个收藏`; default: return `${n} bookmarks`; } } } class Pixiv { constructor() { this.tt = document.querySelector('input[name="tt"]').value; } fetch(url) { return axios.get(url) .then(res => { return new Promise((resolve, reject) => { (res.statusText !== 'OK') ? reject(res) : resolve(res.data); }); }) .catch(console.error); } static toDOM(html) { return (new DOMParser()).parseFromString(html, 'text/html'); } static rmAnnoyance(doc = document) { [ 'iframe', //Ad '.ad', '.ads_area', '.ad-footer', '.ads_anchor', '.ads-top-info', '.comic-hot-works', '.user-ad-container', '.ads_area_no_margin', //Premium '.hover-item', '.ad-printservice', '.bookmark-ranges', '.require-premium', '.showcase-reminder', '.sample-user-search', '.popular-introduction', ].forEach(cl => { Array.from(doc.querySelectorAll(cl)).forEach(el => { el.remove(); }); }); } static storageGet() { if(!localStorage.getItem('むきゅー')) { Pixiv.storageSet({}); } return JSON.parse(localStorage.getItem('むきゅー')); } static storageSet(obj) { localStorage.setItem('むきゅー', JSON.stringify(obj)); } static hrefAttr(elem) { const a = elem; if (!a) { return ''; } else if(a.href) { // Firefox return a.href; } else { // Chrome const m = a.outerHTML.match(/href="([^"]+)"/); if (!m) { return ''; } const query = m[1].replace(/&/g, '&'); return `${location.pathname}${query}`; } } getBookmarkCount(illust_id) { const url = `/bookmark_detail.php?illust_id=${illust_id}`; return this.fetch(url) .then(Pixiv.toDOM) .then(doc => { const _a = doc.querySelector('a.bookmark-count'); const bookmark_count = _a ? parseInt(_a.innerText) : 0; return { bookmark_count, illust_id, }; }) .catch(console.error); } getBookmarksDetail(illust_ids) { const _f = this.getBookmarkCount.bind(this); const bookmarks = illust_ids.map(_f); return Promise.all(bookmarks) .then(arr => arr.reduce((a, b) => { a[b.illust_id] = b; return a; }, {})) .catch(console.error); } getIllustPageDetail(illust_id) { const url = `/member_illust.php?mode=medium&illust_id=${illust_id}`; return this.fetch(url) .then(Pixiv.toDOM) .then(doc => { const _a = doc.querySelector('.score-count'); const rating_score = _a ? parseInt(_a.innerText) : 0; return { illust_id, rating_score, }; }) .catch(console.error); } getIllustPagesDetail(illust_ids) { const _f = this.getIllustPageDetail.bind(this); const pages = illust_ids.map(_f); return Promise.all(pages) .then(arr => arr.reduce((a, b) => { a[b.illust_id] = b; return a; }, {})) .catch(console.error); } getIllustsDetail(illust_ids) { const _a = illust_ids.join(','); const url = `/rpc/index.php?mode=get_illust_detail_by_ids&illust_ids=${_a}&tt=${this.tt}`; return this.fetch(url) .then(json => json.body) .catch(console.error); } getUsersDetail(user_ids) { const _a = user_ids.join(','); const url = `/rpc/get_profile.php?user_ids=${_a}&tt=${this.tt}`; return this.fetch(url) .then(json => json.body) .then(arr => arr.reduce((a, b) => { // make the same output of getIllustsDetail a[b.user_id] = b; return a; }, {})) .catch(console.error); } getPageInformationAndNext(url) { return this.fetch(url) .then(Pixiv.toDOM) .then(doc => { Pixiv.rmAnnoyance(doc); const _a = doc.querySelector('.next a'); const next_url = Pixiv.hrefAttr(_a); //FF & Chrome issue const _b = doc.querySelectorAll('.image-item img'); const illusts = Array.from(_b).map(x => ({ illust_id: x.dataset.id, thumb_src: x.dataset.src, user_id: x.dataset.userId, tags: x.dataset.tags.split(' '), })).filter(x => x.illust_id !== '0'); return { next_url, illusts, }; }) .catch(console.error); } postBookmarkadd(illust_id) { const data = [ 'mode=save_illust_bookmark', `illust_id=${illust_id}`, 'restrict=0', 'comment=', 'tags=', `tt=${this.tt}`, ].join('&'); const config = {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'}; return axios.post('/rpc/index.php', data, config) .then(res => { return new Promise((resolve, reject) => { (res.statusText === 'OK' && !res.data.error) ? resolve(true) : reject(res); }); }) .catch(console.error); } } (() => { // version check const s = Pixiv.storageGet(); if (!s.version || s.version < version) { Pixiv.storageSet({ version, }); } })(); const utils = { linkStyle: function(url) { const link = document.createElement('link'); link.rel = 'stylesheet'; link.type = 'text/css'; link.href = url; document.head.appendChild(link); }, linkScript: function(url) { const script = document.createElement('script'); script.src = url; document.head.appendChild(script); }, createIcon: function(name, options = {}) { const el = document.createElement('i'); el.classList.add('fa'); el.classList.add(`fa-${name}`); el.setAttribute('aria-hidden', 'true'); return el; }, addStyle: function(text) { const style = document.createElement('style'); style.innerHTML = text; document.head.appendChild(style); }, asyncWhile: function(condition, action, options = {}) { options = Object.assign({ first: undefined, ctx: this, }, options); const ctx = options.ctx; const first = options.first; const whilst = function(data) { return condition.call(ctx, data) ? Promise.resolve(action.call(ctx, data)).then(whilst) : data; }; return whilst(first); } }; const globalStore = { api: new Pixiv(), l10n: new L10N(), books: [], filters: { limit: 0, orderBy: 'illust_id', }, patchouliToMount: (() => { const _a = document.querySelector('li.image-item'); return _a ? _a.parentElement : null; })(), koakumaToMount: (() => { return document.querySelector('#toolbar-items'); })(), page: (() => { let type = 'default'; let supported = true; let path = location.pathname; let search = new URLSearchParams(location.search); /** type - for patchouli * * default: thumb + title + user + count-list * member-illust: default w/o user * mybookmark: default with bookmark-edit */ switch(path) { case '/search.php': case '/bookmark_new_illust.php': case '/new_illust.php': case '/mypixiv_new_illust.php': case '/new_illust_r18.php': case '/bookmark_new_illust_r18.php': break; case '/member_illust.php': if (search.has('id')) { type = 'member-illust'; } else { supported = false; } break; case '/bookmark.php': const t = search.get('type') if(search.has('id')) { type = 'default'; } else if(!t || t === 'illust_all') { type = 'mybookmark'; } else { // e.g. http://www.pixiv.net/bookmark.php?type=reg_user supported = false; } break; default: supported = false; } return { supported, type, }; })(), }; globalStore.favorite = (()=>{ const _f = Object.assign({ fullwidth: 1, sort: 0, }, Pixiv.storageGet()); if (_f.fullwidth) { document.querySelector('#wrapper').classList.add('fullwidth'); } if (_f.sort) { globalStore.filters.orderBy = 'bookmark_count'; } Pixiv.storageSet(_f); return _f; })(); Vue.component('koakuma-settings', { props: ['favorite', 'l10n'], methods: { fullwidthClick(event) { this.$emit('fullwidthUpdate', event.target.checked); }, sortClick(event) { this.$emit('sortUpdate', event.target.checked); }, }, template: `
{{l10n.koakumaFullwidth}} {{l10n.koakumaSort}}
`, }); Vue.component('koakuma-bookmark', { props: ['limit', 'l10n'], methods: { blur(event) { const self = event.target; if(!self.validity.valid) { console.error('koakuma-bookmark', self.validationMessage); } }, input(event) { let val = parseInt(event.target.value); val = Math.max(0, val); this.$emit('limitUpdate', val); }, wheel(event) { let val; if (event.deltaY < 0) { val = this.limit + 20; } else { val = Math.max(0, this.limit - 20); } this.$emit('limitUpdate', val); }, }, template:`
`, }); // Vue.component('koakuma-tag-filter', { // //todo // }); const koakuma = new Vue({ // make koakuma to left side data: { l10n: globalStore.l10n, books: globalStore.books, filters: globalStore.filters, api: globalStore.api, favorite: globalStore.favorite, next_url: location.href, isStoped: true, isEnded: false, }, methods: { start(times = Infinity) { this.isStoped = false; return utils.asyncWhile(next_url => { if (!next_url) { this.isStoped = this.isEnded = true; return false; } if (times > 0) { times--; } else { this.isStoped = true; } return !this.isStoped; }, url => { return this.api.getPageInformationAndNext(url) .then(inf => { return new Promise((resolve, reject) => { if (inf.next_url === this.next_url) { reject(`Duplicated url: ${url}`); } else { resolve(inf); } }); }) .then(inf => { this.next_url = inf.next_url; const user_ids = inf.illusts.map(x => x.user_id); const illust_ids = inf.illusts.map(x => x.illust_id); const usersDetail = this.api.getUsersDetail(user_ids); const illustsDetail = this.api.getIllustsDetail(illust_ids); const illustPagesDetail = this.api.getIllustPagesDetail(illust_ids); const bookmarkDetail = this.api.getBookmarksDetail(illust_ids); return new Promise((resolve, reject) => { Promise.all([usersDetail, illustsDetail, illustPagesDetail, bookmarkDetail]) .then(resolve) .catch(reject); }) .then(details => { return { next_url: inf.next_url, illusts: inf.illusts, usersDetail: details[0], illustsDetail: details[1], illustPagesDetail: details[2], bookmarkDetail: details[3], }; }) .catch(console.error); }) .then(inf => { const books = inf.illusts.map(illust => { const ud = inf.usersDetail; const ild = inf.illustsDetail; const ipd = inf.illustPagesDetail; const bd = inf.bookmarkDetail; return { illust_id: illust.illust_id, thumb_src: illust.thumb_src, user_id: illust.user_id, // tags: illust.tags, user_name: ud[illust.user_id].user_name, is_follow: ud[illust.user_id].is_follow, illust_title: ild[illust.illust_id].illust_title, is_multiple: ild[illust.illust_id].is_multiple, is_bookmarked: ild[illust.illust_id].is_bookmarked, is_manga: ild[illust.illust_id].illust_type === '1', is_ugoira: !!ild[illust.illust_id].ugoira_meta, bookmark_count: bd[illust.illust_id].bookmark_count, rating_score: ipd[illust.illust_id].rating_score, }; }); this.books.push(...books); return inf; }) .then(inf => { return inf.next_url; }) .catch(console.error); }, { first: this.next_url, }); }, stop() { this.isStoped = true; }, switchSearching() { if (this.isStoped) { this.start(); } else { this.stop(); } }, limitUpdate(value) { globalStore.filters.limit = isNaN(value) ? 0 : value; }, fullwidthUpdate(todo) { if(todo) { document.querySelector('#wrapper').classList.add('fullwidth'); globalStore.favorite.fullwidth = 1; } else { document.querySelector('#wrapper').classList.remove('fullwidth'); globalStore.favorite.fullwidth = 0; } Pixiv.storageSet(globalStore.favorite); }, sortUpdate(todo) { if(todo) { globalStore.filters.orderBy = 'bookmark_count'; globalStore.favorite.sort = 1; } else { globalStore.filters.orderBy = 'illust_id'; globalStore.favorite.sort = 0; } Pixiv.storageSet(globalStore.favorite); }, }, computed: { switchText() { return this.isEnded ? this.l10n.koakumaEnd : (this.isStoped ? this.l10n.koakumaGo : this.l10n.koakumaPause); }, switchStyle() { return { ended: this.isEnded, toSearch: !this.isEnded && this.isStoped, toStop: !this.isEnded && !this.isStoped, }; }, }, template: `
{{l10n.koakumaProcessed(books.length)}}
`, }); Vue.component('image-item-thumb', { props:['detail'], computed: { thumbStyle() { return { multiple: this.detail.multiple, manga: this.detail.manga, 'ugoku-illust': this.detail.ugoira, }; }, }, template:`
`, }); Vue.component('image-item-title', { props:['detail'], template:`

{{ detail.title }}

`, }); Vue.component('image-item-user', { props:['user'], computed: { href() { return `/member_illust.php?id=${this.user.id}`; }, userStyle() { return { following: this.user.is_follow, }; }, }, template:` {{ user.name }} `, }); Vue.component('image-item-count-list', { props:['api', 'detail', 'l10n'], data() { return { bookmarked: this.detail.bookmarked, }; }, computed: { tooltip() { return this.l10n.bookmarkTooltip(this.detail.bmkcount); }, shortRating() { return (this.detail.rating > 10000) ? `${(this.detail.rating / 1e3).toFixed(1)}K` : this.detail.rating; }, bookmarkStyle() { return this.bookmarked ? 'fa-bookmark' : 'fa-bookmark-o'; }, }, methods: { click(event) { if(!this.bookmarked) { this.api.postBookmarkadd(this.detail.illust_id); this.$emit('bookmarkUpdate', this.detail.illust_id); this.bookmarked = true; } }, }, template:` `, }); Vue.component('image-item', { props:['api', 'l10n', 'detail', 'pagetype'], computed: { illust_page_href() { return `/member_illust.php?mode=medium&illust_id=${this.detail.illust_id}`; }, bookmark_detail_href() { return `/bookmark_detail.php?illust_id=${this.detail.illust_id}`; }, thumb_detail() { return { href: this.illust_page_href, src: this.detail.thumb_src, multiple: this.detail.is_multiple, manga: this.detail.is_manga, ugoira: this.detail.is_ugoira, }; }, user_detail() { return { id: this.detail.user_id, name: this.detail.user_name, is_follow: this.detail.is_follow, }; }, title_detail() { return { href: this.illust_page_href, title: this.detail.illust_title, }; }, count_detail() { return { bmkhref: this.bookmark_detail_href, bmkcount: this.detail.bookmark_count, rating: this.detail.rating_score, bookmarked: this.detail.is_bookmarked, illust_id: this.detail.illust_id, }; }, }, template: `
  • `, }); const patchouli = new Vue({ data: { api: globalStore.api, l10n: globalStore.l10n, books: globalStore.books, filters: globalStore.filters, pagetype: globalStore.page.type, }, computed: { sortedBooks() { const _limit = this.filters.limit; const _order = this.filters.orderBy; const _books = this.books.filter(b => b.bookmark_count >= _limit); return _books.sort((a, b) => b[_order] - a[_order]); }, }, methods: { bookmarkUpdate(illust_id) { const _a = this.books.filter(b => b.illust_id === illust_id); if (_a.length) { _a[0].is_bookmarked = true; } }, }, template:` `, }); console.log('Vue.version:', Vue.version); console.log('Patchouli version:', version); if (globalStore.page.supported) { koakuma.$mount(globalStore.koakumaToMount); koakuma.start(1).then(() => { patchouli.$mount(globalStore.patchouliToMount); }); } Pixiv.rmAnnoyance(); utils.linkStyle('https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css'); if (globalStore.page.supported) { utils.addStyle(` #wrapper.fullwidth, #wrapper.fullwidth .layout-a, #wrapper.fullwidth .layout-body { width: initial; } #wrapper.fullwidth .layout-a { display: flex; flex-direction: row-reverse; } #wrapper.fullwidth .layout-column-2{ flex: 1; margin-left: 20px; } #wrapper.fullwidth .layout-body, #wrapper.fullwidth .layout-a { margin: 10px 20px; } .fa-feed { color: dodgerblue; cursor: default; } .fa-feed:hover::after { content:'${globalStore.l10n.following}'; position: absolute; color: white; white-space: nowrap; background-color: dodgerblue; padding: 2px; border-radius: 3px; margin-left: 8px; font-family: "Helvetica Neue","arial","Hiragino Kaku Gothic ProN", Meiryo, sans-serif; } .rating-score { background-color: #FFEE88; color: #FF7700; border-radius: 3px; display: inline-block !important; margin: 0 1px; padding: 0 6px !important; font: bold 10px/18px "lucida grande", sans-serif !important; text-decoration: none; cursor: default; } .is-bookmarked { cursor: pointer; } #パチュリー { display: flex; flex-wrap: wrap; justify-content: space-around; } #koakuma-bookmark { display: flex; } #koakuma-bookmark label{ white-space: nowrap; color: #0069b1 !important; background-color: #cceeff; border-radius: 3px; padding: 0 6px; } #koakuma-bookmark-input::-webkit-inner-spin-button, #koakuma-bookmark-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } #koakuma-bookmark-input { -moz-appearance: textfield; border: none; background-color: transparent; padding: 0px; color: blue; font-size: 16px; display: inline-block; cursor: ns-resize; text-align: center; min-width: 0; } #koakuma-bookmark-input:focus { cursor: initial; } #koakuma-switch { border: 0; padding: 3px 20px; border-radius: 3px; font-size: 16px; } #koakuma-switch:hover { box-shadow: 1px 1px gray; } #koakuma-switch:active { box-shadow: 1px 1px gray inset; } #koakuma-switch:focus { outline: 0; } #koakuma-switch.toSearch { background-color: lightgreen; } #koakuma-switch.toStop { background-color: lightpink; } #koakuma-switch.ended { background-color: lightgrey; } #koakuma-switch.ended:hover, #koakuma-switch.ended:hover { box-shadow: unset; } #こあくま { position: fixed; left: 10px; bottom: 10px; z-index: 1; background-color: aliceblue; border-radius: 10px; padding: 5px; font-size: 16px; text-align: center; width: 160px; } #こあくま > * { margin: 2px 0; }`); }