This userscript adds the year to partial video release dates on manyvids.com
Installation requires a browser extension such as Tampermonkey or Greasemonkey.
This userscript adds the year to partial video release dates on manyvids.com
Installation requires a browser extension such as Tampermonkey or Greasemonkey.
| // ==UserScript== | |
| // @name ManyVids Release Year | |
| // @author peolic | |
| // @version 1.8 | |
| // @description Adds year to partial video release dates | |
| // @icon https://logos.manyvids.com/icon_public/favicon-32x32.png?v=4 | |
| // @namespace https://github.com/peolic | |
| // @match https://www.manyvids.com/Video/* | |
| // @match http*://web.archive.org/*/http*://www.manyvids.com/Video/* | |
| // @grant GM.xmlHttpRequest | |
| // @connect manyvids.com | |
| // @homepageURL https://gist.github.com/peolic/09dc7e0cebe6cb57babcec404bd37a3f | |
| // @downloadURL https://gist.github.com/peolic/09dc7e0cebe6cb57babcec404bd37a3f/raw/manyvids-release-year.user.js | |
| // @updateURL https://gist.github.com/peolic/09dc7e0cebe6cb57babcec404bd37a3f/raw/manyvids-release-year.user.js | |
| // ==/UserScript== | |
| // @ts-check | |
| async function main() { | |
| const dateEl = await getDateEl(); | |
| if (!dateEl || !dateEl.textContent) { | |
| console.error(`[userscript] partial date not found`); | |
| if (dateEl) | |
| return makeError(dateEl, 'Partial date not found'); | |
| const channelDiv = /** @type {HTMLDivElement} */ (await elementReady('.video-details .media')); | |
| const errorEl = document.createElement('h4'); | |
| errorEl.innerText = `userscript error: partial date not found`; | |
| channelDiv.after(errorEl); | |
| makeError(errorEl, ''); | |
| return; | |
| } | |
| const original = dateEl.textContent.trim(); | |
| const partialDate = getPartialDate(original); | |
| if (!partialDate) { | |
| console.error(`[userscript] unable to parse partial date "${original}"`); | |
| makeError(dateEl, `Unable to parse partial date "${original}"`); | |
| return; | |
| } | |
| const [actualDate, source] = await getActualDate(partialDate); | |
| if (actualDate === null) { | |
| console.error('[userscript] full date not found'); | |
| makeError(dateEl, 'Full date not found'); | |
| return; | |
| } | |
| const fullDate = document.createElement('b'); | |
| fullDate.style.userSelect = 'all'; | |
| fullDate.innerText = actualDate.toISOString().slice(0, 10); | |
| dateEl.innerText = | |
| actualDate.toLocaleString("en-us", { | |
| month: "short", year: "numeric", day: "numeric", | |
| // hour: "2-digit", minute: "2-digit", timeZoneName: "short", | |
| timeZone: "UTC", | |
| }); | |
| dateEl.append(' (', fullDate, ')'); | |
| setStyles(dateEl, { textDecoration: 'underline dotted', cursor: 'help' }) | |
| dateEl.title = [ | |
| 'Date is in UTC (+0)', | |
| `Source: ${source}`, | |
| actualDate.toISOString() | |
| ].join('\n'); | |
| if (!isExpectedDate(actualDate, partialDate)) { | |
| makeError(dateEl, `Full date may be incorrect!\nOriginal date was ${original}`); | |
| } | |
| console.debug(`[userscript] ${[ | |
| source, | |
| actualDate.toISOString(), | |
| original, | |
| ].join(' / ')}`); | |
| } | |
| /** @returns {Promise<HTMLSpanElement | null>} */ | |
| async function getDateEl() { | |
| const dateSel = [ | |
| '[rel="kiwi-video-bff"]:not(.d-none) [rel="kiwi-video-bff-date"]', | |
| 'span[rel="kiwi-video-bff-fallback"]:not(.d-none) > span:nth-child(2)', | |
| '.video-details .media ~ .mb-1 > span:not([class]):nth-child(2)', // Archive | |
| ].join(', '); | |
| let attempts = 20; // 20 * 50 = 1000ms | |
| let el = document.querySelector(dateSel); | |
| while (!el && attempts > 0) { | |
| attempts--; | |
| await wait(50); | |
| el = document.querySelector(dateSel); | |
| } | |
| if (!el) | |
| return null; | |
| attempts = 30; // 30 * 50 = 1500ms | |
| while (!el.textContent && attempts > 0) { | |
| attempts--; | |
| await wait(50); | |
| } | |
| return /** @type {HTMLSpanElement} */ (el); | |
| } | |
| /** | |
| * @typedef PartialDate | |
| * @property {number} month | |
| * @property {number} day | |
| */ | |
| /** | |
| * @param {string} original | |
| * @returns {PartialDate | null} | |
| */ | |
| function getPartialDate(original) { | |
| const [monthName, day] = original.split(/ /g); | |
| if (!(monthName && day)) return null; | |
| const month = ({ | |
| Jan: 1, Feb: 2, Mar: 3, Apr: 4, May: 5, Jun: 6, | |
| Jul: 7, Aug: 8, Sep: 9, Oct: 10, Nov: 11, Dec: 12, | |
| })[monthName]; | |
| if (!month) return null; | |
| return { month, day: Number(day) }; | |
| } | |
| /** @typedef {'video' | 'image' | 'API'} DateSource */ | |
| /** | |
| * @param {PartialDate} partialDate | |
| * @returns {Promise<[actualDate: Date, source: DateSource] | [actualDate: null, source: null]>} | |
| */ | |
| async function getActualDate(partialDate) { | |
| /** @type {DateSource | undefined} */ | |
| let source; | |
| /** @type {number | null} */ | |
| let timestamp; | |
| [timestamp, source] = getTimestampMeta(); | |
| if (!timestamp) { | |
| // If all else failed, use the API endpoint | |
| timestamp = await getTimestampAPI(); | |
| source = 'API'; | |
| } | |
| if (!(timestamp && source)) | |
| return [null, null]; | |
| let actualDate = new Date(timestamp); | |
| if (!isExpectedDate(actualDate, partialDate) && source !== 'API') { | |
| // Use API | |
| const apiTimestamp = await getTimestampAPI(); | |
| if (apiTimestamp) { | |
| actualDate = new Date(apiTimestamp); | |
| timestamp = apiTimestamp; | |
| source = 'API'; | |
| } | |
| } | |
| return [actualDate, source]; | |
| } | |
| /** | |
| * Based on: | |
| * https://github.com/ThePornDatabase/scrapers/blob/ec4b146a3eef5aa093ecebfc927af0ca577e6284/scenes/networkManyVids.py#L126-L151 | |
| * @returns {[ts: number, source: 'video' | 'image'] | [ts: null, source: undefined]} | |
| */ | |
| function getTimestampMeta() { | |
| const videoMeta = | |
| /** @type {HTMLMetaElement | undefined} */ | |
| (document.querySelector('meta[property="og:video"]'))?.content; | |
| if (videoMeta) { | |
| const raw = videoMeta.match(/_(\d{10,13})\./)?.[1]; | |
| if (raw) { | |
| console.debug('[userscript] using video', raw); | |
| return [Number(raw) * (raw.length === 13 ? 1 : 1000), 'video']; | |
| } | |
| } | |
| const imageMeta = | |
| /** @type {HTMLMetaElement | undefined} */ | |
| (document.querySelector('meta[name="twitter:image"]'))?.content; | |
| if (imageMeta) { | |
| const raw = imageMeta.match(/.*_([0-9a-zA-Z]{10,20})\.jpg/)?.[1]; | |
| if (raw) { | |
| console.debug('[userscript] using image', raw); | |
| const hex = raw.slice(0, 8); | |
| const dec = Number(raw); | |
| if (hex && hex >= '386D43BC' && hex <= '83AA7EBC') | |
| return [parseInt(hex, 16) * 1000, 'image']; | |
| if (dec && dec >= 946684860 && dec <= 2208988860) | |
| return [dec * 1000, 'image']; | |
| } | |
| } | |
| return [null, undefined]; | |
| } | |
| /** | |
| * @typedef MVWindow | |
| * @property {string} [bffApiUrl] | |
| * @property {string} [videoId] | |
| */ | |
| /** | |
| * @returns {Promise<number | null>} | |
| */ | |
| async function getTimestampAPI() { | |
| const { bffApiUrl, videoId: bffVideoId } = /** @type {MVWindow} */ (window); | |
| const apiEndpoint = bffApiUrl || 'https://video-player-bff.estore.kiwi.manyvids.com/videos/'; | |
| const videoId = bffVideoId || window.location.pathname.match(/\/Video\/(\d+)\//)?.[1]; | |
| if (apiEndpoint && videoId) { | |
| console.debug('[userscript] using api'); | |
| const url = apiEndpoint + videoId; | |
| const res = await new Promise((resolve, reject) => { | |
| //@ts-expect-error | |
| GM.xmlHttpRequest({ | |
| method: 'GET', | |
| url, | |
| responseType: 'json', | |
| anonymous: true, | |
| timeout: 10000, | |
| onload: resolve, | |
| onerror: reject, | |
| }); | |
| }); | |
| if (res.status >= 200 && res.status <= 299) { | |
| const iso = res.response?.launchDate; | |
| if (iso) { | |
| return new Date(iso).getTime(); | |
| } | |
| } else { | |
| console.error(`[userscript] HTTP ${res.status} ${res.statusText} GET ${url}`); | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * @template {HTMLElement | SVGSVGElement} E | |
| * @param {E} el | |
| * @param {Partial<CSSStyleDeclaration>} styles | |
| * @returns {E} | |
| */ | |
| function setStyles(el, styles) { | |
| Object.assign(el.style, styles); | |
| return el; | |
| } | |
| /** | |
| * @param {Date} date | |
| * @param {PartialDate} partial | |
| */ | |
| function isExpectedDate(date, partial) { | |
| return date.getUTCDate() === partial.day && date.getUTCMonth() === partial.month - 1; | |
| }; | |
| /** | |
| * @param {HTMLSpanElement} dateEl | |
| * @param {string} error | |
| */ | |
| function makeError(dateEl, error) { | |
| let newTitle = error; | |
| if (dateEl.title) { | |
| if (error) newTitle += '\n\n'; | |
| newTitle += dateEl.title; | |
| } | |
| dateEl.title = newTitle; | |
| setStyles(dateEl, { | |
| textDecoration: 'underline dotted', | |
| cursor: 'help', | |
| color: '#dc3545', | |
| }); | |
| } | |
| const wait = (/** @type {number} */ ms) => new Promise((resolve) => setTimeout(resolve, ms)); | |
| // MIT Licensed | |
| // Author: jwilson8767 | |
| // https://gist.github.com/jwilson8767/db379026efcbd932f64382db4b02853e | |
| /** | |
| * 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(); |
I recommend changing the following line to allow for other file types such as png:
const raw = imageMeta.match(/.*_([0-9a-zA-Z]{10,20})\.jpg/)?.[1];For my own use I changed it to the following, however there may be a more elegant solution:
const raw = imageMeta.match(/.*_([0-9a-zA-Z]{10,20})\.(?:jpg|png)/)?.[1];