// ==UserScript==
// @name IAFD
// @author peolic
// @version 4.099
// @description Add extra utilities to iafd.com
// @icon https://www.iafd.com/favicon-196x196.png
// @namespace https://github.com/peolic
// @match https://www.iafd.com/*
// @grant GM.setClipboard
// @grant GM.addStyle
// @homepageURL https://gist.github.com/peolic/9e2981a8a14a49b9626cb277f878b157
// ==/UserScript==
function main() {
makeInternalLinksHTTPS();
fixIAFDLinks();
const { pathname } = window.location;
if (/^\/title\.(asp|rme\/)/.test(pathname))
return titlePage();
if (/^\/person\.(asp|rme\/)/.test(pathname))
return personPage();
if (/^\/(([a-z]{4}_)?studio|distrib)\.(asp|rme\/)$/.test(pathname))
return studioDistribSelectPage();
if (/^\/(studio|distrib)\.(asp\?|rme\/)(studio|distrib)=\d+/.test(pathname))
return studioDistribPage();
if (/^\/(new|updated)(perfs|headshots)\.asp/.test(pathname))
return personUpdatePage();
if (/^\/lookuptat\.asp/.test(pathname))
return tattooLookupPage();
if (/^\/results\.asp/.test(pathname))
return setSearchField();
}
const getBioDataElement = (headingText) => {
return Array.from(document.querySelectorAll('p.bioheading'))
.find(e => e.innerText.localeCompare(headingText, 'en', { sensitivity: 'accent' }) === 0)
?.nextElementSibling;
};
const makeBioEntry = (heading, ...data) => {
return [['heading', heading], ...data.map((d) => ['data', d])].map(([type, text]) => {
const p = document.createElement('p');
p.classList.add(`bio${type}`);
if (text instanceof Node) p.append(text);
else p.innerText = text;
return p;
});
};
const makeQuickSelect = (text) => {
const b = document.createElement('b');
b.style.userSelect = 'all';
b.innerText = text;
return b;
};
const makeISODateElement = (date) => {
const isoDate = new Date(`${date} 0:00 UTC`).toISOString().slice(0, 10);
return makeQuickSelect(isoDate);
};
const slug = (text, char='-') => {
return encodeURI(text.replace(/[^a-z0-9_.,:'-]+/gi, '___'))
.toLowerCase()
.replace(/___/g, char);
};
const makeLegacyTitleLink = (title, year) => {
const encodedTitle = slug(title, '+').replace(/^\+|\++$|^(a|an|the)\+/g, '');
return `https://www.iafd.com/title.rme/title=${encodedTitle}/year=${year}/${encodedTitle.replace(/\+/g, '-')}.htm`;
};
const makeLegacyPerformerLink = (perfId, gender, name=undefined) => {
const nameSlug = name ? `/${slug(name, '-')}.htm` : '';
return `https://www.iafd.com/person.rme/perfid=${perfId}/gender=${gender}${nameSlug}`;
};
function makeInternalLinksHTTPS() {
/** @type {NodeListOf<HTMLAnchorElement>} */
(document.querySelectorAll('a[href^="http://www.iafd.com/"]')).forEach((el) => {
el.href = el.href.replace(/^http:/, 'https:');
});
};
function fixIAFDLinks() {
const replacer = (_, offset, string) => {
if (offset > string.indexOf('/year=')) return '-';
else return '+';
};
/** @type {NodeListOf<HTMLAnchorElement>} */
(document.querySelectorAll('a[href*="//www.iafd.com/"]')).forEach((el) => {
el.href = el.href
// upper-case UUID to lower-case UUID
.replace(/(?<=\/id=)([A-F0-9-]+)$/g, (t) => t.toLowerCase())
// fix bad escaped characters
.replace(/%2f/g, replacer);
});
};
function titlePage() {
const canonical = document.querySelector('.panel:last-of-type .padded-panel')
?.innerText?.match(/should be linked to:\n(.+)/)?.[1]?.replace(/^http:/, 'https:');
if (canonical)
history.replaceState(null, '', canonical);
const titleHeading = document.querySelector('h1');
// remove trailing space
titleHeading.textContent = titleHeading.textContent.replace(/[ \n]$/, '');
const correct = document.querySelector('#correct');
if (correct) {
const filmIdBio = makeBioEntry('Film ID', correct.querySelector('input[name="FilmID"]')?.value);
filmIdBio[0].style.marginTop = '4em';
correct.before(...filmIdBio);
const links = [];
if (canonical) {
const link = document.createElement('a');
link.href = canonical;
link.title = link.href;
link.innerText = 'Title Page Link';
Object.assign(link.style, {
color: '#337ab7',
margin: '1em 0',
});
links.push(link);
}
// Legacy link
const titleAndYear = titleHeading.innerText.match(/^(.+) \((\d+)\)$/)?.slice(1) ?? [];
const legacyLink = document.createElement('a');
legacyLink.href = makeLegacyTitleLink(...titleAndYear);
legacyLink.title = legacyLink.href;
legacyLink.innerText = 'Legacy Title Page Link';
Object.assign(legacyLink.style, {
color: '#337ab7',
margin: '1em 0',
});
links.push(legacyLink);
correct.before(...makeBioEntry('🔗 Links', ...links));
}
const titleSeq = titleHeading.innerText.match(/\((X{0,3}(IX|IV|V?I{0,3}))\)/);
if (titleSeq) {
// Break up the text node
for (let i=0; i < titleHeading.childNodes.length; i++) {
if (i >= 10) break;
const node = titleHeading.childNodes[i];
const open = node.textContent.indexOf('(', 1),
close = node.textContent.indexOf(')');
if (!(open || close)) continue;
if (close && close+1 >= node.length) break;
node.splitText(open ?? close+1);
}
const seqNode = Array.from(titleHeading.childNodes).find((node) => node.textContent.includes(titleSeq?.[1]));
const seq = document.createElement('small');
seq.style.color = 'lightgray';
seq.textContent = seqNode.textContent;
titleHeading.replaceChild(seq, seqNode);
}
const releaseDateElement = getBioDataElement('RELEASE DATE');
const releaseDateNodes = Array.from(releaseDateElement.childNodes).filter((node) => node.nodeType === node.TEXT_NODE);
releaseDateNodes.forEach((node) => {
const text = node.textContent.trim();
const releaseDate = text.replace(/ \(.+\)$/, '');
if (!releaseDate || releaseDate === 'No Data')
return;
releaseDateElement.insertBefore(makeISODateElement(releaseDate), node);
releaseDateElement.insertBefore(document.createElement('br'), node);
});
setupScenePerformers();
}
const setupScenePerformers = () => {
const sceneInfo = document.querySelector('#sceneinfo');
if (!sceneInfo) return;
const scenes = Array.from(sceneInfo.querySelectorAll('table tr'));
if (scenes.length === 0) return;
const castboxes = Array.from(document.querySelectorAll('.castbox')).map((cb) => ({
name: cb.querySelector('a').lastChild.textContent,
cb,
}));
GM.addStyle(`
.ext__scene-performers-toggle { white-space: nowrap; }
.ext__scene-performers { display: flex; flex-wrap: wrap; column-gap: 1em; }
.ext__scene-performers .multiple-matches { background: #bf0000cc; color: white; flex-basis: 100%; font-weight: 600; margin: 0.5em 1em 0 0; padding: 0.25em 0.6em; }
.ext__scene-performers .castbox { max-width: 180px; min-height: unset; float: unset; margin-left: 0; }
.ext__scene-performers .castbox img.headshot { margin-left: -14px; }
`);
const toggleStates = new Array(scenes.length).fill(false);
const sceneToggles = scenes.map((sceneRow, sceneIndex) => {
const sceneLabel = sceneRow.querySelector(':scope > td:first-of-type');
const sceneLabelText = sceneLabel.innerText.toLowerCase();
const scenePerformers = sceneLabel.nextElementSibling;
const toggle = (newState = undefined) => {
let sceneContainer = sceneRow.querySelector('.ext__scene-performers');
if (sceneContainer) {
sceneContainer.style.display = sceneContainer.style.display === 'none' || newState === true ? '' : 'none';
toggleStates[sceneIndex] = sceneContainer.style.display !== 'none';
return;
} else if (newState === false) {
return;
}
const performers = scenePerformers.innerText
.match(/^.+$/m)[0] // grab first row
.replace(/ \[[a-z0-9 ]+\]/gi, '') // remove tags
.split(/, /g); // split names
sceneContainer = document.createElement('div');
sceneContainer.className = 'ext__scene-performers';
performers.forEach((performer) => {
const matches = castboxes.filter(({ name }) => name === performer);
if (matches.length === 0) {
const [count, text] = performer.match(/^(\d+) (.+?)s?$/)?.slice(1, 3) ?? [1, performer];
for (let i = 1; i <= count; i++) {
const node = document.createElement('div');
node.className = 'castbox';
const p = document.createElement('p');
const placeholder = document.createElement('div');
Object.assign(placeholder.style, { width: '170px', height: '200px', padding: '1em' });
const na = document.createElement('abbr');
na.title = `Performer "${performer}" was not found above`;
na.innerText = '[N/A]';
p.append(placeholder, `${count == 1 ? performer : text} `, na);
node.append(p);
sceneContainer.append(node);
}
} else {
if (matches.length > 1) {
const warning = document.createElement('div');
warning.innerText = `multiple matches for "${performer}"`;
warning.className = 'multiple-matches';
sceneContainer.prepend(warning);
}
matches.forEach(({ cb: performerCB }) => {
const node = performerCB.cloneNode(true);
// remove empty elements and extra line breaks from the inner paragraph element
for (const cn of Array.from(node.firstElementChild.childNodes).reverse()) {
if (cn.textContent.trim()) break;
cn.remove();
}
sceneContainer.append(node);
});
}
});
scenePerformers.append(sceneContainer);
toggleStates[sceneIndex] = true;
};
const sceneToggle = document.createElement('div');
sceneLabel.append(sceneToggle);
sceneToggle.append(sceneLabel.firstChild);
Object.assign(sceneToggle.style, { cursor: 'pointer', textDecoration: 'underline' });
sceneToggle.title = `View performers for ${sceneLabelText}`;
sceneToggle.className = 'ext__scene-performers-toggle';
sceneToggle.addEventListener('click', () => {
if (window.getSelection().type === 'Range') return; // Prevent unwanted action when selecting text
toggle();
});
return toggle;
});
// Toggle all scenes
const sceneInfoHeading = sceneInfo.querySelector('.panel-heading h3');
Object.assign(sceneInfoHeading.style, { cursor: 'pointer', textDecoration: 'double underline' });
sceneInfoHeading.title = `View performers for all ${scenes.length} scenes\n [hold Alt to reset]`;
sceneInfoHeading.addEventListener('click', (ev) => {
if (window.getSelection().type === 'Range') return; // Prevent unwanted action when selecting text
if (ev.altKey) {
sceneInfo.querySelectorAll('.ext__scene-performers').forEach((el) => el.remove());
return toggleStates.fill(false);
}
const newState = toggleStates.filter(Boolean).length < scenes.length;
sceneToggles.forEach((toggle) => toggle(newState));
});
};
function personPage() {
makeExportButton();
const canonical = document.querySelector('#perfwith a[href^="/person.rme/"]')?.href;
if (canonical)
history.replaceState(null, '', canonical);
const nameHeading = document.querySelector('h1');
// remove trailing space
nameHeading.textContent = nameHeading.textContent.replace(/[ \n]$/, '');
// Director page
if (/\/gender=d\//.test(window.location.pathname)) {
const directorPage = document.createElement('b');
directorPage.innerText = 'Director-only page:';
const [maleLink, femaleLink] = ['male', 'female'].map((gender) => {
const a = document.createElement('a');
a.href = (canonical ?? window.location.href).replace('/gender=d/', `/gender=${gender.charAt(0)}/`);
a.innerText = gender;
return a;
});
const directorHelp = document.createElement('div');
directorHelp.append(directorPage, ' try the ', maleLink, ' or ', femaleLink, ' performer pages');
nameHeading.after(directorHelp);
}
const corrections = document.querySelector('#corrections');
if (corrections) {
const perfIdCorrectionInput = corrections.querySelector('input[name="PerfID"]');
const gender = corrections.querySelector('input[name="Gender"]')?.value;
const perfIdScenePair = document.querySelector('#scenepairings')?.dataset.src.match(/\/perfid=(.+)$/)[1];
const perfId = perfIdCorrectionInput?.value ?? perfIdScenePair;
const perfIdBio = makeBioEntry('Performer ID', `${perfId} [${gender.toUpperCase()}]`);
perfIdBio[0].style.marginTop = '4em';
corrections.before(...perfIdBio);
if (!perfIdCorrectionInput.getAttribute('value')) {
corrections.querySelector('input[type="submit"]').style.color = 'red';
perfIdCorrectionInput.setAttribute('value', perfId);
}
const links = [];
if (canonical) {
const link = document.createElement('a');
link.href = canonical;
link.title = link.href;
link.innerText = 'Performer Page Link';
Object.assign(link.style, {
color: '#337ab7',
margin: '1em 0',
});
links.push(link);
}
// Legacy link
const legacyLink = document.createElement('a');
legacyLink.href = makeLegacyPerformerLink(perfId, gender, nameHeading.innerText);
legacyLink.title = legacyLink.href;
legacyLink.innerText = 'Legacy Performer Page Link';
Object.assign(legacyLink.style, {
color: '#337ab7',
margin: '1em 0',
});
links.push(legacyLink);
corrections.before(...makeBioEntry('🔗 Links', ...links));
}
const birthDateElement = getBioDataElement('BIRTHDAY');
const birthdayText = birthDateElement.innerText;
const birthDate = birthdayText.trim().match(/([A-Z][a-z]+ \d{1,2}, \d{4})\b/);
if (birthDate) {
birthDateElement.prepend(makeISODateElement(birthDate[1]), document.createElement('br'));
} else {
const partialDate = birthdayText.trim().match(/(?<month>\?\?|\d{1,2})\/(?<day>\?\?|\d{1,2})\/(?<year>\d{2}[\d?]{2})/);
if (partialDate) {
const { year, month, day } = partialDate.groups;
const exactYear = /^\d{4}$/.test(year);
if (exactYear) {
const dateParts = [year, month, day].join('-');
const firstQM = dateParts.indexOf('?', 4)-1;
const [isoDate, remainder] = [dateParts.slice(0, firstQM), dateParts.slice(firstQM)];
birthDateElement.prepend(makeQuickSelect(isoDate), remainder, document.createElement('br'));
}
if (month !== '??') {
const partialDateStr = (new Date(birthdayText.replace(/\?\?/g, '01') + ' 12:00'))
.toLocaleString('en-us', {
month: month === '??' ? undefined : 'long',
day: day === '??' ? undefined : 'numeric',
year: exactYear ? 'numeric' : undefined,
}) + (exactYear ? '' : `, ${year}`);
birthDateElement.lastChild.before(document.createTextNode(partialDateStr), document.createElement('br'));
}
}
}
const heightElement = getBioDataElement('HEIGHT');
const height = heightElement?.innerText.trim().match(/\((\d+) cm\)/);
if (height && heightElement) {
heightElement.prepend(makeQuickSelect(height[1]), document.createElement('br'));
}
// example: Lee Stone
const akasDirectorElement = getBioDataElement('DIRECTOR AKA');
if (akasDirectorElement && akasDirectorElement.innerText.match(/^None No known aliases$/)) {
akasDirectorElement.innerText = akasDirectorElement.innerText.replace(/^None /, '');
}
const akasElement = getBioDataElement('AKA') ?? getBioDataElement('PERFORMER AKA');
const akas = akasElement?.innerText.trim();
// empty Performer AKA
if (akas === '') {
akasElement.innerText = 'No known aliases';
}
if (akas && akas !== 'No known aliases') {
const copyButtonDefaultText = '[copy names]';
const akasCopy = document.createElement('a');
akasCopy.innerText = copyButtonDefaultText;
akasCopy.title = 'Copy only the names used (removes site names)'
akasCopy.id = 'copy-akas';
Object.assign(akasCopy.style, { float: 'right', cursor: 'pointer', lineHeight: 1 });
akasCopy.addEventListener('click', async (ev) => {
ev.stopPropagation();
ev.preventDefault();
// https://regex101.com/r/7Ad3U1/2
const names = akas.replace(/ \(.+?\)/g, '').split(/[,;\n] ?/g).map((s) => s.trim()).filter(Boolean);
if (!names || names.length === 0) {
akasCopy.innerText = '❌ Failed!';
akasCopy.style.color = 'red';
return;
}
const result = names.join(', ');
GM.setClipboard(result);
akasCopy.innerText = '✔ Copied to clipboard';
akasCopy.style.color = 'green';
akasElement.innerText = result;
akasElement.style.backgroundColor = 'yellow';
await wait(1500);
akasCopy.innerText = copyButtonDefaultText;
akasCopy.style.color = null;
akasElement.innerText = akas;
akasElement.style.backgroundColor = '';
})
akasElement.previousElementSibling.append(akasCopy);
}
}
function studioDistribSelectPage() {
const select = document.querySelector('select[name="Studio"], select[name="Distrib"]');
const pageType = select.closest('form').getAttribute('action').replace(/^\/|\.rme\/$/g, '').toLowerCase();
const selectType = select.name.toLowerCase();
const fullType = selectType === 'distrib' ? 'distributor' : selectType;
const listId = `${selectType}-list`;
const submit = document.querySelector('form input[type="submit"]');
const input = document.createElement('input');
input.type = 'text';
input.placeholder = `Lookup ${fullType}...`;
input.setAttribute('list', listId);
Object.assign(input.style, {
display: 'block',
width: window.getComputedStyle(select).width,
marginBottom: '.5rem',
});
select.before(input);
input.focus();
const datalist = document.createElement('datalist');
datalist.id = listId;
for (const option of select.children) {
const cloned = option.cloneNode(true);
cloned.removeAttribute('value');
datalist.append(cloned);
}
select.before(datalist);
const escapeRegex = (string) => {
return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
};
input.addEventListener('input', () => {
const value = input.value.trim();
if (!value) return;
const search = new RegExp(`^${escapeRegex(value)}`, 'i');
const found = Array.from(select.children).find((o) => search.test(o.innerText));
if (!found) return;
select.value = found.value;
});
const handleClick = (/** @type {Event} */ ev) => {
if (!select.value)
return;
ev.preventDefault();
ev.stopPropagation();
const displayName = select.selectedOptions[0].textContent.replace(/[^a-z0-9.-]/ig, '-');
window.location = `https://www.iafd.com/${pageType}.rme/${selectType}=${select.value}/${displayName}.htm`;
};
select.addEventListener('dblclick', (ev) => {
if (ev.target instanceof HTMLOptionElement)
return handleClick(ev);
});
select.addEventListener('keyup', (/** @type {KeyboardEvent} */ ev) => {
if (ev.key === 'Enter')
return handleClick(ev);
});
submit.addEventListener('click', handleClick);
}
function studioDistribPage() {
makeExportButton();
}
async function makeExportButton() {
const filter = await elementReady('div[id$="_filter"]');
const type = filter.id.split('_')[0];
const exportButtonDefaultText = 'Export CSV';
const exportTimestamp = (new Date()).toISOString();
const tools = document.createElement('div');
Object.assign(tools.style, {
marginRight: '.5em',
display: 'inline-block',
});
filter.prepend(tools);
const button = document.createElement('button');
button.type = 'button';
button.innerText = exportButtonDefaultText;
button.style.marginRight = '.5em';
tools.prepend(button);
(async () => {
const info = await Promise.race([
elementReady(`div#${type}_info`).then((el) => /** @type {HTMLDivElement} */ (el).innerText),
wait(5000).then(() => null),
]);
if (!info) return;
/** @param {string} s */
const toNumber = (s) => Number(s?.replace(/,/, ''));
const { start, end, total } = info
.match(/Showing (?<start>[\d,]+) to (?<end>[\d,]+) of (?<total>[\d,]+) entries/i)
?.groups || {};
const count = toNumber(end) - Math.max(toNumber(start) - 1, 0);
const totalCount = toNumber(total);
let countLabel = `Count: ${count}`;
if (count !== totalCount)
countLabel += ` out of ${total}`;
button.title = countLabel;
})();
button.addEventListener('click', async () => {
const output = makeOutput(type);
if (!output) {
button.innerText = '❌ Failed!';
button.style.backgroundColor = 'red';
return;
}
GM.setClipboard(output);
button.innerText = '✔ Copied to clipboard';
button.style.backgroundColor = 'yellow';
await wait(1500);
button.innerText = exportButtonDefaultText;
button.style.backgroundColor = '';
});
/** @param {string} type */
const makeOutput = (type) => {
const dataRows = Array.from(document.querySelectorAll(`div#${type}_wrapper .dataTable tbody > tr`));
let columns;
let csv;
if (type === 'studio' || type === 'distable') {
const data = dataRows.map((tr) => ({
url: tr.children[0].querySelector('a').href,
title: tr.children[0].innerText,
studio: tr.children[1].innerText,
year: Number(tr.children[2].innerText),
}));
columns = ['Title', 'Studio', 'Year', 'URL'];
csv = data.map((d) => columns.map((c) => d[c.toLowerCase()]).join('\t'));
} else if (type === 'personal') {
const data = dataRows.map((tr) => ({
url: tr.children[0].querySelector('a').href,
title: tr.children[0].innerText,
year: Number(tr.children[1].innerText),
distributor: tr.children[2].innerText,
notes: tr.children[3].innerText,
}));
columns = ['Title', 'Distributor', 'Year', /*'Notes', */'URL'];
csv = data.map((d) => columns.map((c) => d[c.toLowerCase()]).join('\t'));
} else {
return null;
}
return columns.join('\t') +`\t${exportTimestamp}` + '\n' + csv.join('\n') + '\n';
};
}
function personUpdatePage() {
}
async function tattooLookupPage() {
const info = await Promise.race([
elementReady(`div[id^="tat"][id$="_info"]`).then((el) => /** @type {HTMLDivElement} */ (el).innerText),
wait(5000).then(() => null),
]);
if (!info) return;
}
function setSearchField() {
const field = document.querySelector('#the-basics > input[name="searchstring"]');
const params = new URLSearchParams(window.location.search);
field.value = params.get('searchstring');
}
const wait = (/** @type {number} */ ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Waits for an element satisfying selector to exist, then resolves promise with the element.
* Useful for resolving race conditions.
*
* @param {string} selector
* @param {HTMLElement} [parentEl]
* @returns {Promise<Element>}
*/
function elementReady(selector, parentEl) {
return new Promise((resolve, reject) => {
let el = (parentEl || document).querySelector(selector);
if (el) {resolve(el);}
new MutationObserver((mutationRecords, observer) => {
// Query for elements matching the specified selector
Array.from((parentEl || document).querySelectorAll(selector)).forEach((element) => {
resolve(element);
//Once we have resolved we don't need the observer anymore.
observer.disconnect();
});
})
.observe(parentEl || document.documentElement, {
childList: true,
subtree: true
});
});
}
main();