X-Art Semantic Download

Renames video and gallery downloads with a semantic filename

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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