Numerous features to enrich your browsing experience
// ==UserScript==
// @name E-Hentai - UX Tweaks
// @namespace brazenvoid
// @version 2.2.8
// @author brazenvoid
// @license GPL-3.0-only
// @description Numerous features to enrich your browsing experience
// @match https://e-hentai.org/*
// @match https://exhentai.org/*
// @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js
// @require https://update.greasyfork.org/scripts/375557/1875051/Brazen%20Framework%20-%20Utilities.js
// @require https://update.greasyfork.org/scripts/416104/1875150/Brazen%20Framework%20-%20View%20Layer.js
// @require https://update.greasyfork.org/scripts/418665/1875786/Brazen%20Framework%20-%20Configuration%20Manager.js
// @require https://update.greasyfork.org/scripts/429587/1847139/Brazen%20Framework%20-%20Item%20Attributes%20Resolver.js
// @require https://update.greasyfork.org/scripts/416105/1875149/Brazen%20Framework%20-%20Framework.js
// @grant GM_addStyle
// @grant GM_download
// @run-at document-end
// ==/UserScript==
GM_addStyle(
String.raw`#bv-ui{font-size:1rem;min-width:310px;width:310px}#bv-ui textarea.bv-input,#bv-ui .bv-textarea-group>label.bv-label,#bv-ui .bv-stat-group :is(.bv-label,.bv-stat-label){font-family:var(--bv-font-family,ui-sans-serif,system-ui,sans-serif);font-size:1rem;font-weight:normal}#bv-ui textarea.bv-input{line-height:1.35}.adult-tag::after{content:'\2713';position:absolute;top:-8px;right:0;font-size:10px;color:aquamarine}.disliked-tag{background-color:lightcoral !important;color:white !important}.disliked-tag:hover{background-color:indianred !important}.disliked-tag > a{color:white !important}.disliked-tag.favourite-tag{background-color:orange !important}.disliked-tag.favourite-tag:hover{background-color:darkorange !important}.favourite-tag{background-color:mediumseagreen !important;color:white !important}.favourite-tag:hover{background-color:forestgreen !important}.favourite-tag > a{color:white !important}.underage-tag::after{content:'\2717';position:absolute;top:-8px;right:0;font-size:10px;color:red}.unknown-age-tag::after{content:'\003F';position:absolute;top:-8px;right:0;font-size:10px;color:yellow}`)
const ITEM_RATED_BLUE = 'ratedBlue'
const ITEM_RATED_GREEN = 'ratedGreen'
const ITEM_RATED_RED = 'ratedRed'
const ITEM_TAGS = 'tags'
const ITEM_WATCHED = 'watched'
const FILTER_RATED_VIDEOS = 'hide-rated-galleries'
const FILTER_UNDERAGE_CHARACTERS_OPTION = 'enable-underage-females-filter'
const FILTER_WATCHED_FROM_SEARCH = 'hide-watched-galleries'
const STYLE_GALLERY_HIGHLIGHT = 'gallery-highlight'
const UI_DEFAULTS_PAGE_RANGE = 'page-range'
const UI_DEFAULTS_PAGE_RANGE_ENABLE = 'enable-page-range-filter'
const UI_DEFAULTS_RATING = 'rating'
const UI_DEFAULTS_RATING_ENABLE = 'enable-rating-filter'
const UI_DEFAULTS_TAGS = 'tags'
const UI_DEFAULTS_TAGS_ENABLE = 'enable-default-tags'
const UI_OPEN_GALLERIES_AUTO_NEXT = 'search-auto-next'
const UI_OPEN_GALLERY_PAGES_AUTO_NEXT = 'gallery-auto-next'
const UI_OPEN_GALLERY_PAGES_THROTTLING = 'throttling-bypass'
const UI_OPEN_GALLERY_PAGES_DOWNLOAD = 'download-the-image'
const UI_EMBED_TORRENTS = 'embed-torrent-downloads'
const UI_VISITED_HIGHLIGHT = 'highlight-visited'
const UI_GALLERY_HIGHLIGHTS = 'gallery-highlights'
const UI_GALLERY_HIGHLIGHTS_COLOUR = 'highlight-colour'
const UI_TAG_HIGHLIGHTS_ADULT_CHARACTER = 'adult-characters'
const UI_TAG_HIGHLIGHTS_DISLIKED = 'disliked-tags'
const UI_TAG_HIGHLIGHTS_FAVOURITE = 'favourite-tags'
const UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER = 'underage-characters'
const UI_TAG_HIGHLIGHTS_UNKNOWN_AGE_CHARACTER = 'unknown-age-characters'
const LEGACY_CONFIG_KEY_MAP = [
['Hide Rated Galleries', FILTER_RATED_VIDEOS],
['Enable Underage Females Filter', FILTER_UNDERAGE_CHARACTERS_OPTION],
['Hide Watched Galleries', FILTER_WATCHED_FROM_SEARCH],
['Page Range', UI_DEFAULTS_PAGE_RANGE],
['Enable Page Range Filter', UI_DEFAULTS_PAGE_RANGE_ENABLE],
['Rating', UI_DEFAULTS_RATING],
['Enable Rating Filter', UI_DEFAULTS_RATING_ENABLE],
['Tags', UI_DEFAULTS_TAGS],
['Enable Default Tags', UI_DEFAULTS_TAGS_ENABLE],
['Search Auto Next', UI_OPEN_GALLERIES_AUTO_NEXT],
['Gallery Auto Next', UI_OPEN_GALLERY_PAGES_AUTO_NEXT],
['Throttling Bypass', UI_OPEN_GALLERY_PAGES_THROTTLING],
['Download The Image', UI_OPEN_GALLERY_PAGES_DOWNLOAD],
['Embed Torrent Downloads', UI_EMBED_TORRENTS],
['Highlight Visited', UI_VISITED_HIGHLIGHT],
['Gallery Highlights', UI_GALLERY_HIGHLIGHTS],
['Highlight Colour', UI_GALLERY_HIGHLIGHTS_COLOUR],
['Adult Characters', UI_TAG_HIGHLIGHTS_ADULT_CHARACTER],
['Disliked Tags', UI_TAG_HIGHLIGHTS_DISLIKED],
['Favourite Tags', UI_TAG_HIGHLIGHTS_FAVOURITE],
['Underage Characters', UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER],
['Unknown Age Characters', UI_TAG_HIGHLIGHTS_UNKNOWN_AGE_CHARACTER],
]
/**
* @param {string[]} rules
* @return {string[][]}
*/
function optimizeTagRulesAsTags(rules)
{
let orTags, iteratedRuleset
let optimizedRuleset = []
let expandRuleset = (ruleset, tags) => {
let grownRuleset = []
for (let tag of tags) {
let cleanedTag = tag.trim()
for (let rule of ruleset) {
grownRuleset.push([...rule, cleanedTag])
}
}
return grownRuleset
}
let growRuleset = (ruleset, tagToAdd) => {
if (ruleset.length) {
tagToAdd = tagToAdd.trim()
for (let rule of ruleset) {
rule.push(tagToAdd)
}
} else {
let tags = typeof tagToAdd === 'string' ? [tagToAdd] : tagToAdd
for (let tag of tags) {
ruleset.push([tag.trim()])
}
}
}
for (let rule of rules) {
iteratedRuleset = []
for (let andTag of rule.split(' // ')[0].split('&')) {
orTags = andTag.split('|')
if (orTags.length === 1) {
growRuleset(iteratedRuleset, andTag)
} else if (iteratedRuleset.length) {
iteratedRuleset = expandRuleset(iteratedRuleset, orTags)
} else {
growRuleset(iteratedRuleset, orTags)
}
}
optimizedRuleset = optimizedRuleset.concat(iteratedRuleset)
}
return optimizedRuleset.sort((a, b) => a.length - b.length)
}
class EHentaiSearchAndUITweaks extends BrazenFramework
{
constructor()
{
super({
downloadsDelay: 2,
isUserLoggedIn: false,
itemDeepAnalysisSelector: 'div.gm',
itemLinkSelector: {
extended: '.gl2e > div > a',
compact: '.gl3c.glname > a',
minimal: '.gl3m.glname > a',
thumbnail: '.gl3t > a',
},
itemListSelectors: {
extended: '.itg.glte > tbody',
compact: '.itg.gltc > tbody',
minimal: '.itg.gltm > tbody',
thumbnail: '.itg.gld',
},
itemNameSelector: {
extended: '.gl4e.glname > div.glink',
compact: '.gl3c.glname > a > div.glink',
minimal: '.gl3m.glname > a > div.glink',
thumbnail: '.gl4t.glname',
},
itemSelectors: {
extended: 'tr',
compact: 'tr',
minimal: 'tr',
thumbnail: 'div.gl1t',
},
itemSelectionMethod: 'children',
requestDelay: 0,
scriptPrefix: 'e-hentai-ux-',
tagSelectorGenerator: (tag) => {
tag = tag.trim()
if (this.isPage('gallery')) {
let tagAttribute = tag.replaceAll(' ', '_')
return 'div[id="td_' + tagAttribute + '"]'
}
return 'div.gt[title="' + tag + '"], div.gtl[title="' + tag + '"]'
},
})
this.definePages({
smallWindow: () => $('.stuffbox').length > 0,
search: {
detect: () => $('#f_search').length > 0,
layout: () => {
if ($('table.itg.glte').length > 0) {
return 'extended'
}
if ($('table.itg.gltm').length > 0) {
return 'minimal'
}
if ($('table.itg.gltc').length > 0) {
return 'compact'
}
if ($('div.itg.gld').length > 0) {
return 'thumbnail'
}
return null
},
},
tagSearch: () => location.pathname.startsWith('/tag'),
uploaderSearch: () => location.pathname.startsWith('/uploader'),
watched: () => document.querySelectorAll('.ido > div > p.ip')?.length > 0,
gallery: () => $('#gdt').length > 0,
image: () => location.pathname.startsWith('/s/'),
})
this.setCompliancePages(['search'])
this._onValidateInit = () => !this.isPage('smallWindow')
this._setupFeatures()
this._setupFilters()
this._onBeforeFullInit.unshift(() => {
this._migrateLegacyConfigKeys()
// Phase 0 resolves layout-map itemLinkSelector on _config; the Item Attributes Resolver
// still holds the raw map from construct time — sync so deep fetch can find hrefs.
if (this._config.itemLinkSelector !== undefined) {
this._itemAttributesResolver._itemLinkSelector = this._config.itemLinkSelector
}
this._registerItemTagAttribute()
})
this._onBeforeFullInit.push(() => {
this._setupUI()
this._setupEvents()
})
}
/**
* @private
*/
_migrateLegacyConfigKeys()
{
let settingsKey = this._config.scriptPrefix + 'settings'
let storedStore = localStorage.getItem(settingsKey)
if (!storedStore || storedStore === '') {
return
}
let settings
try {
settings = JSON.parse(storedStore)
} catch {
return
}
if (!settings || typeof settings !== 'object') {
return
}
let changed = false
for (let [oldKey, newKey] of LEGACY_CONFIG_KEY_MAP) {
if (settings[oldKey] !== undefined && settings[newKey] === undefined) {
settings[newKey] = settings[oldKey]
delete settings[oldKey]
changed = true
}
}
if (changed) {
localStorage.setItem(settingsKey, JSON.stringify(settings))
}
}
/**
* Register tag attribute after page/layout detection.
* Thumbnail and minimal search layouts have no on-page tag chips — deep-fetch gallery HTML.
* @private
*/
_registerItemTagAttribute()
{
let searchLayout = this.getLayout('search')
let deepAttribute = searchLayout === 'thumbnail' || searchLayout === 'minimal'
this._addItemTagAttribute(
ITEM_TAGS,
deepAttribute,
false,
(item) => this._gatherItemTags(item))
}
/**
* @param {string} configKey
* @return {string[][]}
* @private
*/
_getTagOnlyRulesFromField(configKey)
{
let field = this._configurationManager.getField(configKey)
if (!field?.value?.length) {
return []
}
let rules = field.value
if (typeof rules === 'string') {
rules = rules.split('\n').map((line) => line.trim()).filter(Boolean)
}
if (field.formatter) {
rules = field.formatter(rules)
}
return optimizeTagRulesAsTags(rules)
}
/**
* @return {string}
* @private
*/
_resolveDownloadFolderFromTitle()
{
return ((f) => (f.includes('|') ? (f.match(/^(\s*\[[^\]]+\])/)?.[1] || '') + f.split('|')[1] : f))(document.title.replace(' --- Porn', ''))
}
/**
* @param {ItemTagHighlightsConfiguration} config
* @protected
*/
_ageFilterHighlighter(config)
{
this.registerHighlightStyleClass(config.styleClass)
this._configurationManager.addTagRulesetField(config.configKey, true).
setRows(config.rows ?? 5).
setHelpText(config.helpText).
setFormatter((rules) => this._morphCharacterNamesToTags(rules)).
setSortRules(true)
let highlightsHandler = (section) => this._paintAgeTagHighlights(section, config.configKey, config.styleClass, config.removeClasses)
this._onItemShow.push((item) => highlightsHandler(item))
}
/**
* Paint favourite/disliked tag-ruleset highlights (CSS selectors in field.optimized).
* @param {JQuery} section
* @param {string} configKey
* @param {string} styleClass
* @param {string} [removeClasses]
* @private
*/
_paintTagRulesetHighlights(section, configKey, styleClass, removeClasses)
{
let optimizedRuleset = this._configurationManager.getField(configKey)?.optimized
if (!optimizedRuleset) {
return
}
let ruleApplies, subjectTags
for (let rule of optimizedRuleset) {
ruleApplies = true
subjectTags = section.find(rule.join(', '))
for (let tagSelector of rule) {
if (section.find(tagSelector).length === 0) {
ruleApplies = false
break
}
}
if (ruleApplies) {
subjectTags.addClass(styleClass)
if (removeClasses !== undefined) {
subjectTags.removeClass(removeClasses)
}
} else {
subjectTags.removeClass(styleClass)
}
}
}
/**
* Paint age character/parody pair highlights.
* @param {JQuery} section
* @param {string} configKey
* @param {string} styleClass
* @param {string} [removeClasses]
* @private
*/
_paintAgeTagHighlights(section, configKey, styleClass, removeClasses)
{
let optimized = this._configurationManager.getField(configKey)?.optimized
if (!optimized) {
return
}
let characterTag, characterTagExists, parodyTagExists
for (let rule of optimized) {
characterTag = section.find(rule[0])
characterTagExists = characterTag.length === 1
parodyTagExists = section.find(rule[1]).length === 1
if (characterTagExists && parodyTagExists) {
characterTag.addClass(styleClass)
if (removeClasses !== undefined) {
characterTag.removeClass(removeClasses)
}
}
}
}
/**
* Gallery `#taglist` chip highlights (wired after page detection — not in constructor).
* @param {JQuery} taglist
* @private
*/
_paintGalleryPageTagHighlights(taglist)
{
if (!taglist?.length) {
return
}
this._paintTagRulesetHighlights(taglist, UI_TAG_HIGHLIGHTS_FAVOURITE, 'favourite-tag')
this._paintTagRulesetHighlights(taglist, UI_TAG_HIGHLIGHTS_DISLIKED, 'disliked-tag')
this._paintAgeTagHighlights(taglist, UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER, 'underage-tag', 'favourite-tag disliked-tag')
this._paintAgeTagHighlights(taglist, UI_TAG_HIGHLIGHTS_UNKNOWN_AGE_CHARACTER, 'unknown-age-tag')
this._paintAgeTagHighlights(taglist, UI_TAG_HIGHLIGHTS_ADULT_CHARACTER, 'adult-tag')
}
/**
* Append synthetic `type:…` when real tags are already on the item (shallow path, or after deep load).
* Do not seed a type-only bag while deep fetch is pending — that races with deep overwrite.
* @param {JQuery} item
* @private
*/
_appendGalleryTypeTag(item)
{
let bag = item[0]?.scriptAttributes
if (!bag || !Array.isArray(bag.tags)) {
return
}
let galleryType = item.find('.cn,.cs').first().text()
let galleryTypeMap = new Map([
['Artist CG', 'acg'],
['Asian Porn', 'aporn'],
['Cosplay', 'cosplay'],
['Doujinshi', 'dj'],
['Game CG', 'gcg'],
['Image Set', 'img'],
['Manga', 'manga'],
['Misc', 'misc'],
['Non-H', 'nonh'],
['Western', 'west'],
])
let mapped = galleryTypeMap.get(galleryType)
if (!mapped) {
return
}
let typeTag = 'type:' + mapped
if (!bag.tags.includes(typeTag)) {
bag.tags.push(typeTag)
}
}
/**
* @param {string} tag
* @return {string}
* @private
*/
_formatTag(tag)
{
if (tag.includes(':') && !tag.includes('"') && (tag.includes(' ') || tag.includes('+'))) {
tag = tag.replace(':', ':"') + '"'
}
return tag
}
/**
*
* @param {JQuery} item
* @return {string[]}
* @private
*/
_gatherItemTags(item)
{
let tags = []
item.find('.gt,.gtl').each((_i, e) => {
let element = $(e)
let title = element.attr('title')
if (title) {
tags.push(title)
return
}
let tagID = element.find('a').attr('id') ?? element.attr('id')
if (!tagID) {
return
}
if (tagID.startsWith('ta_')) {
tagID = tagID.replace('ta_', '')
}
if (tagID.startsWith('td_')) {
tagID = tagID.replace('td_', '')
}
tags.push(tagID.replaceAll('_', ' '))
})
return tags
}
/**
* @param {{}} range
* @param {URLSearchParams} queryParams
* @private
*/
_handleDefaultPageRangeFilter(range, queryParams)
{
if (range.minimum > 0) {
queryParams.set('f_spf', range.minimum)
}
if (range.maximum > 0) {
queryParams.set('f_spt', range.maximum)
}
}
/**
* @param {string} rating
* @param {URLSearchParams} queryParams
* @private
*/
_handleDefaultRatingsFilter(rating, queryParams)
{
queryParams.set('f_srdd', rating)
}
/**
* @param {string[]} tags
* @param {URLSearchParams} queryParams
* @private
*/
_handleDefaultTags(tags, queryParams)
{
let existingTags = queryParams.get('f_search')
let updatedTags = existingTags
let include = true
for (let tag of tags) {
if (existingTags.includes(tag)) {
include = false
break
} else {
updatedTags += '+' + this._formatTag(tag)
}
}
if (include) {
queryParams.set('f_search', updatedTags)
}
}
/**
* @private
*/
_handleDefaults()
{
let queryParams = new URLSearchParams(location.search)
let existingParams = queryParams.toString()
if (!queryParams.has('next') &&
(this._getConfig(UI_DEFAULTS_PAGE_RANGE_ENABLE) || this._getConfig(UI_DEFAULTS_RATING_ENABLE) || this._getConfig(UI_DEFAULTS_TAGS_ENABLE))) {
if (!queryParams.has('f_search')) {
let existingTag = ''
let urlSegments = location.pathname.split('/')
if (this.isPage('tagSearch')) {
existingTag = urlSegments.pop().trim()
} else if (this.isPage('uploaderSearch')) {
existingTag = 'uploader:' + urlSegments.pop().trim()
}
queryParams.set('f_search', existingTag.length ? this._formatTag(existingTag) : '')
}
if (!queryParams.has('advsearch')) {
queryParams.set('advsearch', '1')
}
let validatePageRange = (range, defaultValidator) => defaultValidator(range) && !queryParams.has('f_spf') &&
!queryParams.has('f_spt')
this._performTogglableComplexOperation(UI_DEFAULTS_PAGE_RANGE_ENABLE, UI_DEFAULTS_PAGE_RANGE, validatePageRange,
(range) => {
this._handleDefaultPageRangeFilter(range, queryParams)
})
let validateRatingFilter = (range, defaultValidator) => defaultValidator(range) && !queryParams.has('f_srdd')
this._performTogglableComplexOperation(UI_DEFAULTS_RATING_ENABLE, UI_DEFAULTS_RATING, validateRatingFilter,
(rating) => {
this._handleDefaultRatingsFilter(rating, queryParams)
})
this._performTogglableOperation(UI_DEFAULTS_TAGS_ENABLE, UI_DEFAULTS_TAGS, (tags) => {
this._handleDefaultTags(tags, queryParams)
})
let updatedParams = queryParams.toString().replaceAll('%2B', '+')
if (updatedParams !== existingParams) {
if (this.isPage('tagSearch') || this.isPage('uploaderSearch')) {
location.href = location.origin + '?' + updatedParams
} else {
location.href = location.origin + location.pathname + '?' + updatedParams
}
}
}
}
/**
* The site sets `#img.src` twice: first a decoy `*.hath.network/om/.../x/0/...` URL with no
* `keystamp` token (404s), then the real `*.hath.network/h/...keystamp=...;xres=org/...` URL after
* its P2P resolution. Only the latter is downloadable.
*
* @param {*} src
* @return {boolean}
* @private
*/
_isValidImageSource(src)
{
// Real image URLs are the original (`hath.network/h/...keystamp=`) or a resample
// (`hath.network/om/.../<width>/...`, e.g. `/1280/`). The downloader honeypot is an `/om/` URL
// whose resample slot is the placeholder `/x/0/` (no real resample) — that one is the decoy and
// must be skipped. Every other hath image URL is downloadable, including `/om/` resamples (which
// never swap to a `/h/` URL, so we must NOT reject `/om/` wholesale).
if (typeof src !== 'string' || !/hath\.network(:\d+)?\/(h|om)\//.test(src)) {
return false
}
return !src.includes('/x/0/')
}
/**
* Fires `callback` as soon as `#img` carries a valid (non-decoy) `src`, WITHOUT waiting for `load`.
*
* Resolving immediately is the whole point: the download must start the instant the tab opens (not
* after the image finishes loading), which also keeps it reliable when many tabs are opened in quick
* succession. `#img` may hold the real URL up front, or start as the `/x/0/` decoy and swap to the
* real one; `_isValidImageSource` rejects the decoy, and a poll + observer catch the swap (the poll
* is the reliable part — a `MutationObserver` alone can miss the update under heavy tab churn).
*
* @param {function(HTMLImageElement): void} callback
* @private
*/
_resolveImageForDownload(callback)
{
let resolved = false
let observer = null
let poll = null
let timeout = null
let cleanup = () => {
observer?.disconnect()
if (poll) {
clearInterval(poll)
}
if (timeout) {
clearTimeout(timeout)
}
}
let attempt = () => {
if (resolved) {
return
}
let image = document.querySelector('#img')
if (!(image instanceof HTMLImageElement) || !this._isValidImageSource(image.src)) {
return
}
resolved = true
cleanup()
callback(image)
}
attempt()
if (resolved) {
return
}
observer = new MutationObserver(attempt)
observer.observe(document.documentElement, {subtree: true, childList: true, attributes: true, attributeFilter: ['src']})
poll = setInterval(attempt, 150)
timeout = setTimeout(cleanup, 30000)
}
/**
* @param {boolean} removeElement
* @private
*/
_handleDownloadMedia(removeElement)
{
this._resolveImageForDownload((image) => {
let folder = this._resolveDownloadFolderFromTitle()
let filename = image.src.split('/').pop()
// Queue the download FIRST, while `#img` is still in the DOM with its valid `src`. `_addDownload`
// fires `GM_download` synchronously (eager Promise executor), so the download starts immediately.
// Guarded so a throw can't skip the node removal below.
try {
this._addDownload(image, folder, filename, false)
} catch (error) {
console.error('e-hentai download failed to enqueue:', error)
}
// Then remove the node DIRECTLY (plain detach, never `src=''`) so the image is not displayed and
// its memory is freed in this tab. A direct detach does not trip `#img`'s `onerror -> nl()` retry.
if (removeElement) {
image.remove()
}
})
}
/**
* @return {string[][]}
* @private
*/
_getFavouriteTagMatchRules()
{
return this._getTagOnlyRulesFromField(UI_TAG_HIGHLIGHTS_FAVOURITE)
}
/**
* @param {JQuery} item
* @private
*/
_handleGalleryHighlights(item)
{
let mode = this._getConfig(UI_GALLERY_HIGHLIGHTS)
let itemHasHighlight = item.hasClass(STYLE_GALLERY_HIGHLIGHT)
if (mode !== 'Disabled') {
let itemTags = this._get(item, ITEM_TAGS), doHighlight = false, tag
if (itemTags) {
for (let rule of this._getFavouriteTagMatchRules()) {
doHighlight = true
for (let tag of rule) {
if ((mode === 'All' && !itemTags.includes(tag)) ||
(mode === 'Source' && ((!tag.startsWith('artist:') && !tag.startsWith('group:')) || !itemTags.includes(tag)))) {
doHighlight = false
break
}
}
if (doHighlight) {
if (!itemHasHighlight) {
item.addClass(STYLE_GALLERY_HIGHLIGHT)
}
break
}
}
if (!doHighlight && itemHasHighlight) {
item.removeClass(STYLE_GALLERY_HIGHLIGHT)
}
}
} else if (itemHasHighlight) {
item.removeClass(STYLE_GALLERY_HIGHLIGHT)
}
}
_handleOpenGalleries()
{
let links = document.querySelectorAll('.' + CLASS_COMPLIANT_ITEM + ' > ' + this._config.itemLinkSelector)
if (links.length > 0) {
links.forEach(itemLink => window.open(itemLink.href))
if (this._getConfig(UI_OPEN_GALLERIES_AUTO_NEXT)) {
document.querySelector('#unext').click()
}
}
}
/**
* @private
*/
async _handleOpenGalleryImages()
{
let bypassThrottling = this._getConfig(UI_OPEN_GALLERY_PAGES_THROTTLING)
let images = $('#gdt > a')
let firstPageNumber = images.first().attr('href').split('-').pop()
let maxPages = firstPageNumber + images.length - 1
for (let page = images.length - 1; page >= 0; page--) {
window.open(images.eq(page).attr('href'))
if (bypassThrottling && page !== 0) {
await Utilities.sleep(2000)
}
}
if (this._getConfig(UI_OPEN_GALLERY_PAGES_AUTO_NEXT)) {
let page = location.href.split('=')[1] ?? 0
let pageNavs = $('.ptt td')
maxPages = Number.parseInt(pageNavs.eq(pageNavs.length - 2).children('a').text()) - 1
if (page < maxPages) {
let uri = location.href
if (page === 0) {
uri += '?p=1'
} else {
uri = uri.replace('?p=' + page++, '?p=' + page)
}
location.href = uri
}
}
}
/**
* @param {JQuery} item
* @param {string} option
* @private
*/
_handleRatedGalleries(item, option)
{
let doesntComply
switch (option) {
case 'Blue':
doesntComply = this._get(item, ITEM_RATED_BLUE)
break
case 'Green':
doesntComply = this._get(item, ITEM_RATED_GREEN)
break
case 'Red':
doesntComply = this._get(item, ITEM_RATED_RED)
break
case 'All':
doesntComply = this._get(item, ITEM_RATED_BLUE) || this._get(item, ITEM_RATED_GREEN) || this._get(item, ITEM_RATED_RED)
break
}
return !doesntComply
}
/**
* @private
*/
_handleTorrentDownloadsEmbedding()
{
let link = $('#gd5 > .g2 > a').eq(1)
if (!link.text().endsWith('(0)')) {
let container = $('<div class="gm"></div>').insertBefore('#cdiv')
container.load(link.attr('onclick').replace('return popUp(\'', '').replace('\',610,590)', '') + ' form', () => {
container.prepend('<h1 style="font-size:10pt; font-weight:bold; margin:3px; text-align:center">Torrents</h1>')
link.parent().remove()
})
}
}
_handleUnderageFilter()
{
this._configurationManager.addFlagField(FILTER_UNDERAGE_CHARACTERS_OPTION).
setTitle('Enable Underage Females Filter').
setHelpText('Applies the underage filter.')
this._ageFilterHighlighter({
configKey: UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER,
styleClass: 'underage-tag',
removeClasses: 'favourite-tag disliked-tag',
helpText: "Filter and mark character tags that are not adults in any canon timelines.\nFormat: [character tag] from [parody tag] // [comments]\nExample: lynae from wuthering waves",
})
this._ageFilterHighlighter({
configKey: UI_TAG_HIGHLIGHTS_UNKNOWN_AGE_CHARACTER,
styleClass: 'unknown-age-tag',
helpText: "Specify characters which don't have officially declared age.\nFormat: [character tag] from [parody tag]\nExample: chiori from genshin impact",
})
this._ageFilterHighlighter({
configKey: UI_TAG_HIGHLIGHTS_ADULT_CHARACTER,
styleClass: 'adult-tag',
helpText: "Specify characters checked to be adults.\nFormat: [character tag] from [parody tag] // [comments]\nExample: cantarella from wuthering waves",
})
this._addItemComplexComplianceFilter(
UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER,
(rules) => this._getConfig(FILTER_UNDERAGE_CHARACTERS_OPTION) && rules.length,
(item) => {
let itemTags = this._get(item, ITEM_TAGS)
if (itemTags !== null && itemTags.length) {
for (let rule of this._getTagOnlyRulesFromField(UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER)) {
let isBlacklisted = true
for (let tag of rule) {
if (!itemTags.includes(tag)) {
isBlacklisted = false
break
}
}
if (isBlacklisted) {
return false
}
}
}
return true
})
this._configurationManager.getField(UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER)?.setTitle('Underage Characters')
this._configurationManager.getField(UI_TAG_HIGHLIGHTS_UNKNOWN_AGE_CHARACTER)?.setTitle('Unknown Age Characters')
this._configurationManager.getField(UI_TAG_HIGHLIGHTS_ADULT_CHARACTER)?.setTitle('Adult Characters')
}
/**
* @param {string[]} rules
* @return {string[]}
* @private
*/
_morphCharacterNamesToTags(rules)
{
let morphedRules = [], tags
for (let rule of rules) {
tags = rule.split(' // ')[0].split(' from ')
if (tags.length === 2) {
morphedRules.push('character:' + tags[0] + ' & parody:' + tags[1])
}
}
return morphedRules
}
/**
* @private
*/
_setupEvents()
{
this._onBeforeUIBuild.push(() => {
this._performOperation(UI_VISITED_HIGHLIGHT, () => {
GM_addStyle(`td.gl2e > div > a:visited > .glname > .glink {color: black;}`)
})
this._forPage('search', () => {
this._handleDefaults(this._getConfig(UI_GALLERY_HIGHLIGHTS_COLOUR))
GM_addStyle('.gallery-highlight,.gallery-highlight>td,.gallery-highlight>div{background-color:' + this._getConfig(UI_GALLERY_HIGHLIGHTS_COLOUR) + ' !important}.gallery-highlight{border:whitesmoke 2px solid}')
})
this._forPage('gallery', () => {
this._paintGalleryPageTagHighlights($('#taglist'))
})
this._forPage('image', () => {
if (this._getConfig(UI_OPEN_GALLERY_PAGES_DOWNLOAD)) {
// Disable the UI first so it is never built/embedded on an auto-download image page,
// regardless of the (deferred) download wiring.
this._disableUI = true
this._handleDownloadMedia(true)
}
})
})
this._onAfterUIBuild.push(() => {
this._uiGen.getSelectedSection()[0].userScript = this
this._forPage('gallery', () => {
this._performOperation(UI_EMBED_TORRENTS, () => this._handleTorrentDownloadsEmbedding())
})
})
this._onFirstHitBeforeCompliance.push((item) => this._appendGalleryTypeTag(item))
this._onBeforeCompliance.push((item) => this._appendGalleryTypeTag(item))
this._onItemHide = (item) => {
if (item.is('td.gl2e')) {
item.parent().addClass('noncompliant-item')
item.parent().hide()
} else {
item.removeClass('noncompliant-item')
item.hide()
}
}
this._onItemShow.push((item) => {
if (item.is('td.gl2e')) {
item.parent().removeClass('noncompliant-item')
item.parent().show()
} else {
item.removeClass('noncompliant-item')
item.show()
}
})
this._forPage('search', () => {
this._onItemShow.push((item) => this._handleGalleryHighlights(item))
})
}
/**
* @private
*/
_setupFeatures()
{
this._configurationManager.addColorField(UI_GALLERY_HIGHLIGHTS_COLOUR).
setTitle('Highlight Colour').
setHelpText('Colour to highlight the galleries with. Requires refresh to change.')
this._configurationManager.addFlagField(FILTER_WATCHED_FROM_SEARCH).
setTitle('Hide Watched Galleries').
setHelpText('Hides watched galleries from searches initiated other than the watched page.')
this._configurationManager.addFlagField(UI_OPEN_GALLERIES_AUTO_NEXT).
setTitle('Search Auto Next').
setHelpText('Automatically navigates to the next page after opening all galleries.')
this._configurationManager.addFlagField(UI_OPEN_GALLERY_PAGES_AUTO_NEXT).
setTitle('Gallery Auto Next').
setHelpText('Automatically navigates to the next page after opening all images.')
this._configurationManager.addFlagField(UI_OPEN_GALLERY_PAGES_DOWNLOAD).
setTitle('Download The Image').
setHelpText('Download image on a gallery page to a folder named after the gallery.')
this._configurationManager.addFlagField(UI_OPEN_GALLERY_PAGES_THROTTLING).
setTitle('Throttling Bypass').
setHelpText('Paces the page opening logic to not trigger the site\'s throttling mechanism which limits resolution to 1280x.')
this._configurationManager.addFlagField(UI_DEFAULTS_PAGE_RANGE_ENABLE).
setTitle('Enable Page Range Filter').
setHelpText('Always set these page limits in searches. Ignored if you set your own values on the page.')
this._configurationManager.addFlagField(UI_DEFAULTS_RATING_ENABLE).
setTitle('Enable Rating Filter').
setHelpText('Enable default rating filter in searches')
this._configurationManager.addFlagField(UI_DEFAULTS_TAGS_ENABLE).
setTitle('Enable Default Tags').
setHelpText('Enable default tags in searches.')
this._configurationManager.addFlagField(UI_EMBED_TORRENTS).
setTitle('Embed Torrent Downloads').
setHelpText('Embed torrent downloads in gallery pages.')
this._configurationManager.addFlagField(UI_VISITED_HIGHLIGHT).
setTitle('Highlight Visited').
setHelpText('Colours the visited gallery links black, to make them distinct.')
this._configurationManager.addRadiosGroup(FILTER_RATED_VIDEOS, [
['Disabled', 'Disabled'],
['Red', 'Red'],
['Blue', 'Blue'],
['Green', 'Green'],
['All', 'All'],
]).
setTitle('Hide Rated Galleries').
setHelpText('Hides galleries rated by you with the colour set in site settings or all.')
this._configurationManager.addRadiosGroup(UI_DEFAULTS_RATING, [
['2 stars', '2'],
['3 stars', '3'],
['4 stars', '4'],
['5 stars', '5'],
]).
setTitle('Rating').
setHelpText('Always set this rating filter in searches. Ignored if you set your own value on the page.')
this._configurationManager.addRadiosGroup(UI_GALLERY_HIGHLIGHTS, [
['Disabled', 'Disabled'],
['All Favourite Tags', 'All'],
['Only Group / Artist Tags', 'Source'],
]).
setTitle('Gallery Highlights').
setHelpText('Highlights favourite galleries in search results with at least one matching tag.')
this._configurationManager.addRangeField(UI_DEFAULTS_PAGE_RANGE, 0, 2000).
setTitle('Page Range').
setHelpText('Enable default page range filter in searches.')
this._configurationManager.addRulesetField(UI_DEFAULTS_TAGS).
setTitle('Tags').
setRows(4).
setHelpText('Always add the following tags in search. Can be overridden with at least one tag present.')
this._itemAttributesResolver.
addAttribute(ITEM_WATCHED, (item) => item.find('.gt[style],.gtl[style]').attr('style')?.startsWith('color:#f1f1f1') ?? false).
addAttribute(ITEM_RATED_BLUE, (item) => item.find('.irb').length > 0).
addAttribute(ITEM_RATED_GREEN, (item) => item.find('.irg').length > 0).
addAttribute(ITEM_RATED_RED, (item) => item.find('.irr').length > 0)
}
_setupFilters()
{
// Gallery `#taglist` highlights are painted in `_setupEvents` after page detection
// (`isPage('gallery')` is false during the constructor).
this._addItemComplexComplianceFilter(
FILTER_RATED_VIDEOS,
(option) => option !== 'Disabled',
(item, option) => this._handleRatedGalleries(item, option))
this._addItemComplexComplianceFilter(
FILTER_WATCHED_FROM_SEARCH,
(enabled) => !this.isPage('gallery') && !this.isPage('watched') && enabled,
(item) => !this._get(item, ITEM_WATCHED))
this._addItemTagHighlights({
configKey: UI_TAG_HIGHLIGHTS_FAVOURITE,
styleClass: 'favourite-tag',
rows: 10,
helpText: 'Specify favourite tags to highlight.',
})
this._configurationManager.getField(UI_TAG_HIGHLIGHTS_FAVOURITE)?.setTitle('Favourite Tags')
this._addItemTagHighlights({
configKey: UI_TAG_HIGHLIGHTS_DISLIKED,
styleClass: 'disliked-tag',
rows: 10,
helpText: 'Specify disliked tags to highlight.',
})
this._configurationManager.getField(UI_TAG_HIGHLIGHTS_DISLIKED)?.setTitle('Disliked Tags')
this._addItemTagBlacklistFilter(ITEM_TAGS, false, 20)
this._addItemBlacklistFilter('Hide galleries with specified phrases in their names.', 10)
this._handleUnderageFilter()
}
/**
* @private
*/
_setupUI()
{
let pageSpecificButton, statistics = []
if (this.isPage('gallery')) {
pageSpecificButton = this._uiGen.createFormButton(
'Open Gallery Images',
'Opens all images on current page of this gallery.',
() => this._handleOpenGalleryImages())
} else if (this.isPage('image')) {
pageSpecificButton = this._uiGen.createFormButton(
'Download Image',
'Downloads the image on the page.',
() => this._handleDownloadMedia(false))
} else {
pageSpecificButton = this._uiGen.createFormButton(
'Open All Galleries',
'Opens all galleries on current page.',
() => this._handleOpenGalleries())
statistics = [
this._uiGen.createStatisticsFormGroup(FILTER_TEXT_BLACKLIST, 'Text Blacklist'),
this._uiGen.createStatisticsFormGroup(FILTER_TAG_BLACKLIST, 'Tag Blacklist'),
this._uiGen.createStatisticsFormGroup(FILTER_RATED_VIDEOS, 'Hide Rated Galleries'),
this._uiGen.createStatisticsFormGroup(UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER, 'Underage Characters'),
this.isPage('watched') ? '' : this._uiGen.createStatisticsFormGroup(FILTER_WATCHED_FROM_SEARCH, 'Hide Watched Galleries'),
]
}
this._userInterface = [
this._uiGen.createTabsSection(['Filters', 'Filters 2', 'Underage Filter', 'Galleries', 'Tag Highlights', 'Search Defaults', 'Extras'], [
this._uiGen.createTabPanel('Filters', true).append([
this._configurationManager.createElement(FILTER_WATCHED_FROM_SEARCH),
this._configurationManager.createElement(OPTION_ENABLE_TAG_BLACKLIST),
this._configurationManager.createElement(FILTER_TAG_BLACKLIST),
this._configurationManager.createElement(OPTION_DISABLE_COMPLIANCE_VALIDATION),
]),
this._uiGen.createTabPanel('Filters 2').append([
this._configurationManager.createElement(FILTER_RATED_VIDEOS),
this._uiGen.createSeparator(),
this._configurationManager.createElement(OPTION_ENABLE_TEXT_BLACKLIST),
this._configurationManager.createElement(FILTER_TEXT_BLACKLIST),
]),
this._uiGen.createTabPanel('Galleries').append([
this._uiGen.createTitle('Open Galleries'),
this._configurationManager.createElement(UI_OPEN_GALLERIES_AUTO_NEXT),
this._uiGen.createSeparator(),
this._uiGen.createTitle('Open Images'),
this._configurationManager.createElement(UI_OPEN_GALLERY_PAGES_AUTO_NEXT),
this._configurationManager.createElement(UI_OPEN_GALLERY_PAGES_THROTTLING),
this._uiGen.createSeparator(),
this._uiGen.createTitle('Image Pages'),
this._configurationManager.createElement(UI_OPEN_GALLERY_PAGES_DOWNLOAD),
this._uiGen.createSeparator(),
this._configurationManager.createElement(UI_GALLERY_HIGHLIGHTS),
this._uiGen.createBreakSeparator(),
this._uiGen.createBreakSeparator(),
this._configurationManager.createElement(UI_GALLERY_HIGHLIGHTS_COLOUR),
]),
this._uiGen.createTabPanel('Tag Highlights').append([
this._configurationManager.createElement(UI_TAG_HIGHLIGHTS_FAVOURITE),
this._configurationManager.createElement(UI_TAG_HIGHLIGHTS_DISLIKED),
]),
this._uiGen.createTabPanel('Search Defaults').append([
this._configurationManager.createElement(UI_DEFAULTS_PAGE_RANGE_ENABLE),
this._configurationManager.createElement(UI_DEFAULTS_PAGE_RANGE),
this._uiGen.createSeparator(),
this._configurationManager.createElement(UI_DEFAULTS_RATING),
this._uiGen.createBreakSeparator(),
this._configurationManager.createElement(UI_DEFAULTS_RATING_ENABLE),
this._uiGen.createSeparator(),
this._configurationManager.createElement(UI_DEFAULTS_TAGS_ENABLE),
this._configurationManager.createElement(UI_DEFAULTS_TAGS),
]),
this._uiGen.createTabPanel('Underage Filter').append([
this._configurationManager.createElement(FILTER_UNDERAGE_CHARACTERS_OPTION),
this._configurationManager.createElement(UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER),
this._configurationManager.createElement(UI_TAG_HIGHLIGHTS_UNKNOWN_AGE_CHARACTER),
this._configurationManager.createElement(UI_TAG_HIGHLIGHTS_ADULT_CHARACTER),
]),
this._uiGen.createTabPanel('Extras').append([
this._configurationManager.createElement(UI_EMBED_TORRENTS),
this._configurationManager.createElement(UI_VISITED_HIGHLIGHT),
this._configurationManager.createElement(OPTION_ALWAYS_SHOW_SETTINGS_PANE),
this._uiGen.createSeparator(),
this._createSettingsBackupRestoreFormActions(),
]),
]),
this._uiGen.createBottomSection([
...statistics,
this._uiGen.createSeparator(),
pageSpecificButton,
this._uiGen.createSeparator(),
this._createSettingsFormActions(),
]),
]
}
}
void (new EHentaiSearchAndUITweaks).init()