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];