This userscript adds useful tools to indexxx.com
Installation requires a browser extension such as Tampermonkey or Greasemonkey.
This userscript adds useful tools to indexxx.com
Installation requires a browser extension such as Tampermonkey or Greasemonkey.
| // ==UserScript== | |
| // @name indexxx | |
| // @author peolic | |
| // @version 3.15 | |
| // @description Adds useful information and tools to indexxx pages | |
| // @icon https://www.indexxx.com/apple-touch-icon.png | |
| // @namespace https://github.com/peolic | |
| // @match https://www.indexxx.com/* | |
| // @grant none | |
| // @homepageURL https://gist.github.com/peolic/6aa2cef8fafa377cb5848a473c0e3b30 | |
| // @downloadURL https://gist.github.com/peolic/6aa2cef8fafa377cb5848a473c0e3b30/raw/indexxx.user.js | |
| // @updateURL https://gist.github.com/peolic/6aa2cef8fafa377cb5848a473c0e3b30/raw/indexxx.user.js | |
| // ==/UserScript== | |
| //@ts-check | |
| /** @type {{ name: string; url: string; } | null} */ | |
| let currentModel = null; | |
| function main() { | |
| // Model page | |
| if (/^\/m\/(.+)/.test(window.location.pathname)) { | |
| new ModelPage(); | |
| } else if (/^\/websites\/(.+)\/sets\//.test(window.location.pathname)) { | |
| new WebsiteSetsPage(); | |
| } | |
| } | |
| const NO_ALIAS = '[no alias]'; | |
| class ModelPage { | |
| constructor() { | |
| currentModel = { | |
| name: /** @type {HTMLHeadingElement} */ (document.querySelector('h1#model-name')).innerText, | |
| url: /** @type {HTMLLinkElement} */ (document.querySelector('link[rel="canonical"]')).href, | |
| }; | |
| this.websitesBox = /** @type {HTMLDivElement} */ (document.querySelector('#model-websites-box')); | |
| this.modelHeader = /** @type {HTMLDivElement} */ (document.querySelector('#model-header')); | |
| this.portfolioHeader = /** @type {HTMLHeadingElement} */ (this.modelHeader.querySelector(':scope > .block1 > h2')); | |
| /** @type {IndexxxSet[]} */ | |
| this.allSets = | |
| /** @type {HTMLDivElement[]} */ | |
| (Array.from(document.querySelectorAll('.pset.card'))).map(parseSetCard); | |
| this.uniqueSets = this.allSets | |
| .filter((set, i, self) => { | |
| const { outUrl, display } = set.site; | |
| const alias = set.models[0].name; | |
| return i === self.findIndex((other) => | |
| (outUrl ? outUrl === other.site.outUrl : display === other.site.display) | |
| && alias === other.models[0].name | |
| ); | |
| }); | |
| this.aliases = this.aliasesUsageCount(); | |
| const boxAliases = this.aliasUsageCountBox(); | |
| const boxSites = this.sitesForAliasBox(); | |
| this.websitesBox.after(boxAliases, boxSites); | |
| tryResizable(boxAliases, { handles: 'e', minWidth: 130 }); | |
| tryResizable(boxSites, { handles: 'e', minWidth: 204 }); | |
| this.setUpToggle(); | |
| this.exportTarget = this.setUpExport(); | |
| } | |
| style = { | |
| box: { | |
| borderColor: '#006ccc', | |
| backgroundColor: '#8bbeff', | |
| width: '204px', | |
| }, | |
| title: { | |
| backgroundColor: '#006ccc', | |
| }, | |
| body: { | |
| backgroundColor: '#e5eff9', | |
| wordBreak: 'break-word', | |
| padding: '0.1rem', | |
| }, | |
| csv: { | |
| userSelect: 'all', | |
| overflow: 'hidden', | |
| textOverflow: 'ellipsis', | |
| whiteSpace: 'nowrap', | |
| fontSize: '10px', | |
| }, | |
| } | |
| // Alias Usage Count | |
| aliasUsageCountBox() { | |
| const box = document.createElement('div'); | |
| box.classList.add('model-snippet-box'); | |
| setStyles(box, this.style.box); | |
| const boxTitle = document.createElement('div'); | |
| boxTitle.classList.add('box-title', 'd-flex', 'justify-content-between', 'px-1'); | |
| boxTitle.innerText = 'Alias Usage Count'; | |
| setStyles(boxTitle, this.style.title); | |
| box.appendChild(boxTitle); | |
| const titleAliasCount = document.createElement('span'); | |
| boxTitle.appendChild(titleAliasCount); | |
| const boxBody = document.createElement('div'); | |
| boxBody.classList.add('box-body'); | |
| setStyles(boxBody, this.style.body); | |
| this.aliases | |
| .sort(({ count: a }, { count: b }) => { | |
| if (a < b) return 1; | |
| if (a > b) return -1; | |
| return 0; | |
| }) | |
| .forEach(({ alias, count, listed }) => { | |
| const div = document.createElement('div'); | |
| const searchSpan = document.createElement('span'); | |
| setStyles(searchSpan, { marginRight: '.25rem', cursor: 'pointer', userSelect: 'none' }); | |
| searchSpan.innerText = '🔎'; | |
| searchSpan.addEventListener('click', () => { | |
| const input = /** @type {HTMLInputElement} */ (document.querySelector('input#aliasSitesInput')); | |
| input.value = alias; | |
| input.dispatchEvent(new Event('input')); | |
| }); | |
| const aliasSpan = document.createElement('span'); | |
| setStyles(aliasSpan, { | |
| fontWeight: alias === NO_ALIAS ? 'bold' : undefined, | |
| userSelect: 'all', | |
| }); | |
| if (!listed) { | |
| aliasSpan.classList.add('text-danger'); | |
| aliasSpan.title = `"${alias}" is not listed as an alias.`; | |
| } | |
| aliasSpan.innerText = alias; | |
| div.append(searchSpan, aliasSpan, ` = ${count >= 0 ? count : '?'}`); | |
| boxBody.appendChild(div); | |
| }); | |
| box.appendChild(boxBody); | |
| const csv = document.createElement('div'); | |
| setStyles(csv, this.style.csv); | |
| const validAliases = this.aliases | |
| .reduce( | |
| (r, { alias }) => alias !== NO_ALIAS ? r.concat(alias) : r, | |
| /** @type {string[]} */ ([]) | |
| ); | |
| csv.innerText = validAliases.sort().join(', '); | |
| titleAliasCount.innerText = `${validAliases.length}`; | |
| box.appendChild(csv); | |
| return box; | |
| } | |
| // Sites For Alias | |
| sitesForAliasBox() { | |
| const box = document.createElement('div'); | |
| box.classList.add('model-snippet-box'); | |
| setStyles(box, this.style.box); | |
| const boxTitle = document.createElement('div'); | |
| boxTitle.classList.add('box-title', 'd-flex', 'justify-content-between', 'px-1'); | |
| setStyles(boxTitle, this.style.title); | |
| box.appendChild(boxTitle); | |
| const boxDefaultTitle = 'Websites By Alias'; | |
| const boxTitleNode = document.createTextNode(boxDefaultTitle); | |
| boxTitle.appendChild(boxTitleNode); | |
| const titleSetCount = document.createElement('span'); | |
| boxTitle.appendChild(titleSetCount); | |
| const boxBody = document.createElement('div'); | |
| boxBody.classList.add('box-body'); | |
| setStyles(boxBody, this.style.body); | |
| box.appendChild(boxBody); | |
| const results = document.createElement('div'); | |
| results.innerText = 'Results will show up here'; | |
| boxBody.appendChild(results); | |
| const filterDiv = document.createElement('div'); | |
| filterDiv.classList.add('d-flex'); | |
| boxBody.prepend(filterDiv); | |
| const input = document.createElement('input'); | |
| input.id = 'aliasSitesInput'; | |
| input.type = 'text'; | |
| input.setAttribute('placeholder', 'Click 🔎 or enter an alias...'); | |
| input.setAttribute('list', 'modelAliases'); | |
| setStyles(input, { flex: 'auto', marginBottom: '0.2rem' }); | |
| filterDiv.append(input); | |
| const clearWrapper = document.createElement('span'); | |
| clearWrapper.style.position = 'relative'; | |
| const clear = document.createElement('span'); | |
| setStyles(clear, { | |
| position: 'absolute', | |
| top: '-0.22em', | |
| right: '.1em', | |
| padding: '0 .1em', | |
| color: this.style.title.backgroundColor, | |
| fontWeight: '800', | |
| fontFamily: 'sans-serif', | |
| fontSize: '1.2rem', | |
| cursor: 'pointer', | |
| userSelect: 'none', | |
| }) | |
| clear.title = 'Clear'; | |
| clear.innerText = 'x'; | |
| clear.addEventListener('click', () => { | |
| input.value = ''; | |
| input.dispatchEvent(new Event('input')); | |
| }); | |
| clearWrapper.appendChild(clear); | |
| filterDiv.append(clearWrapper); | |
| const datalist = document.createElement('datalist'); | |
| datalist.id = 'modelAliases'; | |
| for (const alias of this.aliases.map(({ alias }) => alias).sort()) { | |
| const option = document.createElement('option'); | |
| option.innerText = alias; | |
| datalist.appendChild(option); | |
| } | |
| input.after(datalist); | |
| const csv = document.createElement('div'); | |
| setStyles(csv, this.style.csv); | |
| box.appendChild(csv); | |
| const handleInput = () => { | |
| const value = input.value.trim(); | |
| filterAll.disabled = !value; | |
| filterAll.checked = false; | |
| restoreAllSets(); | |
| let sites = this.sitesForName(value, true) | |
| .sort((a, b) => (a.name || a.display).localeCompare((b.name || b.display), undefined, { sensitivity: 'accent' })); | |
| if (!value) | |
| sites = sites.filter(({ setCount }) => setCount > 0); | |
| if (sites.length === 0) { | |
| boxTitleNode.textContent = boxDefaultTitle; | |
| titleSetCount.innerText = ''; | |
| csv.innerText = ''; | |
| if (value) { | |
| results.innerText = 'No results'; | |
| filterAll.disabled = true; | |
| filterAll.checked = false; | |
| } else { | |
| results.innerText = 'Results will show up here'; | |
| } | |
| return; | |
| } | |
| const totalSetCount = sites.reduce((sum, { setCount }) => sum + setCount, 0); | |
| // Disable if there are no sets | |
| filterAll.disabled = totalSetCount === 0; | |
| boxTitleNode.textContent = value ? boxDefaultTitle : 'Sets By Website'; | |
| results.innerText = ''; | |
| results.append(...sites.map(makeSiteDiv)); | |
| csv.innerText = sites | |
| .map((s) => s.name || s.display) | |
| .filter((s, i, self) => self.indexOf(s) === i) | |
| .join(','); | |
| titleSetCount.innerText = `${totalSetCount} sets`; | |
| }; | |
| input.addEventListener('input', handleInput); | |
| const filterAll = document.createElement('input'); | |
| filterAll.type = 'checkbox'; | |
| filterAll.checked = false; | |
| filterAll.disabled = true; | |
| filterAll.title = 'Select/unselect all sites below'; | |
| setStyles(filterAll, { margin: '0 2px 2px 0' }); | |
| filterDiv.prepend(filterAll); | |
| filterAll.addEventListener('input', () => { | |
| const checkboxes = getFilterCheckboxes(); | |
| checkboxes.forEach((cb) => { | |
| cb.checked = filterAll.checked; | |
| }); | |
| filterSets(); | |
| }); | |
| /** @param {SiteFilter} site */ | |
| const makeSiteDiv = (site) => { | |
| const { name, display, listedCount, setCount } = site; | |
| const div = document.createElement('div'); | |
| const filter = document.createElement('input'); | |
| filter.type = 'checkbox'; | |
| filter.name = 'siteSetFilter'; | |
| filter.value = JSON.stringify(site); | |
| filter.disabled = setCount === 0; | |
| filter.title = 'Filter sets by this sites'; | |
| setStyles(filter, { marginRight: '.25rem' }); | |
| filter.addEventListener('input', () => { | |
| const checkboxes = getFilterCheckboxes(); | |
| filterAll.checked = checkboxes.every((cb) => cb.checked); | |
| filterSets(); | |
| }); | |
| const siteSpan = document.createElement('abbr'); | |
| setStyles(siteSpan, { userSelect: 'all', wordBreak: 'break-all' }); | |
| siteSpan.innerText = name || display; | |
| if (name && name !== display) | |
| siteSpan.title = display; | |
| const countEl = document.createElement('span'); | |
| countEl.innerText = ` (${setCount || listedCount})`; | |
| const currentFilter = input.value.trim(); | |
| if (currentFilter && listedCount && setCount === 0) { | |
| countEl.classList.add('text-danger'); | |
| countEl.title = `No sets as "${currentFilter}" found.`; | |
| } | |
| div.append(filter, siteSpan, countEl); | |
| return div; | |
| }; | |
| /** @type {IndexxxSet["el"][]} */ | |
| const filteredSets = []; | |
| const restoreAllSets = () => { | |
| filteredSets.forEach((el) => { | |
| el.classList.toggle('d-none', false); | |
| }); | |
| filteredSets.length = 0; | |
| } | |
| const getFilterCheckboxes = () => | |
| /** @type {HTMLInputElement[]} */ | |
| (Array.from(document.querySelectorAll('input[name="siteSetFilter"]:not(:disabled)'))) | |
| const filterSets = () => { | |
| const checkboxes = getFilterCheckboxes(); | |
| /** @type {string[]} */ | |
| const selected = []; | |
| for (const cb of checkboxes) { | |
| if (cb.checked) { | |
| /** @type {SiteFilter} */ | |
| const site = JSON.parse(cb.value); | |
| selected.push(site.display); | |
| } | |
| } | |
| // console.debug('selected sites', selected); | |
| this.allSets.forEach((set) => { | |
| const isSelected = selected.length === 0 || selected.includes(set.site.display); | |
| const currentFilter = input.value.trim(); | |
| const isMatch = isSelected && (!currentFilter || set.models[0].name === currentFilter); | |
| set.el.classList.toggle('d-none', !isMatch); | |
| if (!isMatch) return filteredSets.push(set.el); | |
| const index = filteredSets.indexOf(set.el); | |
| if (index !== -1) filteredSets.splice(index, 1); | |
| }); | |
| }; | |
| // initial state | |
| handleInput(); | |
| return box; | |
| } | |
| _createActions() { | |
| const modelTitleSection = /** @type {HTMLDivElement */ (document.querySelector('#modelTitleSection')); | |
| const atid = 'userscript-actions-top'; | |
| /** @type {HTMLDivElement | null} */ | |
| let actionsTop = modelTitleSection.querySelector(`#${atid}`); | |
| if (!actionsTop) { | |
| actionsTop = document.createElement('div'); | |
| actionsTop.id = atid; | |
| setStyles(actionsTop, { fontSize: '2em', width: '3.3em', padding: '0 0.25rem' }); | |
| actionsTop.classList.add('d-flex', 'justify-content-between'); | |
| modelTitleSection.appendChild(actionsTop); | |
| } | |
| const abid = 'userscript-actions-bottom'; | |
| /** @type {HTMLSpanElement | null} */ | |
| let actionsBottom = this.portfolioHeader?.querySelector(`#${abid}`); | |
| if (this.portfolioHeader && !actionsBottom) { | |
| actionsBottom = document.createElement('span'); | |
| actionsBottom.id = abid; | |
| actionsBottom.classList.add('ml-2'); | |
| this.portfolioHeader.appendChild(actionsBottom); | |
| } | |
| return { | |
| titleActions: actionsTop, | |
| portfolioActions: actionsBottom, | |
| }; | |
| } | |
| setUpToggle() { | |
| const { titleActions } = this._createActions(); | |
| const toggleButton = document.createElement('div'); | |
| setStyles(toggleButton, { cursor: 'pointer' }); | |
| toggleButton.title = 'Toggle all info boxes'; | |
| toggleButton.innerText = '🔘'; | |
| titleActions.append(toggleButton); | |
| toggleButton.addEventListener('click', () => { | |
| const anyHidden = | |
| /** @type {HTMLDivElement[]} */ | |
| (Array.from(this.modelHeader.querySelectorAll('.box-title + .box-body'))) | |
| .some((el) => el.style.display === 'none'); | |
| /** @type {HTMLDivElement[]} */ | |
| (Array.from(this.modelHeader.querySelectorAll('.box-title + .box-body'))) | |
| .forEach((el) => el.style.display = anyHidden ? '' : 'none'); | |
| const twitter = /** @type {HTMLDivElement} */ (this.modelHeader.querySelector(':scope > .twitter')); | |
| if (twitter) | |
| twitter.classList.toggle('d-none', !anyHidden); | |
| const modelImage = /** @type {HTMLImageElement} */ (this.modelHeader.querySelector('img.model-img')); | |
| if (modelImage.dataset.maxHeight) { | |
| modelImage.style.maxHeight = modelImage.dataset.maxHeight; | |
| modelImage.dataset.maxHeight = ''; | |
| } else { | |
| modelImage.dataset.maxHeight = modelImage.style.maxHeight; | |
| modelImage.style.maxHeight = '350px'; | |
| } | |
| }); | |
| } | |
| /** @type {Map<string, (set: IndexxxSet) => HTMLTableCellElement>} */ | |
| ExportFields = new Map([ | |
| ['URL', (set) => { | |
| const cell = document.createElement('td'); | |
| cell.style.maxWidth = '500px'; | |
| const a = document.createElement('a'); | |
| a.href = a.innerText = set.url; | |
| a.target = '_blank'; | |
| cell.appendChild(a); | |
| return cell; | |
| }], | |
| ['Site', (set) => { | |
| const cell = document.createElement('td'); | |
| cell.innerText = set.site.name || set.site.display; | |
| return cell; | |
| }], | |
| ['Date', (set) => { | |
| const cell = document.createElement('td'); | |
| cell.innerText = set.date; | |
| return cell; | |
| }], | |
| ['Title', (set) => { | |
| const cell = document.createElement('td'); | |
| cell.style.maxWidth = '400px'; | |
| cell.innerText = set.title; | |
| return cell; | |
| }], | |
| ['Models', (set) => { | |
| const cell = document.createElement('td'); | |
| cell.style.maxWidth = '400px'; | |
| set.models.forEach((model, idx) => { | |
| if (idx > 0) cell.append(' | '); | |
| const a = document.createElement('a'); | |
| a.classList.add('d-inline-block'); | |
| a.innerText = | |
| model.actualName && model.actualName !== model.name | |
| ? `${model.name} (${model.actualName})` | |
| : (model.actualName || model.name); | |
| a.href = model.url; | |
| cell.append(a); | |
| }); | |
| return cell; | |
| }], | |
| ]); | |
| exportColumnOrder = Array.from(this.ExportFields.keys()); | |
| setUpExport() { | |
| const { portfolioActions } = this._createActions(); | |
| if (!portfolioActions) | |
| return; | |
| const exportButton = document.createElement('span'); | |
| setStyles(exportButton, { cursor: 'pointer' }); | |
| exportButton.title = 'Export visible sets'; | |
| exportButton.innerText = '📤'; | |
| portfolioActions.append(exportButton); | |
| const exportTarget = document.createElement('div'); | |
| exportTarget.id = 'export-target'; | |
| this.portfolioHeader.after(exportTarget); | |
| exportButton.addEventListener('click', () => this.onExport()); | |
| return exportTarget; | |
| } | |
| onExport() { | |
| const table = document.createElement('table'); | |
| table.classList.add('table', 'table-sm', 'table-bordered', 'table-striped', 'w-auto'); | |
| const thead = document.createElement('thead'); | |
| const tbody = document.createElement('tbody'); | |
| table.append(thead, tbody); | |
| const theadrow = document.createElement('tr'); | |
| theadrow.classList.add('table-primary'); | |
| this.exportColumnOrder.forEach((key, column) => { | |
| const cell = document.createElement('th'); | |
| cell.innerText = key; | |
| cell.style.cursor = 'pointer'; | |
| cell.title = 'Hide values in column'; | |
| cell.addEventListener('click', () => { | |
| for (const row of tbody.rows) { | |
| row.cells[column].classList.toggle('invisible'); | |
| } | |
| }); | |
| theadrow.appendChild(cell); | |
| }); | |
| thead.append(theadrow); | |
| const visibleSets = this.allSets.filter(({ el }) => !el.classList.contains('d-none')); | |
| const rows = visibleSets.map((set) => { | |
| const row = document.createElement('tr'); | |
| for (const fieldFunc of this.ExportFields.values()) { | |
| row.appendChild(fieldFunc(set)); | |
| } | |
| return row; | |
| }); | |
| tbody.append(...rows); | |
| this.exportTarget.innerHTML = ''; | |
| this.exportTarget.append(table); | |
| const { portfolioActions } = this._createActions(); | |
| const bodyRect = document.body.getBoundingClientRect(); | |
| const targetRect = this.exportTarget.getBoundingClientRect(); | |
| window.scrollTo({ | |
| behavior: 'smooth', | |
| top: targetRect.top - bodyRect.top - 50, | |
| left: 0, | |
| }); | |
| /** @type {HTMLSpanElement | null} */ | |
| let buttons = this.portfolioHeader.querySelector('#userscript-export-buttons'); | |
| if (!buttons) { | |
| buttons = document.createElement('span'); | |
| buttons.id = 'userscript-export-buttons'; | |
| buttons.classList.add('ml-2'); | |
| portfolioActions.append(buttons); | |
| } | |
| const scrollUp = () => | |
| window.scrollTo({ behavior: 'smooth', top: bodyRect.top, left: 0 }); | |
| const titleUp = 'Go up'; | |
| if (!buttons.querySelector(`[title="${titleUp}"]`)) { | |
| const up = document.createElement('span'); | |
| setStyles(up, { cursor: 'pointer' }); | |
| up.title = titleUp; | |
| up.innerText = '🔝'; | |
| up.addEventListener('click', scrollUp); | |
| buttons.append(up); | |
| } | |
| const titleClose = 'Close'; | |
| if (!buttons.querySelector(`[title="${titleClose}"]`)) { | |
| const close = document.createElement('span'); | |
| setStyles(close, { cursor: 'pointer' }); | |
| close.title = titleClose; | |
| close.innerText = '❌'; | |
| close.addEventListener('click', () => { | |
| this.exportTarget.innerHTML = ''; | |
| close.remove(); | |
| }); | |
| buttons.append(close); | |
| } | |
| } | |
| /** | |
| * Number of uses for each alias (sets or listed) | |
| */ | |
| aliasesUsageCount() { | |
| /** @type {string[]} */ | |
| const sites = []; | |
| /** @type {{ alias: string, count: number, listed: boolean }[]} */ | |
| const results = []; | |
| /** @type {HTMLSpanElement[]} */ | |
| const aliasSpans = (Array.from(this.websitesBox.querySelectorAll('li span.alias'))); | |
| aliasSpans.forEach((aliasSpan) => { | |
| let name = aliasSpan.innerText.trim(); | |
| if (!name) name = NO_ALIAS; | |
| const siteEl = closestWebsiteLink(aliasSpan); | |
| if (!siteEl) { | |
| console.error('error getting site for', aliasSpan); | |
| return; | |
| } | |
| const site = siteEl.innerText; | |
| if (site) { | |
| const key = [site, name].join('|'); | |
| if (sites.includes(key)) { | |
| console.debug(`skipping duplicate site/alias: ${key}`); | |
| return; | |
| } | |
| sites.push(key); | |
| } | |
| const { length: setCount } = this.setsBySite(siteEl, aliasSpan.innerText); | |
| const listedCount = this.getAliasListedCount(aliasSpan); | |
| if (listedCount === null && !setCount) return; | |
| const minSetCount = setCount || listedCount || 1; | |
| const result = results.find(({ alias }) => alias === name); | |
| if (!result) results.push({ alias: name, count: minSetCount, listed: true }); | |
| else result.count += minSetCount; | |
| }); | |
| this.uniqueSets | |
| .forEach((set) => { | |
| const { name } = set.models[0]; | |
| if (results.find(({ alias }) => alias === name) === undefined) { | |
| const { display: siteDisplay, el: siteEl } = set.site; | |
| const key = [siteDisplay, name].join('|'); | |
| if (sites.includes(key)) { | |
| // console.debug(`skipping duplicate site/alias: ${key}`); | |
| return; | |
| } | |
| sites.push(key); | |
| const { length: count } = this.setsBySite(siteEl, name); | |
| results.push({ alias: name, count, listed: false }); | |
| } | |
| }); | |
| return results; | |
| } | |
| /** | |
| * @typedef SiteFilter | |
| * @property {string | null} name | |
| * @property {string} display | |
| * @property {number} listedCount | |
| * @property {number} setCount | |
| */ | |
| /** | |
| * List of sites for alias | |
| * @param {string} [name] | |
| * @param {boolean} [exact=false] | |
| * @returns {SiteFilter[]} | |
| */ | |
| sitesForName(name, exact=false) { | |
| /** @type {SiteFilter[]} */ | |
| const sites = []; | |
| /** | |
| * @param {SiteFilter} other | |
| */ | |
| const findExistingSite = (other) => | |
| sites.find((site) => site.name == other.name && site.display == other.display); | |
| /** | |
| * @param {string} name | |
| * @param {string} display | |
| * @param {number} count | |
| * @param {boolean} fromList | |
| */ | |
| const push = (name, display, count, fromList) => { | |
| const countTarget = (c = fromList) => c ? 'listedCount' : 'setCount'; | |
| const final = /** @type {SiteFilter} */ ({ | |
| name, | |
| display, | |
| [countTarget()]: count, | |
| [countTarget(!fromList)]: 0, | |
| }); | |
| const existingSite = findExistingSite(final); | |
| if (existingSite) | |
| existingSite[countTarget()] += count; | |
| else | |
| sites.push(final); | |
| }; | |
| /** | |
| * @param {string} alias | |
| */ | |
| const shouldHandle = (alias) => ( | |
| !name | |
| || (name === NO_ALIAS && !alias) | |
| || alias.localeCompare(name, undefined, { sensitivity: exact ? 'variant' : 'accent' }) === 0 | |
| ); | |
| /** @type {HTMLSpanElement[]} */ | |
| (Array.from(this.websitesBox.querySelectorAll('li span.alias'))) | |
| .forEach((aliasSpan) => { | |
| const alias = aliasSpan.innerText.trim(); | |
| if (shouldHandle(alias)) { | |
| const siteEl = closestWebsiteLink(aliasSpan); | |
| if (!siteEl) { | |
| console.error('error getting site for', aliasSpan); | |
| sites.push({ name: null, display: '???', listedCount: 0, setCount: 0 }); | |
| return; | |
| } | |
| const siteSets = this.setsBySite(siteEl); | |
| const siteName = siteSets[0]?.site.name || ''; | |
| const siteDisplay = siteEl.innerText; | |
| const hasSetsAsAlias = !!siteSets.find((set) => set.models[0].name === alias); | |
| if (!hasSetsAsAlias) { | |
| const listedCount = (name ? this.getAliasListedCount(aliasSpan) : 0); | |
| if (listedCount !== null) | |
| push(siteName, siteDisplay, listedCount, true); | |
| } | |
| } | |
| }); | |
| this.uniqueSets | |
| .forEach((set) => { | |
| const alias = set.models[0].name; | |
| if (shouldHandle(alias)) { | |
| const { name: siteName, display: siteDisplay, el: siteEl } = set.site; | |
| const { length: setCount } = this.setsBySite(siteEl, alias); | |
| push(siteName, siteDisplay, setCount, false); | |
| } | |
| }); | |
| return sites; | |
| } | |
| /** | |
| * Get only the listed count of aliases. | |
| * @param {HTMLSpanElement} aliasSpan | |
| * @returns {number | null} | |
| */ | |
| getAliasListedCount(aliasSpan) { | |
| let nextSpan = /** @type {HTMLElement | undefined} */ (aliasSpan.nextElementSibling); | |
| if (!nextSpan || nextSpan.matches('a[title="edit"]')) { | |
| // get parent site (multiple names for one site, set count on site row) | |
| const site = closestWebsiteLink(aliasSpan); | |
| if (!site) { | |
| console.error('error getting site for', aliasSpan); | |
| return 0; | |
| } | |
| const siteParent = /** @type {HTMLElement} */ (site.parentElement); | |
| nextSpan = /** @type {HTMLSpanElement} */ (siteParent.querySelector(':scope > span > span')); | |
| if (!nextSpan) | |
| return 0; | |
| } | |
| if (nextSpan.classList.contains('count')) { | |
| return Number(nextSpan.innerText.trim()) || 0; | |
| } | |
| if (nextSpan.classList.contains('descr')) { | |
| const text = nextSpan.innerText.trim(); | |
| if (/\b(delete|remove)\b/i.test(text)) | |
| return null; | |
| const match = text.match(/^(\d+) *;.+/); | |
| if (match) | |
| return Number(match[0]); | |
| } | |
| return 0; | |
| } | |
| /** | |
| * @param {HTMLAnchorElement | HTMLSpanElement} siteEl | |
| * @param {string} [alias] | |
| * @returns {IndexxxSet[]} | |
| */ | |
| setsBySite(siteEl, alias) { | |
| const site = siteEl.getAttribute('href') || siteEl.innerText; | |
| if (!site) | |
| return []; | |
| const sets = this.allSets.filter((set) => { | |
| if (alias && set.models[0].name !== alias) | |
| return false; | |
| if (/^https?:\/\//.test(site) || site.startsWith('/')) // outUrl | |
| return set.site.outUrl === site; | |
| else | |
| return set.site.display === site; | |
| }); | |
| return sets; | |
| } | |
| } // ModelPage | |
| class WebsiteSetsPage { | |
| constructor() { | |
| /** @type {IndexxxSet[]} */ | |
| this.allSets = | |
| /** @type {HTMLDivElement[]} */ | |
| (Array.from(document.querySelectorAll('.pset.card'))).map(parseSetCard); | |
| } | |
| } // WebsiteSetsPage | |
| /** | |
| * @param {HTMLElement} start | |
| * @returns {HTMLAnchorElement | null} | |
| */ | |
| function closestWebsiteLink(start) { | |
| const selector = ':scope > a.websiteLink, :scope > span.websiteLink'; | |
| let current = /** @type {HTMLElement} */ (start.parentElement).closest('li'); | |
| while (current && !current.querySelector(selector)) { | |
| current = /** @type {HTMLElement} */ (current.parentElement).closest('li'); | |
| } | |
| if (!current) return null; | |
| return current.querySelector(selector); | |
| } | |
| /** | |
| * @typedef IndexxxSetModel | |
| * @property {string} name | |
| * @property {string} [actualName] | |
| * @property {string} url | |
| * @property {HTMLAnchorElement | HTMLSpanElement | null} el | |
| */ | |
| /** | |
| * @typedef IndexxxSetSite | |
| * @property {string} name (title) | |
| * @property {string} display (innerText) | |
| * @property {string | null} outUrl (href) | |
| * @property {HTMLAnchorElement | HTMLSpanElement} el | |
| */ | |
| /** | |
| * @typedef IndexxxSet | |
| * @property {string} title | |
| * @property {string} date | |
| * @property {string} url | |
| * @property {[self: IndexxxSetModel, ...other: IndexxxSetModel[]]} models | |
| * @property {IndexxxSetSite} site | |
| * @property {HTMLDivElement} el | |
| */ | |
| /** | |
| * @param {HTMLDivElement} setCard | |
| * @returns {IndexxxSet} | |
| */ | |
| function parseSetCard(setCard) { | |
| const setLink = /** @type {HTMLAnchorElement} */ (setCard.querySelector('.psetInfo > div:nth-of-type(2) > a')); | |
| const url = setLink.href; | |
| const date = /** @type {HTMLTimeElement} */ (setCard.querySelector('time')).innerText.trim(); | |
| // alt="Lily Jordan, Kylie Page in Kylie Page fucking in the bed with her small tits, at 2chickssametime.com" | |
| // alt="Lily Jordan in at amkingdom.com" | |
| // alt="Michelle B (Lenka) in Michelle B 1, at domai.com" | |
| // alt="Eva Fenix in , at atkexotics.com" | |
| // [website sets - no models] alt="in The Pro, at brandibelle.com" | |
| // https://regex101.com/r/SqnoyK/3 | |
| const alt = /** @type {HTMLImageElement} */ (setCard.querySelector('img')).alt.trim(); | |
| const altMatch = alt.match(/^(?:(.*?) )?in (?:(.+)?, )? at .+$/); | |
| if (!altMatch) { | |
| console.debug(setCard); | |
| throw new Error(`Failed to parse alt value: ${alt}`); | |
| } | |
| const [, rawActualNames, rawTitle] = altMatch; | |
| if (currentModel && !rawActualNames) | |
| throw new Error(`Failed to parse alt value: ${alt} (no models)`); | |
| const title = rawTitle || '[no title]'; | |
| setLink.title = title; | |
| /** @type {{ [index: number]: string | undefined }} */ | |
| const actualNames = {}; | |
| rawActualNames?.split(/, /g).forEach((name, i) => { | |
| // https://regex101.com/r/fO11Ht/1 | |
| const nameMatch = name.match(/^(.+?)(?: \((.+)\))?$/); | |
| if (!nameMatch) { | |
| console.error(`Failed to parse name: ${name}`, setCard); | |
| return; | |
| } | |
| const [, aliasOrName, actualName] = nameMatch; | |
| actualNames[i] = actualName || aliasOrName || undefined; | |
| }); | |
| /** @type {IndexxxSetModel[]} */ | |
| const allModels = ( | |
| /** @type {(HTMLAnchorElement | HTMLSpanElement)[]} */ | |
| (Array.from(setCard.querySelectorAll('.models li .modelLink'))) | |
| .map((modelLink, i) => ({ | |
| name: modelLink.innerText.trim(), | |
| actualName: actualNames[i], | |
| url: (modelLink instanceof HTMLAnchorElement | |
| ? modelLink | |
| : /** @type {HTMLLinkElement} */ (modelLink.nextElementSibling) | |
| ).href, | |
| el: modelLink, | |
| })) | |
| ); | |
| let thisModel = allModels.find(({ el }) => el instanceof HTMLSpanElement); | |
| if (thisModel) { | |
| allModels.splice(allModels.indexOf(thisModel), 1); | |
| } else { | |
| if (currentModel) { | |
| console.info('unexpected missing set model', setCard); | |
| /** @type {IndexxxSetModel} */ | |
| thisModel = ({ ...currentModel, el: null }); | |
| } | |
| } | |
| // FIXME: TypeScript error | |
| /** @type {IndexxxSet["models"]} */ | |
| const models = [thisModel, ...allModels]; | |
| const siteEl = /** @type {HTMLAnchorElement | HTMLSpanElement} */ | |
| (setCard.querySelector('.psetInfo > .psWebsite > :first-child')); | |
| const { innerText: display, title: siteTitle } = siteEl; | |
| const outUrl = siteEl.getAttribute('href'); | |
| const siteName = siteTitle?.replace(/^Go to: /, ''); | |
| /** @type {IndexxxSet["site"]} */ | |
| const site = { name: siteName, display, outUrl, el: siteEl }; | |
| return { date, url, models, site, title, el: setCard }; | |
| } | |
| /** | |
| * @template {HTMLElement} E | |
| * @param {E} el | |
| * @param {Partial<CSSStyleDeclaration>} styles | |
| * @returns {E} | |
| */ | |
| function setStyles(el, styles) { | |
| Object.assign(el.style, styles); | |
| return el; | |
| } | |
| /** | |
| * @template {HTMLElement} E | |
| * @param {E} el | |
| * @param {Object} opts | |
| * @returns {E} | |
| */ | |
| function tryResizable(el, opts) { | |
| try { | |
| //@ts-expect-error | |
| jQuery(el).resizable(opts); | |
| } catch (error) {} | |
| return el; | |
| } | |
| main(); |
Useful script!
;-)
Can you add a related pornstar panel, with Thumbnail and links in indexxx ?
If you can find a way to link to EGAFD thumbnail when it possible - which are very useful for verification - it should be fine...