// ==UserScript==
// @name ExHentai Viewer
// @namespace Violentmonkey Scripts
// @description manage your favorite tags, enhance searching, improve comic page
// @description:zh-CN 管理标签,增强搜索,改进漫画页面
// @match *://exhentai.org/*
// @match *://e-hentai.org/*
// @grant GM_setValue
// @grant GM_getValue
// @version 1.2.0
// ==/UserScript==
/*
* To-do:
* 1. preload images?
*/
const currentVersion = '1.2.0';
const sortFilters = false;
const $ = selector => document.querySelector(selector);
const $$ = selector => document.querySelectorAll(selector);
const create = tag => document.createElement(tag);
let data = {
custom_filter: GM_getValue('custom_filter', {}),
script_config: GM_getValue('script_config', {}),
tag_pref: GM_getValue('tag_pref', { liked_tags: [], disliked_tags: [] })
};
init();
function init() {
// upgrade and load script config
loadScriptData();
// router
const url = window.location.href;
if (url.includes('/s/')) {
// comic page
EHViewer('s');
} else if (url.includes('/mpv')) {
// multi page mode
EHViewer('mpv');
} else if (url.includes('/g/')) {
// gallery page
handleGallery();
} else if ($('#searchbox')) {
// add panel below the searchbox
handleSearchBox();
} else if (url.includes('/gallerytorrents.php')) {
// show magnet link
showMagnetLink();
}
// add EHV setting button
let ehvSettingBtn = create('div');
ehvSettingBtn.innerHTML = '<a href="#">EHV Settings</a>';
ehvSettingBtn.onclick = createSettingPanel;
$('#nb').append(ehvSettingBtn);
$('#nb').style.maxWidth = 'max-content';
// highlight tags
highlightTags();
}
function loadScriptData() {
switch (data.script_config.current_version) {
// don't break, just go through the flow to upgrade script data by version
case undefined:
// just install
default:
data.script_config.current_version = currentVersion;
GM_setValue('custom_filter', data.custom_filter);
GM_setValue('script_config', data.script_config);
}
}
function changeStyle(css, mode, id = 'ehv-style') {
let cssEl = $('#' + id);
if (!cssEl) {
cssEl = create('style');
cssEl.type = 'text/css';
cssEl.id = id;
document.head.append(cssEl);
}
switch (mode) {
case 'add':
cssEl.innerHTML += css;
break;
case 'replace':
cssEl.innerHTML = css;
break;
}
}
function addBtnContainer() {
let btnStyle = `
#ehv-btn-c{
text-align: center;
list-style: none;
position: fixed;
bottom: 30px;
right: 30px;
z-index: 999;
}
.ehv-btn {
line-height: 32px;
font-size: 16px;
padding: 2px;
margin: 5px;
color: #233;
position: relative;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
box-shadow: 0 0 3px 0 #0006;
cursor: pointer;
user-select: none;
outline: none;
background-color: #fff;
background-repeat: no-repeat;
background-position: center;
}
.ehv-btn:hover{
box-shadow: 0 0 3px 1px #0005!important;
}
.ehv-btn:active {
top: 1px;
box-shadow: 0 0 1px 1px #0004!important;
}`;
let btnContainer = create('ul');
btnContainer.id = 'ehv-btn-c';
document.body.append(btnContainer);
changeStyle(btnStyle, 'add', 'ehv-btn-c-style');
return btnContainer;
}
function addBtn(btn, container) {
let btnEl = create('li');
if (btn.icon.startsWith('data:image')) {
btnEl.style.backgroundImage = "url('" + btn.icon + "')";
} else {
btnEl.innerHTML = btn.icon;
}
btnEl.classList.add('ehv-btn');
btnEl.addEventListener(btn.event, btn.cb);
container.append(btnEl);
return btnEl;
}
function EHViewer(mode) {
const host = window.location.host;
const svgIcons = {
autofit:
'data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16"><path d="M8 3V5H4V9H2V3H8ZM2 21V15H4V19H8V21H2ZM22 21H16V19H20V15H22V21ZM22 9H20V5H16V3H22V9Z" fill="rgba(120,120,120,1)"></path></svg>',
zoomIn: 'data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16"><path d="M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748ZM10 10V7H12V10H15V12H12V15H10V12H7V10H10Z" fill="rgba(120,120,120,1)"></path></svg>',
zoomOut:
'data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16"><path d="M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748ZM7 10H15V12H7V10Z" fill="rgba(120,120,120,1)"></path></svg>',
prevPage:
'data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16"><path d="M10.8284 12.0007L15.7782 16.9504L14.364 18.3646L8 12.0007L14.364 5.63672L15.7782 7.05093L10.8284 12.0007Z" fill="rgba(120,120,120,1)"></path></svg>',
nextPage:
'data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16"><path d="M13.1714 12.0007L8.22168 7.05093L9.63589 5.63672L15.9999 12.0007L9.63589 18.3646L8.22168 16.9504L13.1714 12.0007Z" fill="rgba(120,120,120,1)"></path></svg>',
reload: 'data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16"><path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z" fill="rgba(120,120,120,1)"></path></svg>',
gallery:
'data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16"><path d="M5.82843 6.99955L8.36396 9.53509L6.94975 10.9493L2 5.99955L6.94975 1.0498L8.36396 2.46402L5.82843 4.99955H13C17.4183 4.99955 21 8.58127 21 12.9996C21 17.4178 17.4183 20.9996 13 20.9996H4V18.9996H13C16.3137 18.9996 19 16.3133 19 12.9996C19 9.68584 16.3137 6.99955 13 6.99955H5.82843Z" fill="rgba(120,120,120,1)"></path></svg>'
};
let css = '';
let btnContainer = addBtnContainer();
let currentScale = 1;
let autofitEnable = true;
switch (mode) {
case 's':
css += `
#i1{
width:98%!important;
max-width:98%!important;
min-width:800px;
background-color: inherit!important;
border: none!important
}
#img{
max-width:none!important;
max-height:none!important;
background-color: #4f535b!important;
padding: 8px;
border: 1px solid #000;
border-radius: 2px;
}
h1, #i2, #i5, #i6, #i7, .ip, .sn{
display:none!important;
}
::-webkit-scrollbar{
display:none;
}
html{
scrollbar-width: none;
}`;
// add btns
[
{ icon: svgIcons.autofit, event: 'mousedown', cb: autofit },
{ icon: svgIcons.zoomIn, event: 'mousedown', cb: e => zoomer(zoomInS) },
{ icon: svgIcons.zoomOut, event: 'mousedown', cb: e => zoomer(zoomOutS) },
{ icon: svgIcons.prevPage, event: 'click', cb: prevPage },
{ icon: svgIcons.nextPage, event: 'click', cb: nextPage },
{ icon: svgIcons.reload, event: 'click', cb: e => $('#loadfail').click() },
{ icon: svgIcons.gallery, event: 'click', cb: e => $('div.sb>a').click() }
].forEach(v => addBtn(v, btnContainer));
// handle key events
document.body.addEventListener('keydown', e => {
switch (e.key) {
case '=':
zoomInS(0.1);
break;
case '-':
zoomOutS(0.1);
break;
case ',':
window.scroll(0, 0);
break;
case '.':
window.scroll(0, window.innerHeight);
break;
case '[':
window.scrollBy(0, -window.innerHeight * 0.3);
break;
case ']':
window.scrollBy(0, window.innerHeight * 0.3);
break;
}
});
// set scale on page load
setNewPage();
// set scale on img load
let observer = new MutationObserver(setNewPage);
observer.observe($('#i3'), { attributes: false, childList: true, subtree: false });
break;
case 'mpv':
css += '#pane_images_inner>div{margin:auto;}';
// add btns
[
{ icon: svgIcons.zoomIn, event: 'mousedown', cb: e => zoomer(zoomInMpv) },
{ icon: svgIcons.zoomOut, event: 'mousedown', cb: e => zoomer(zoomOutMpv) }
].forEach(v => addBtn(v, btnContainer));
document.body.addEventListener('keydown', e => {
switch (e.key) {
case '=':
zoomInMpv(0.1);
break;
case '-':
zoomOutMpv(0.1);
break;
}
});
break;
}
// add style
if (data.script_config.hide_button) {
css += `
#ehv-btn-c{
padding: 80px 30px 30px 80px;
bottom: 0!important;
right: -80px!important;
transition-duration: 300ms;
}
#ehv-btn-c:hover{
padding: 80px 30px 30px 20px!important;
right: 0!important;
}`;
}
css += '.ehv-btn{background-color: #44454B!important;}';
if (host === 'e-hentai.org') {
// btn color
css = css.replaceAll('#44454B', '#D3D0D1');
// page bg & border (for mode 's')
css = css.replace('#4f535b', '#EDEBDF').replace('1px solid #000', '1px solid #5C0D12');
}
changeStyle(css, 'replace', 'ehv-style');
// === functions ===
function autofit() {
autofitEnable = true;
const img = $('#img');
const imgRatio = img.height / img.width;
const windowRatio = window.innerHeight / window.innerWidth;
if (imgRatio > windowRatio) {
// img thinner than window
currentScale = (window.innerHeight - 25) / img.height;
} else {
//img wider than window
currentScale = (window.innerWidth - 25) / img.width;
}
window.scrollTo(0, 0);
zoom4S();
}
function zoomer(cb) {
// long press to trigger continuous scaling(zoomTimeout > zoomInterval)
let zoomInterval = -1;
let zoomTimeout = setTimeout(function () {
zoomInterval = setInterval(cb, 100);
}, 800);
document.addEventListener(
'mouseup',
e => {
if (zoomInterval === -1) {
cb(0.1);
clearTimeout(zoomTimeout);
} else {
clearInterval(zoomInterval);
}
},
{ once: true }
);
}
function prevPage() {
const currentPage = $('.sn>span:first-child').innerText;
currentPage !== '1' ? $('#prev').click() : alert('This is first page (⊙_⊙)');
}
function nextPage() {
const currentPage = $('.sn span:first-child').innerText;
const lastPage = $('.sn span:last-child').innerText;
currentPage !== lastPage ? $('#next').click() : alert('This is last page (⊙ω⊙)');
}
function setNewPage() {
// add page indicator
let footer = $('#i4 > div:first-child');
const currentPage = $('.sn span:first-child').innerText;
const totalPage = $('.sn span:last-child').innerText;
footer.innerHTML = currentPage + 'P / ' + totalPage + 'P :: ' + footer.innerText + ' :: ';
// add origin source
let imgLink = $('#i7>a');
imgLink ? footer.append(imgLink) : (footer.innerHTML += 'No download');
if (autofitEnable) {
autofit();
} else {
// inherit the zoom scale of previous page
zoom4S();
}
}
function zoomInS(pace = 0.02) {
autofitEnable = false;
currentScale = 1 + pace;
zoom4S();
}
function zoomOutS(pace = 0.02) {
autofitEnable = false;
const img = $('#img');
currentScale = 1 - pace;
zoom4S();
}
function zoom4S() {
const img = $('#img');
// img.style.width = currentScale * 100 + '%';
img.style.width = img.width * currentScale + 'px';
img.style.height = 'auto';
}
function zoomInMpv(pace = 0.02) {
const maxWidth = Number($('#pane_images').style.width.replace('px', '')) - 20;
const originalWidth = Number($('#image_1').style.maxWidth.replace('px', ''));
currentScale = originalWidth * (1 + pace) < maxWidth ? currentScale + pace : currentScale;
zoom4Mpv(originalWidth * currentScale);
}
function zoomOutMpv(pace = 0.02) {
const minWidth = 200;
const originalWidth = Number($('#image_1').style.maxWidth.replace('px', ''));
currentScale = originalWidth * (currentScale - pace) > minWidth ? currentScale - pace : currentScale;
zoom4Mpv(originalWidth * currentScale);
}
function zoom4Mpv(width) {
let mpvStyle =
'img[id^="imgsrc"], div[id^="image"]{width:mpvWidth!important;height:auto!important; max-width:100%!important;min-width:200px!important}"';
mpvStyle = mpvStyle.replace('mpvWidth', width + 'px');
changeStyle(mpvStyle, 'replace', 'custom-width');
}
}
function handleGallery() {
// add searchbox
let searchBox = create('form');
searchBox.innerHTML = `
<p class="nopm">
<input type="text" id="f_search" name="f_search" placeholder="Search Keywords" value="" size="60">
<input type="submit" name="f_apply" value="Search">
</p>`;
searchBox.setAttribute(
'style',
'display: none; width: 720px; margin: 10px auto; border: 2px ridge black; padding: 10px;'
);
searchBox.setAttribute('action', 'https://' + window.location.host + '/');
searchBox.setAttribute('method', 'get');
$('.gm').before(searchBox);
// add btn to show/hide searchbox
let tbody = $('#taglist > table > tbody');
tbody.innerHTML += `
<tr>
<td class="tc">EHV:</td>
<td>
<div id="ehv-panel-btn" class="gt" style="cursor:pointer">show panel</div>
</td>
</tr>`;
$('#ehv-panel-btn').addEventListener('click', e => {
const t = e.target;
if (t.innerText == 'show panel') {
searchBox.style.display = 'block';
t.innerText = 'hide panel';
} else {
searchBox.style.display = 'none';
t.innerText = 'show panel';
}
});
// add panel
setPanel();
// add gallery tag to searchbox by right-click
tbody.addEventListener('contextmenu', applyGalleryTag);
function applyGalleryTag(e) {
const t = e.target;
if (t.tagName === 'A') {
e.preventDefault();
const searchInput = $('#f_search');
let tag = t.href.split('/').pop().replaceAll('+', ' ');
filter = `"${tag}" `;
// add tailing space
if (searchInput.value.length > 0 && searchInput.value.endsWith(' ') === false) searchInput.value += ' ';
searchInput.value.includes(filter)
? (searchInput.value = searchInput.value.replace(filter, ''))
: (searchInput.value += filter);
}
}
}
function handleSearchBox() {
setPanel();
const ehvPanel = $('#ehv-panel');
if (data.script_config.hide_panel) {
ehvPanel.style.display = 'none';
const a = create('a');
a.id = 'ehv-panel-btn';
a.innerText = '[Show EHV Panel]';
a.setAttribute('href', '#');
a.setAttribute('style', 'margin-left: 1em;');
a.addEventListener('click', function () {
if (ehvPanel.style.display == 'none') {
ehvPanel.style.display = 'block';
a.innerText = '[Hide EHV Panel]';
} else {
ehvPanel.style.display = 'none';
a.innerText = '[Show EHV Panel]';
}
});
$$('#searchbox>form>div')[1].append(a);
} else {
$('#ehv-panel-btn') && $('#ehv-panel-btn').remove();
}
}
function setPanel() {
const container = $('#f_search').parentNode;
const searchInput = $('#f_search');
// set style
const panelCss = `#ehv-panel > input[type="button"]{ margin: 2px; }`;
let panelStyle = $('#panel-style');
if (!panelStyle) {
panelStyle = create('style');
panelStyle.id = 'panel-style';
panelStyle.textContent = panelCss;
document.head.append(panelStyle);
}
let ehvPanel = $('#ehv-panel');
if (ehvPanel) ehvPanel.remove();
ehvPanel = document.createElement('div');
ehvPanel.setAttribute('class', 'nopm');
ehvPanel.setAttribute('id', 'ehv-panel');
ehvPanel.setAttribute('style', 'padding-top:8px;');
container.append(ehvPanel);
// filter buttons
for (let key in data.custom_filter) {
let filterBtn = create('input');
filterBtn.setAttribute('type', 'button');
filterBtn.setAttribute('value', key);
filterBtn.setAttribute('title', data.custom_filter[key].toString());
filterBtn.addEventListener('click', applyFilter);
filterBtn.addEventListener('contextmenu', removeFilter);
if (isExist(data.custom_filter[key])) {
filterBtn.setAttribute('style', 'filter: invert(20%);');
} else {
filterBtn.removeAttribute('style');
}
ehvPanel.append(filterBtn);
}
//new filter button
let addFilterBtn = create('input');
addFilterBtn.setAttribute('type', 'button');
addFilterBtn.setAttribute('value', '+');
addFilterBtn.addEventListener('click', addFilter);
addFilterBtn.addEventListener('contextmenu', ehvSetting);
ehvPanel.append(addFilterBtn);
// enhace apply filter button
const searchBtn = $('#f_search+input');
searchBtn.addEventListener('contextmenu', newtabSearch);
searchBtn.title = 'right click to search in new page';
// === function ===
function isExist(values) {
const inputValue = searchInput.value;
return values.every(v => inputValue.includes(v));
}
function applyFilter(e) {
let t = e.target;
let key = t.value;
let values = data.custom_filter[key];
// add tailing space
if (searchInput.value.length > 0 && searchInput.value.endsWith(' ') === false) searchInput.value += ' ';
if (isExist(values)) {
values.forEach(v => (searchInput.value = searchInput.value.replaceAll(`"${v}" `, '')));
t.removeAttribute('style');
} else {
values.forEach(v => (searchInput.value += `"${v}" `));
t.setAttribute('style', 'filter: invert(20%);');
}
}
function addFilter() {
data.custom_filter = GM_getValue('custom_filter', {}); // get latest filter data
const input = prompt('Add filter like format below', '[tag] or [name@tag] or [name@tag+tag+tag+tag]');
const filterStr = input ? input.split('@') : '';
let key, value;
if (filterStr.length === 2) {
key = filterStr[0];
value = filterStr[1].split('+');
data.custom_filter[key] = value;
} else if (filterStr.length === 1 && filterStr[0] !== '') {
key = value = filterStr[0];
data.custom_filter[key] = [value];
} else {
alert('Invalid input...:(');
return;
}
// sort filters by char codepoint
if (sortFilters === true) {
const newFilters = {};
const sortedKeys = Object.keys(data.custom_filter).sort();
for (let key of sortedKeys) {
newFilters[key] = data.custom_filter[key];
}
data.custom_filter = newFilters;
}
GM_setValue('custom_filter', data.custom_filter);
setPanel();
}
function ehvSetting(e) {
e.preventDefault();
data.script_config = GM_getValue('script_config', {}); // sync latest config data
data.script_config.hide_panel = confirm('hide EHV panel by default?');
data.script_config.hide_button = confirm(
'hide comic page buttons by default? (hover lower right corner to show)'
);
GM_setValue('script_config', data.script_config);
handleSearchBox();
}
function removeFilter(e) {
e.preventDefault();
data.custom_filter = GM_getValue('custom_filter', {}); // get latest filter data
if (confirm('Delete this tag?')) {
let key = e.target.value;
delete data.custom_filter[key];
GM_setValue('custom_filter', data.custom_filter);
setPanel();
}
}
function newtabSearch(e) {
e.preventDefault();
const keyword = e.target.previousElementSibling.value;
const url = `https://${location.host}/?f_search=${keyword}`;
open(url);
}
}
function showMagnetLink() {
const links = $$('a');
for (let link of links) {
if (link.href.endsWith('.torrent')) {
let magnetLink = create('a');
let torrentHash = link.href.match(/[\w\d]{40}/)[0];
magnetLink.href = `magnet:?xt=urn:btih:${torrentHash}&dn=${link.innerText}`;
magnetLink.innerText = '[MAGNET]';
link.before(magnetLink);
let span = create('span');
span.innerText = ' ';
link.before(span);
}
}
}
function createSettingPanel() {
// todo:
// custom filter editor
// hide ehv search panel by default
// hide ehv sidebar buttons by default
console.log('creating ehv setting panel');
let container = create('div');
container.innerHTML = `
<div id="ehv-setting-panel" style="display: flex; flex-direction: column; gap:.5rem; background-color: #CCCE; border-radius: 3px; box-shadow: 0 0 3px 0 #0008; margin: 5rem auto; padding: 1rem; max-width: 50rem;">
<span style="color:#233; text-align:left; font-size:12px;">liked tags:</span>
<textarea id="ehv-liked-tags" style="padding: .5em;" placeholder="enter tags you like here, separated by comma"></textarea>
<span style="color:#233; text-align:left; font-size:12px;">disliked tags:</span>
<textarea id="ehv-disliked-tags" style="padding: .5em;" placeholder="enter tags you don't like here, separated by comma"></textarea>
<div style="text-align: right;">
<button id="ehv-save-btn">save</button>
<button id="ehv-cancel-btn">cancel</button>
<div>
</div>`;
container.setAttribute(
'style',
'background-color: transparent; width: 100vw; height: 100vh; position: fixed; top: 0; left: 0; z-index: 9999'
);
const likedTagsInput = container.querySelector('#ehv-liked-tags');
const dislikedTagsInput = container.querySelector('#ehv-disliked-tags');
const saveBtn = container.querySelector('#ehv-save-btn');
const cancelBtn = container.querySelector('#ehv-cancel-btn');
// get latest tag_pref value
data.tag_pref = GM_getValue('tag_pref', { liked_tags: [], disliked_tags: [] });
likedTagsInput.value = data.tag_pref.liked_tags.join(',');
dislikedTagsInput.value = data.tag_pref.disliked_tags.join(',');
saveBtn.onclick = () => {
data.tag_pref.liked_tags = likedTagsInput.value.split(',');
data.tag_pref.disliked_tags = dislikedTagsInput.value.split(',');
highlightTags(data);
GM_setValue('tag_pref', data.tag_pref);
container.remove();
};
cancelBtn.onclick = () => container.remove();
document.body.append(container);
}
function highlightTags() {
// highlight tags
let tagEls = $$('.gt, .gtl');
const likedTags = data.tag_pref.liked_tags;
const dislikedTags = data.tag_pref.disliked_tags;
for (let tagEl of tagEls) {
let tagStr = tagEl.firstChild.textContent;
if (likedTags.includes(tagStr)) {
tagEl.style.backgroundColor = '#CCE8';
tagEl.class;
} else if (dislikedTags.includes(tagStr)) {
tagEl.style.backgroundColor = '#2338';
} else {
tagEl.style.backgroundColor = '';
}
}
}