X-Art Semantic Download

Renames video and gallery downloads with a semantic filename

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==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,
);