X-Art Semantic Download

Renames video and gallery downloads with a semantic filename

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

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