Greasy Fork

Greasy Fork is available in English.

YouTube Cover Downloader

Add a button on YouTube to download the highest-quality available video thumbnail.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         YouTube Cover Downloader
// @namespace    https://tampermonkey.net/
// @version      1.0.0
// @description  Add a button on YouTube to download the highest-quality available video thumbnail.
// @author       Codex
// @match        https://www.youtube.com/*
// @match        https://m.youtube.com/*
// @run-at       document-idle
// @grant        GM_addStyle
// @grant        GM_download
// @connect      i.ytimg.com
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const BUTTON_ID = 'yt-max-thumb-download-btn';
  const SLOT_ID = 'yt-max-thumb-download-slot';
  const OWNER_ACTION_ROW_SELECTORS = [
    'ytd-watch-metadata #owner #actions',
    'ytd-watch-metadata #actions'
  ];
  const OWNER_ACTION_ANCHOR_SELECTORS = [
    'ytd-watch-metadata #owner #subscribe-button',
    'ytd-watch-metadata #owner ytd-subscription-notification-toggle-button-renderer',
    'ytd-watch-metadata #owner #notification-preference-button',
    'ytd-watch-metadata #owner #sponsor-button',
    'ytd-watch-metadata #owner ytd-subscribe-button-renderer'
  ];
  const QUALITY_RANK = {
    maxresdefault: 5,
    sddefault: 4,
    hqdefault: 3,
    mqdefault: 2,
    default: 1
  };
  const FORMAT_RANK = {
    jpg: 2,
    webp: 1
  };
  const TEXT = {
    download: '\u5c01\u9762',
    downloadTitle: '\u4e0b\u8f7d\u5f53\u524d\u89c6\u9891\u7684\u6700\u9ad8\u753b\u8d28\u5c01\u9762',
    notVideo: '\u4e0d\u5728\u89c6\u9891\u9875',
    notVideoTitle: '\u5f53\u524d\u9875\u9762\u6ca1\u6709\u53ef\u4e0b\u8f7d\u7684 YouTube \u89c6\u9891\u5c01\u9762',
    resolving: '\u89e3\u6790\u4e2d',
    resolvingTitle: '\u6b63\u5728\u67e5\u627e\u6700\u9ad8\u753b\u8d28\u5c01\u9762',
    unavailable: '\u65e0\u5c01\u9762',
    unavailableTitle: '\u6ca1\u6709\u627e\u5230\u53ef\u4e0b\u8f7d\u7684\u5c01\u9762\u56fe',
    downloading: '\u4e0b\u8f7d\u4e2d',
    downloaded: '\u5df2\u4e0b\u8f7d',
    opened: '\u5df2\u6253\u5f00\u539f\u56fe',
    openedTitle: '\u6d4f\u89c8\u5668\u672a\u80fd\u76f4\u63a5\u4e0b\u8f7d\uff0c\u5df2\u5728\u65b0\u6807\u7b7e\u9875\u6253\u5f00\u5c01\u9762\u539f\u56fe',
    failed: '\u5931\u8d25',
    failedTitle: '\u5c01\u9762\u4e0b\u8f7d\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5'
  };
  const thumbnailCache = new Map();

  let button = null;
  let slot = null;
  let refreshTimer = 0;
  let currentUrl = '';
  let isBusy = false;
  let busyVideoId = '';

  injectStyles(`
    #${BUTTON_ID} {
      display: flex;
      align-items: center;
      justify-content: center;
      min-width: 60px;
      height: 36px;
      padding: 0 12px;
      border: 1px solid var(--yt-spec-10-percent-layer, rgba(0, 0, 0, 0.12));
      border-radius: 18px;
      background: var(--yt-spec-badge-chip-background, #f2f2f2);
      color: var(--yt-spec-text-primary, #0f0f0f);
      font-size: 13px;
      font-weight: 600;
      line-height: 1;
      flex-shrink: 0;
      cursor: pointer;
      transition: transform 0.18s ease, opacity 0.18s ease, background 0.18s ease;
      box-shadow: none;
      z-index: 2147483647;
      white-space: nowrap;
    }

    #${BUTTON_ID}.is-floating {
      position: fixed;
      right: 24px;
      bottom: 88px;
      border-color: transparent;
      background: #ff0033;
      color: #ffffff;
      box-shadow: 0 8px 20px rgba(255, 0, 51, 0.26);
    }

    #${BUTTON_ID}.is-embedded {
      position: static;
      right: auto;
      bottom: auto;
      min-width: 60px;
      height: 36px;
      box-shadow: none;
    }

    #${BUTTON_ID}:hover {
      transform: translateY(-1px);
      background: var(--yt-spec-badge-chip-background, #e9e9e9);
    }

    #${BUTTON_ID}.is-floating:hover {
      background: #e1002d;
    }

    #${BUTTON_ID}:disabled {
      opacity: 0.65;
      cursor: wait;
      transform: none;
    }

    #${BUTTON_ID}.is-hidden {
      display: none !important;
    }

    #${SLOT_ID} {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: auto;
      margin-left: 8px;
      padding: 0;
      flex: 0 0 auto;
      box-sizing: border-box;
    }

    #${SLOT_ID}.is-hidden {
      display: none !important;
    }

    @media (max-width: 768px) {
      #${BUTTON_ID}.is-floating {
        right: 16px;
        bottom: 76px;
      }

      #${SLOT_ID} {
        margin-left: 6px;
      }
    }
  `);

  function injectStyles(cssText) {
    if (typeof GM_addStyle === 'function') {
      GM_addStyle(cssText);
      return;
    }

    const style = document.createElement('style');
    style.textContent = cssText;
    document.head.appendChild(style);
  }

  function getVideoId(urlString = window.location.href) {
    try {
      const url = new URL(urlString, window.location.origin);
      const pathParts = url.pathname.split('/').filter(Boolean);

      if (url.pathname === '/watch') {
        return url.searchParams.get('v') || '';
      }

      if (pathParts.length >= 2 && ['shorts', 'live', 'embed'].includes(pathParts[0])) {
        return pathParts[1];
      }
    } catch (error) {
      console.warn('[YT Thumbnail Downloader] Failed to parse URL:', error);
    }

    return '';
  }

  function isVideoPage() {
    return Boolean(getVideoId());
  }

  function sanitizeFileName(name) {
    const safeName = (name || '')
      .replace(/[<>:"/\\|?*\u0000-\u001F]/g, ' ')
      .replace(/\s+/g, ' ')
      .trim();

    return safeName.slice(0, 120) || 'youtube-thumbnail';
  }

  function getVideoTitle() {
    const selectors = [
      'ytd-watch-metadata h1 yt-formatted-string',
      'ytd-watch-metadata h1',
      '#title h1',
      'h1.title'
    ];

    for (const selector of selectors) {
      const element = document.querySelector(selector);
      const text = element && element.textContent ? element.textContent.trim() : '';
      if (text) {
        return text;
      }
    }

    const ogTitle = document.querySelector('meta[property="og:title"]');
    const metaTitle = ogTitle ? ogTitle.getAttribute('content') : '';
    if (metaTitle) {
      return metaTitle.trim();
    }

    return document.title.replace(/\s*-\s*YouTube\s*$/i, '').trim();
  }

  function getChannelName() {
    const selectors = [
      'ytd-watch-metadata #owner #channel-name a',
      'ytd-watch-metadata #owner #channel-name yt-formatted-string',
      'ytd-watch-metadata #owner #owner-name a',
      '#upload-info #channel-name a',
      '#owner-name a'
    ];

    for (const selector of selectors) {
      const element = document.querySelector(selector);
      const text = element && element.textContent ? element.textContent.trim() : '';
      if (text) {
        return text;
      }
    }

    const authorMeta = document.querySelector('meta[itemprop="author"]');
    const metaName = authorMeta ? authorMeta.getAttribute('content') : '';
    if (metaName) {
      return metaName.trim();
    }

    return '';
  }

  function buildThumbnailCandidates(videoId) {
    const qualityLevels = ['maxresdefault', 'sddefault', 'hqdefault', 'mqdefault', 'default'];
    const formats = [
      { type: 'jpg', buildUrl: (quality) => `https://i.ytimg.com/vi/${videoId}/${quality}.jpg` },
      { type: 'webp', buildUrl: (quality) => `https://i.ytimg.com/vi_webp/${videoId}/${quality}.webp` }
    ];

    const candidates = [];

    qualityLevels.forEach((quality, qualityIndex) => {
      formats.forEach((format, formatIndex) => {
        candidates.push({
          quality,
          format: format.type,
          url: format.buildUrl(quality),
          tieBreaker: qualityIndex * 10 + formatIndex
        });
      });
    });

    return candidates;
  }

  function loadImageInfo(candidate) {
    return new Promise((resolve, reject) => {
      const image = new Image();
      let settled = false;

      const finish = (callback, value) => {
        if (settled) {
          return;
        }
        settled = true;
        window.clearTimeout(timeoutId);
        callback(value);
      };

      const timeoutId = window.setTimeout(() => {
        finish(reject, new Error(`Timeout loading image: ${candidate.url}`));
      }, 5000);

      image.referrerPolicy = 'no-referrer';
      image.decoding = 'async';

      image.onload = () => {
        finish(resolve, {
          ...candidate,
          width: image.naturalWidth || 0,
          height: image.naturalHeight || 0
        });
      };

      image.onerror = () => {
        finish(reject, new Error(`Image not available: ${candidate.url}`));
      };

      image.src = candidate.url;
    });
  }

  function chooseBestThumbnail(candidates) {
    const sortedCandidates = [...candidates].sort((left, right) => {
      const pixelDiff = right.width * right.height - left.width * left.height;
      if (pixelDiff !== 0) {
        return pixelDiff;
      }

      const qualityDiff = (QUALITY_RANK[right.quality] || 0) - (QUALITY_RANK[left.quality] || 0);
      if (qualityDiff !== 0) {
        return qualityDiff;
      }

      const formatDiff = (FORMAT_RANK[right.format] || 0) - (FORMAT_RANK[left.format] || 0);
      if (formatDiff !== 0) {
        return formatDiff;
      }

      return left.tieBreaker - right.tieBreaker;
    });

    return sortedCandidates[0] || null;
  }

  async function resolveBestThumbnail(videoId) {
    if (!videoId) {
      return null;
    }

    if (!thumbnailCache.has(videoId)) {
      const resolver = (async () => {
        const candidates = buildThumbnailCandidates(videoId);
        const results = await Promise.allSettled(candidates.map(loadImageInfo));
        const availableImages = results
          .filter((result) => result.status === 'fulfilled')
          .map((result) => result.value)
          .filter((image) => image.width > 0 && image.height > 0);

        const bestThumbnail = chooseBestThumbnail(availableImages);
        if (!bestThumbnail) {
          thumbnailCache.delete(videoId);
        }

        return bestThumbnail;
      })().catch((error) => {
        thumbnailCache.delete(videoId);
        throw error;
      });

      thumbnailCache.set(videoId, resolver);
    }

    return thumbnailCache.get(videoId);
  }

  function ensureSlot() {
    if (slot && document.contains(slot)) {
      return slot;
    }

    slot = document.createElement('div');
    slot.id = SLOT_ID;
    slot.classList.add('is-hidden');

    return slot;
  }

  function ensureButton() {
    if (button && document.contains(button)) {
      return button;
    }

    button = document.createElement('button');
    button.id = BUTTON_ID;
    button.type = 'button';
    button.classList.add('is-floating');
    button.textContent = TEXT.download;
    button.title = TEXT.downloadTitle;
    button.addEventListener('click', handleDownloadClick);
    document.body.appendChild(button);

    return button;
  }

  function getOwnerActionPlacement() {
    for (const selector of OWNER_ACTION_ANCHOR_SELECTORS) {
      const matchedNode = document.querySelector(selector);
      if (!matchedNode) {
        continue;
      }

      const anchor =
        matchedNode.closest('#subscribe-button') ||
        matchedNode.closest('ytd-subscription-notification-toggle-button-renderer') ||
        matchedNode.closest('ytd-button-renderer') ||
        matchedNode;

      if (anchor.parentElement) {
        return {
          anchor,
          container: anchor.parentElement
        };
      }
    }

    for (const selector of OWNER_ACTION_ROW_SELECTORS) {
      const container = document.querySelector(selector);
      if (container) {
        return {
          anchor: container.lastElementChild,
          container
        };
      }
    }

    return null;
  }

  function setButtonState(label, disabled, title) {
    const downloadButton = ensureButton();
    downloadButton.textContent = label;
    downloadButton.disabled = disabled;
    if (title) {
      downloadButton.title = title;
    }
  }

  function embedButtonIntoOwnerActions() {
    const downloadButton = ensureButton();
    const downloadSlot = ensureSlot();
    const placement = getOwnerActionPlacement();

    if (!placement || !placement.container || window.location.hostname === 'm.youtube.com') {
      return false;
    }

    const { anchor, container } = placement;
    if (downloadSlot.parentElement !== container || downloadSlot.previousElementSibling !== anchor) {
      container.insertBefore(downloadSlot, anchor ? anchor.nextSibling : null);
    }

    if (downloadButton.parentElement !== downloadSlot) {
      downloadSlot.appendChild(downloadButton);
    }

    downloadSlot.classList.remove('is-hidden');
    downloadButton.classList.remove('is-floating');
    downloadButton.classList.add('is-embedded');

    return true;
  }

  function floatButtonOnPage() {
    const downloadButton = ensureButton();
    const downloadSlot = ensureSlot();

    downloadSlot.classList.add('is-hidden');

    if (downloadButton.parentElement !== document.body) {
      document.body.appendChild(downloadButton);
    }

    downloadButton.classList.remove('is-embedded');
    downloadButton.classList.add('is-floating');
  }

  function updateButtonPlacement() {
    const downloadButton = ensureButton();
    const downloadSlot = ensureSlot();
    const videoId = getVideoId();

    if (!videoId) {
      downloadButton.classList.add('is-hidden');
      downloadSlot.classList.add('is-hidden');
      return;
    }

    downloadButton.classList.remove('is-hidden');
    if (embedButtonIntoOwnerActions()) {
      return;
    }

    floatButtonOnPage();
  }

  function triggerBrowserDownload(url, filename) {
    return fetch(url)
      .then((response) => {
        if (!response.ok) {
          throw new Error(`Failed to fetch thumbnail: ${response.status}`);
        }
        return response.blob();
      })
      .then((blob) => {
        const objectUrl = URL.createObjectURL(blob);
        const anchor = document.createElement('a');
        anchor.href = objectUrl;
        anchor.download = filename;
        document.body.appendChild(anchor);
        anchor.click();
        anchor.remove();
        window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
      });
  }

  function openThumbnailInNewTab(url) {
    const openedWindow = window.open(url, '_blank', 'noopener');
    if (!openedWindow) {
      throw new Error('Unable to open thumbnail preview in a new tab.');
    }
  }

  function downloadWithTampermonkey(url, filename) {
    return new Promise((resolve, reject) => {
      if (typeof GM_download === 'function') {
        GM_download({
          url,
          name: filename,
          saveAs: false,
          onload: resolve,
          onerror: reject,
          ontimeout: () => reject(new Error('Download timed out.'))
        });
        return;
      }

      if (typeof GM !== 'undefined' && GM && typeof GM.download === 'function') {
        Promise.resolve(GM.download({ url, name: filename, saveAs: false }))
          .then(resolve)
          .catch(reject);
        return;
      }

      if (typeof GM_download !== 'function') {
        reject(new Error('GM_download is not available.'));
        return;
      }
    });
  }

  function setBusyState(active, videoId = '') {
    isBusy = active;
    busyVideoId = active ? videoId : '';
  }

  async function handleDownloadClick() {
    const videoId = getVideoId();
    if (!videoId) {
      setButtonState(TEXT.notVideo, false, TEXT.notVideoTitle);
      return;
    }

    setBusyState(true, videoId);
    setButtonState(TEXT.resolving, true, TEXT.resolvingTitle);

    try {
      const bestThumbnail = await resolveBestThumbnail(videoId);
      if (!bestThumbnail) {
        setBusyState(false);
        setButtonState(TEXT.unavailable, false, TEXT.unavailableTitle);
        return;
      }

      const title = sanitizeFileName(getVideoTitle());
      const channelName = sanitizeFileName(getChannelName());
      const filenameBase = channelName ? `${channelName}_${title}` : title;
      const filename = `${filenameBase}.${bestThumbnail.format}`;
      let openedInNewTab = false;

      setButtonState(TEXT.downloading, true, `\u6b63\u5728\u4e0b\u8f7d ${bestThumbnail.width}x${bestThumbnail.height} \u5c01\u9762`);

      try {
        await downloadWithTampermonkey(bestThumbnail.url, filename);
      } catch (tampermonkeyError) {
        console.warn('[YT Thumbnail Downloader] GM_download failed, falling back to fetch:', tampermonkeyError);
        try {
          await triggerBrowserDownload(bestThumbnail.url, filename);
        } catch (browserError) {
          console.warn('[YT Thumbnail Downloader] Fetch download failed, opening thumbnail in new tab:', browserError);
          openThumbnailInNewTab(bestThumbnail.url);
          openedInNewTab = true;
        }
      }

      setBusyState(false);
      if (openedInNewTab) {
        setButtonState(TEXT.opened, false, TEXT.openedTitle);
      } else {
        setButtonState(TEXT.downloaded, false, `\u5df2\u4e0b\u8f7d\u6700\u9ad8\u753b\u8d28\u5c01\u9762: ${bestThumbnail.width}x${bestThumbnail.height}`);
      }
      window.setTimeout(() => {
        if (getVideoId() === videoId) {
          setButtonState(TEXT.download, false, TEXT.downloadTitle);
        }
      }, 1800);
    } catch (error) {
      console.error('[YT Thumbnail Downloader] Download failed:', error);
      setBusyState(false);
      setButtonState(TEXT.failed, false, TEXT.failedTitle);
    }
  }

  function warmThumbnailCache() {
    const videoId = getVideoId();
    if (!videoId) {
      return;
    }

    resolveBestThumbnail(videoId)
      .then((bestThumbnail) => {
        if (!bestThumbnail || getVideoId() !== videoId || (isBusy && busyVideoId === videoId)) {
          return;
        }

        setButtonState(
          TEXT.download,
          false,
          `\u5f53\u524d\u53ef\u7528\u6700\u9ad8\u753b\u8d28: ${bestThumbnail.width}x${bestThumbnail.height}`
        );
      })
      .catch((error) => {
        console.warn('[YT Thumbnail Downloader] Thumbnail probing failed:', error);
      });
  }

  function refreshButton() {
    updateButtonPlacement();

    if (!isVideoPage()) {
      return;
    }

    warmThumbnailCache();
  }

  function scheduleRefresh() {
    window.clearTimeout(refreshTimer);
    refreshTimer = window.setTimeout(() => {
      if (window.location.href !== currentUrl) {
        currentUrl = window.location.href;
      }
      refreshButton();
    }, 120);
  }

  function setupObservers() {
    const observer = new MutationObserver(() => {
      scheduleRefresh();
    });

    observer.observe(document.documentElement, {
      childList: true,
      subtree: true
    });

    window.addEventListener('yt-navigate-finish', scheduleRefresh, true);
    window.addEventListener('yt-page-data-updated', scheduleRefresh, true);
    window.addEventListener('popstate', scheduleRefresh, true);
  }

  function init() {
    currentUrl = window.location.href;
    ensureButton();
    refreshButton();
    setupObservers();
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init, { once: true });
  } else {
    init();
  }
})();