Renames video and gallery downloads with a semantic filename
// ==UserScript==
// @name X-Art Semantic Download
// @namespace https://gist.github.com/angrytoenail/bef6d23f43430f857e5c94cfc241954e
// @author Angry Toenail
// @description Renames video and gallery downloads with a semantic filename
// @version 0.2
// @match https://www.x-art.com/members/galleries/*
// @match https://www.x-art.com/members/videos/*
// @grant GM_download
// @icon https://www.google.com/s2/favicons?sz=64&domain=x-art.com
// ==/UserScript==
/**
* Extracts the filename from a URL by parsing the pathname.
* Strips any hash fragments and returns only the final path segment.
*
* @param {string} url - The URL to extract the filename from
* @returns {string} The filename extracted from the URL pathname, or empty string if extraction fails
*
* @example
* getFilenameFromUrl("https://www.x-art.com/members/videos/sexy-video/x-art_sexy_video_1080.mp4#hash")
* // returns "x-art_sexy_video_1080.mp4"
*/
function getFilenameFromUrl(url) {
try {
const u = new URL(url, location.href);
u.hash = "";
return u.pathname.split("/").pop() || "";
} catch (e) {
return "";
}
}
/**
* Extracts and formats the page date as YYYY-MM-DD.
* Attempts two strategies:
* 1. Looks for a "Date:" label in .info-wrapper h2 spans (gallery page)
* 2. Falls back to searching for date-like patterns (e.g., "Jul 22, 2015") in h2 elements (video page)
*
* @returns {string|null} Formatted date string in ISO format (YYYY-MM-DD) or null if not found/invalid
*
* @example
* // On a page with "Date: Jul 22, 2015"
* getPageDate()
* // returns "2015-07-22"
*/
function getPageDate() {
const label = "Date:";
const infoHeaders = Array.from(document.querySelectorAll(".info-wrapper h2"));
// 1. Try to find date helpfully prefixed with "Date:" (gallery page)
const labeledEl = infoHeaders
.flatMap((h2) => Array.from(h2.querySelectorAll("span")))
.find((span) => span.textContent.trim() === label);
let rawDate = labeledEl ? labeledEl.parentNode.textContent.replace(label, "").trim() : null;
// 2. Fallback: Search for a date-like pattern (video page)
if (!rawDate) {
const datePattern = /\w+ \d+, \d+/;
const fallbackEl = infoHeaders.find((h2) => datePattern.test(h2.textContent));
rawDate = fallbackEl?.textContent.trim();
}
if (!rawDate) return null;
const dateObj = new Date(rawDate);
return isNaN(dateObj) ? null : dateObj.toISOString().split("T")[0];
}
/**
* Extracts list of featured model(s) from the page.
* Searches for elements matching "Model(s):" or "featuring" labels in .info-wrapper h2 spans,
* then parses the model names separated by pipes (|) and formats them using locale-specific list formatting.
*
* @returns {string} Model names formatted as a locale-specific conjunction list (e.g., "Jane Doe and John Smith")
* or empty string if no models found
*
* @example
* // On a page with "Model(s): Jane Doe | John Smith"
* getModels()
* // returns "Jane Doe and John Smith" (in English locale)
*/
function getModels() {
const formatter = new Intl.ListFormat(undefined, { style: "short", type: "conjunction" });
const label = /Model\(s\):|featuring/;
const modelInfo = Array.from(document.querySelectorAll(".info-wrapper h2 span")).find((el) =>
el.textContent.trim().match(label),
);
if (!modelInfo) return "";
const modelArr = modelInfo.parentNode.textContent
.replace(label, "")
.trim()
.split(/\s*(?:\||$)\s*/)
.filter(Boolean);
return formatter.format(modelArr);
}
/**
* Constructs a semantic filename from page metadata.
* Combines site name, date, model names, and title into a standardized filename format.
* Parts are joined with " - " separator, and any missing parts are filtered out.
*
* @param {string} extension - The file extension to append (e.g., ".mp4", ".zip")
* @returns {string} A semantic filename made up of information extracted from the page
*
* @example
* // With all metadata available
* makeSemanticFilename(".mp4")
* // returns "X-Art - 2024-05-01 - Jane Doe and John Smith - Sexy Video.mp4"
*
* @example
* // With missing date
* makeSemanticFilename(".zip")
* // returns "X-Art - Jane Doe - Sexy Video.zip"
*/
function makeSemanticFilename(extension) {
const title = document.querySelector(".info-wrapper h1").textContent.trim();
const parts = ["X-Art", getPageDate(), getModels(), title];
return parts.filter(Boolean).join(" - ") + extension;
}
/**
* Main click event handler for download links.
* Intercepts clicks on .zip and .mp4 download links, prevents default browser download,
* and uses GM_download to save files with semantic filenames instead of CDN-generated names.
*
* The handler:
* - Only processes clicks on <a> elements with href ending in .zip or .mp4
* - Generates a semantic filename based on page metadata
* - Uses GM_download API to trigger download with custom filename
* - Falls back to opening link in new tab if download fails or filename can't be generated
*
* @listens document#click
* @param {MouseEvent} e - The click event object
*/
document.addEventListener(
"click",
function (e) {
const a = e.target.closest && e.target.closest("a[href]");
// only handle clicks on links to .zip or .mp4 files
if (!a || !a.href.match(/\.(zip|mp4)$/)) return;
// the original link contains a semi-semantic filename, this isn't what gets
// downloaded though, the URL ends up redirecting to a CDN that serves a
// completely different (non-semantic) filename.
const ogFilename = getFilenameFromUrl(a.href);
// we can do better...
const ext = a.href.slice(a.href.lastIndexOf("."));
const semanticFilename = makeSemanticFilename(ext);
if (semanticFilename || ogFilename) {
e.preventDefault();
e.stopPropagation();
console.log(`🔻 Downloading ${semanticFilename}`, `(${ogFilename})`);
GM_download({
url: a.href,
name: semanticFilename || ogFilename,
onerror: function (err) {
console.error("🥵 Unable to download file", err);
window.open(a.href, "_blank");
},
});
} else {
console.info("🛑 Unable to generate fancy pants filename");
window.open(a.href, "_blank");
}
},
true,
);