FetLife: Download Image (stable)

Always shows a download button for the active high-res picture with a nice filename.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         FetLife: Download Image (stable)
// @namespace    http://tampermonkey.net/
// @version      1.4
// @description  Always shows a download button for the active high-res picture with a nice filename.
// @match        https://fetlife.com/*/pictures/*
// @match        https://fetlife.com/pictures/*
// @icon         https://fetlife.com/favicons/favicon.ico
// @license      GPL-3.0
// @grant        GM_download
// @run-at       document-idle
// @noframes
// ==/UserScript==

(function () {
  'use strict';

  /** Get the active picture IMG element inside the carousel */
  function getMainImage() {
    // Prefer the active slide image if present
    const active = document.querySelector('main article .splide__slide.is-active img');
    if (active) return active;
    // Fallbacks, just in case markup shifts
    return document.querySelector('main article img.object-contain')
        || document.querySelector('main article img[alt^="Picture"]')
        || document.querySelector('main article img');
  }

  /** Parse srcset and return the highest-multiplier URL, falling back to src */
  function getHighResUrl(img) {
    if (!img) return '';
    if (img.srcset) {
      let best = '';
      let bestX = 0;
      for (const cand of img.srcset.split(',')) {
        const [url, desc] = cand.trim().split(/\s+/);
        if (desc && /x$/i.test(desc)) {
          const mult = parseFloat(desc);
          if (mult > bestX) {
            bestX = mult;
            best = url;
          }
        } else if (desc && /w$/i.test(desc)) {
          // If they ever switch to width descriptors, prefer the largest
          const width = parseFloat(desc);
          if (width > bestX) {
            bestX = width;
            best = url;
          }
        }
      }
      return best || img.src || '';
    }
    return img.src || '';
  }

  /** Build a username_pictureId.ext filename from URL + location */
  function getCustomFilename(highResUrl) {
    try {
      const u = new URL(highResUrl);
      const last = u.pathname.split('/').pop() || '';
      const dot = last.lastIndexOf('.');
      const ext = dot !== -1 ? last.slice(dot) : '.jpg';

      const parts = window.location.pathname.split('/').filter(Boolean);
      // expect: /<username>/pictures/<pictureId>
      const username = parts[0] || 'user';
      const pictureId = parts[2] || 'image';
      return `${username}_${pictureId}${ext}`;
    } catch {
      return 'image.jpg';
    }
  }

  /** Create (or update) the download button in a stable place */
  function upsertButton(img) {
    if (!img) return;

    // Make sure pointer events aren’t disabled on the image
    img.style.setProperty('pointer-events', 'auto', 'important');

    const article = img.closest('article');
    if (!article) return;

    let btn = document.getElementById('downloadImageButton');
    const curSrc = img.currentSrc || img.src;

    if (btn && btn.dataset.currentImageSrc !== curSrc) {
      btn.remove();
      btn = null;
    }

    if (!btn) {
      btn = document.createElement('button');
      btn.id = 'downloadImageButton';
      btn.type = 'button';
      btn.textContent = 'Download high-res image';
      btn.style.display = 'block';
      btn.style.margin = '16px auto';
      btn.style.padding = '8px 12px';
      btn.style.fontSize = '14px';
      btn.style.cursor = 'pointer';
      btn.style.zIndex = '9999';

      // Insert the button in a stable place: right above the article footer.
      const footer = article.querySelector(':scope > footer');
      if (footer) {
        footer.insertAdjacentElement('beforebegin', btn);
      } else {
        // Fallback under the image
        (img.parentElement || article).insertAdjacentElement('beforeend', btn);
      }

      btn.addEventListener('click', () => {
        const url = getHighResUrl(img);
        const name = getCustomFilename(url);

        // Try GM_download first
        try {
          GM_download({
            url,
            name,
            // Tampermonkey ignores custom headers for GM_download, so don't rely on them.
            onerror: () => {
              // Fallback: open in a new tab so the browser sends a normal Referer
              const win = window.open(url, '_blank');
              if (!win) alert('Popup blocked. Please allow popups or click again.');
            },
          });
        } catch (e) {
          // As a safety net, open the URL
          const win = window.open(url, '_blank');
          if (!win) alert('Popup blocked. Please allow popups or click again.');
        }
      });
    }

    // Track the current image used by the button
    btn.dataset.currentImageSrc = curSrc;
  }

  /** Main driver: find image + ensure button exists */
  function process() {
    const img = getMainImage();
    if (img) upsertButton(img);
  }

  // Initial run
  process();

  // Observe SPA route/rerenders, but throttle to animation frames
  let scheduled = false;
  const observer = new MutationObserver(() => {
    if (!scheduled) {
      scheduled = true;
      requestAnimationFrame(() => {
        scheduled = false;
        process();
      });
    }
  });
  observer.observe(document.body, { childList: true, subtree: true });

  // Also hook history changes (some SPAs don’t mutate the whole body)
  for (const method of ['pushState', 'replaceState']) {
    const orig = history[method];
    history[method] = function () {
      const ret = orig.apply(this, arguments);
      setTimeout(process, 0);
      return ret;
    };
  }
  window.addEventListener('popstate', () => setTimeout(process, 0));
})();