Skip to content

Instantly share code, notes, and snippets.

@peolic
Last active March 10, 2024 03:21
Show Gist options
  • Save peolic/09dc7e0cebe6cb57babcec404bd37a3f to your computer and use it in GitHub Desktop.
Save peolic/09dc7e0cebe6cb57babcec404bd37a3f to your computer and use it in GitHub Desktop.
ManyVids Release Year Userscript

ManyVids Release Year Userscript

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();
@octopusknives
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment