// ==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.01
// @icon http://i.imgur.com/VwoYc5w.png
// @grant none
// @noframes
// ==/UserScript==
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);
}
}
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(),
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 <image-item>
*
* 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':
if(search.has('id')) {
type = 'default';
} else if(!search.has('type')) {
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,
order: 0,
}, Pixiv.storageGet());
if (_f.fullwidth) {
document.querySelector('#wrapper').classList.add('fullwidth');
}
if (_f.order) {
globalStore.filters.orderBy = 'bookmark_count';
}
Pixiv.storageSet(_f);
return _f;
})();
Vue.component('koakuma-settings', {
props: ['favorite'],
methods: {
fullwidthClick(event) {
this.$emit('fullwidthUpdate', event.target.checked);
},
orderClick(event) {
this.$emit('orderUpdate', event.target.checked);
},
},
template: `
<div>
<input id="koakuma-settings-fullwidth" type="checkbox"
:checked="favorite.fullwidth"
@click="fullwidthClick"> 全寬
<input id="koakuma-settings-order" type="checkbox"
:checked="favorite.order"
@click="orderClick"> 排序
</div>`,
});
Vue.component('koakuma-bookmark', {
props: ['limit'],
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.limit = 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.limit = val;
this.$emit('limitUpdate', val);
},
},
template:`
<div id="koakuma-bookmark">
<label for="koakuma-bookmark-input">★書籤</label>
<input id="koakuma-bookmark-input"
type="number" min="0" step="1"
:value="limit"
@wheel.stop.prevent="wheel"
@blur="blur"
@input="input"/>
</div>`,
});
// Vue.component('koakuma-tag-filter', {
// //todo
// });
const koakuma = new Vue({
// make koakuma to left side
data: {
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_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);
},
orderUpdate(todo) {
if(todo) {
globalStore.filters.orderBy = 'bookmark_count';
globalStore.favorite.order = 1;
} else {
globalStore.filters.orderBy = 'illust_id';
globalStore.favorite.order = 0;
}
Pixiv.storageSet(globalStore.favorite);
},
},
computed: {
switchText() {
return this.isEnded ? "完" : (this.isStoped ? "找" : "停");
},
switchStyle() {
return {
ended: this.isEnded,
toSearch: !this.isEnded && this.isStoped,
toStop: !this.isEnded && !this.isStoped,
};
},
},
template: `
<div id="こあくま">
<div>已處理 {{books.length}} 張</div>
<koakuma-bookmark :limit="filters.limit" @limitUpdate="limitUpdate"></koakuma-bookmark>
<button id="koakuma-switch"
@click="switchSearching"
:disabled="isEnded"
:class="switchStyle">{{ switchText }}</button>
<koakuma-settings :favorite="favorite"
@fullwidthUpdate="fullwidthUpdate"
@orderUpdate="orderUpdate"></koakuma-settings>
</div>`,
});
Vue.component('image-item-thumb', {
props:['detail'],
computed: {
thumbStyle() {
return {
multiple: this.detail.multiple,
manga: this.detail.manga,
'ugoku-illust': this.detail.ugoira,
};
},
},
template:`
<a class="work _work" :href="detail.href" :class="thumbStyle">
<div><img :src="detail.src"></div>
</a>`,
});
Vue.component('image-item-title', {
props:['detail'],
template:`
<a :href="detail.href">
<h1 class="title" :title="detail.title">{{ detail.title }}</h1>
</a>`,
});
Vue.component('image-item-user', {
props:['user'],
computed: {
href() {
return `/member_illust.php?id=${this.user.id}`;
},
vClass() {
return {
followed: this.user.is_follow,
};
},
},
template:`
<a class="user ui-profile-popup"
:class="vClass"
:href="href"
:title="user.name"
:data-user_id="user.id">{{ user.name }}</a>`,
});
Vue.component('image-item-count-list', {
props:['detail'],
computed: {
tooltip() {
return `${this.detail.bmkcount}件のブックマーク`;
},
shortRating() {
return (this.detail.rating > 10000) ? `${(this.detail.rating / 1e3).toFixed(1)}K` : this.detail.rating;
},
},
template:`
<ul class="count-list">
<li v-if="detail.bmkcount > 0">
<a class="bookmark-count _ui-tooltip"
:href="detail.bmkhref"
:data-tooltip="tooltip">
<i class="_icon sprites-bookmark-badge"></i>{{ detail.bmkcount }}</a>
</li>
<li v-if="detail.rating > 0">
<span class="rating_score">
<i class="fa fa-star" aria-hidden="true"></i>{{ shortRating }}
</span>
</li>
</ul>`,
});
Vue.component('image-item', {
props:['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,
};
},
},
template: `
<li class="image-item">
<image-item-thumb :detail="thumb_detail"></image-item-thumb>
<image-item-title :detail="title_detail"></image-item-title>
<image-item-user :user="user_detail" v-if="pagetype !== 'member-illust'"></image-item-user>
<image-item-count-list :detail="count_detail"></image-item-count-list>
</li>`,
});
const patchouli = new Vue({
data: {
books: globalStore.books,
filters: globalStore.filters,
pagetype: globalStore.page.type,
},
computed: {
orderedBooks() {
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]);
},
},
template:`
<ul id="パチュリー">
<image-item v-for="book in orderedBooks"
:detail="book"
:pagetype="pagetype"></image-item>
</ul>`,
});
let DEBUG = true;
if(DEBUG) {
window.utils = utils;
window.Pixiv = Pixiv;
window.koakuma = koakuma;
window.patchouli = patchouli;
window.globalStore = globalStore;
}
console.log('Vue.version', Vue.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;
}
.followed.followed.followed {
font-weight: bold;
color: red;
}
.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;
}
#パチュリー {
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: 140px;
}
#こあくま > * {
margin: 2px 0;
}`);
}