MyFans Downloader

Scan and download subscribed MyFans plan videos.

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name         MyFans Downloader
// @namespace    https://github.com/myfans-downloader
// @version      0.1.6
// @description  Scan and download subscribed MyFans plan videos.
// @author       myfans-downloader
// @match        https://myfans.jp/*
// @license      MIT
// @grant        GM_xmlhttpRequest
// @connect      api.myfans.jp
// @connect      content.mfcdn.jp
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/mux.min.js
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  const API_BASE = 'https://api.myfans.jp/api';
  const DEFAULT_KEYWORDS = 'demo,sample,preview,teaser,trial,サンプル,プレビュー,予告,トレーラー,無料,お試し,日常';
  const RESERVED_ROUTES = new Set([
    'account', 'auth', 'award_pages', 'events', 'external_notifications', 'feature',
    'feed', 'gachas', 'genres', 'identify', 'messages', 'otoshidama-creators',
    'passwords', 'payment_failure', 'payments', 'policies', 'posts', 'privacy',
    'ranking', 's', 'sign_in', 'sign_up', 'terms', 'tokenize', 'trade', 'unlimited'
  ]);

  const STYLES = `
    #mfd-fab {
      position: fixed; right: 24px; bottom: 24px; z-index: 2147483647;
      width: 52px; height: 52px; border: 0; border-radius: 50%;
      background: linear-gradient(135deg, #0f766e, #2563eb);
      color: #fff; cursor: pointer; box-shadow: 0 8px 24px rgba(15,118,110,.36);
      display: flex; align-items: center; justify-content: center;
      font: 700 18px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    }
    #mfd-fab:hover { transform: translateY(-1px); }
    #mfd-panel {
      position: fixed; right: 24px; bottom: 88px; z-index: 2147483646;
      width: 430px; max-height: 86vh; overflow-y: auto; display: none;
      background: #14171f; color: #d9e2f2; border: 1px solid #2b3140;
      border-radius: 12px; padding: 16px; box-shadow: 0 12px 40px rgba(0,0,0,.48);
      font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    }
    #mfd-panel.open { display: block; }
    #mfd-panel h3 { margin: 0 0 12px; font-size: 16px; color: #7dd3fc; }
    #mfd-panel label { display: block; margin: 0 0 4px; color: #9aa8bd; font-size: 12px; }
    #mfd-panel input[type=text], #mfd-panel input[type=number], #mfd-panel select {
      width: 100%; box-sizing: border-box; padding: 7px 9px; margin: 0 0 10px;
      background: #1f2430; color: #d9e2f2; border: 1px solid #394252; border-radius: 7px;
      font-size: 13px;
    }
    #mfd-panel input:focus, #mfd-panel select:focus { outline: none; border-color: #38bdf8; }
    .mfd-row { display: flex; gap: 8px; align-items: flex-start; }
    .mfd-row > div { flex: 1; min-width: 0; }
    .mfd-check { display: flex; gap: 8px; align-items: center; margin: 0 0 10px; color: #cbd5e1; }
    .mfd-check input { accent-color: #0f766e; }
    .mfd-btn {
      width: 100%; padding: 8px 10px; border-radius: 7px; border: 1px solid transparent;
      cursor: pointer; font-weight: 650; font-size: 13px;
    }
    .mfd-btn:disabled { opacity: .45; cursor: not-allowed; }
    .mfd-btn-primary { background: linear-gradient(135deg, #0f766e, #2563eb); color: #fff; }
    .mfd-btn-green { background: #16a34a; color: #fff; }
    .mfd-btn-outline { background: transparent; color: #d9e2f2; border-color: #394252; }
    .mfd-btn-sm { width: auto; padding: 5px 8px; font-size: 11px; }
    #mfd-status { min-height: 18px; margin: 10px 0 4px; color: #9aa8bd; font-size: 12px; }
    #mfd-stats { min-height: 18px; color: #9aa8bd; font-size: 12px; }
    #mfd-progress-wrap { display: none; height: 6px; overflow: hidden; border-radius: 4px; background: #253044; margin: 10px 0; }
    #mfd-progress-bar { height: 100%; width: 0%; background: linear-gradient(90deg, #14b8a6, #38bdf8); }
    #mfd-results { display: none; margin-top: 12px; }
    #mfd-results.show { display: block; }
    .mfd-select-bar { display: flex; gap: 6px; align-items: center; margin: 8px 0; }
    .mfd-select-bar span { margin-left: auto; color: #9aa8bd; font-size: 11px; }
    #mfd-list { max-height: 300px; overflow: auto; border: 1px solid #2b3140; border-radius: 8px; }
    #mfd-list table { width: 100%; border-collapse: collapse; table-layout: fixed; }
    #mfd-list th { position: sticky; top: 0; background: #202838; color: #9aa8bd; padding: 5px; text-align: left; }
    #mfd-list td { padding: 5px; border-top: 1px solid #252b38; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    #mfd-list tr:hover { background: #1b2230; }
    #mfd-list input[type=checkbox] { accent-color: #0f766e; }
    .mfd-status.ok { color: #22c55e; }
    .mfd-status.err { color: #fb7185; }
    .mfd-status.busy { color: #fbbf24; }
    .mfd-muted { color: #9aa8bd; }
    .mfd-actions { display: flex; gap: 8px; margin-top: 10px; }
    .mfd-actions .mfd-btn { flex: 1; }
  `;

  let results = { items: [] };
  let selectedIndices = new Set();
  let scanning = false;
  let downloading = false;
  let abortController = null;
  let filenameCounts = {};
  let bridgeSeq = 0;

  function injectStyles() {
    if (document.getElementById('mfd-styles')) return;
    const style = document.createElement('style');
    style.id = 'mfd-styles';
    style.textContent = STYLES;
    document.head.appendChild(style);
  }

  function escapeHtml(value) {
    return String(value ?? '').replace(/[&<>"']/g, ch => ({
      '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
    }[ch]));
  }

  function sanitizeFilename(value) {
    return String(value || 'item')
      .replace(/[\\/:*?"<>|]+/g, '_')
      .replace(/[()()]+/g, '_')
      .replace(/\s+/g, ' ')
      .replace(/_+/g, '_')
      .replace(/^[ _]+|[ _]+$/g, '')
      .slice(0, 120) || 'item';
  }

  function resetFilenameCounts() {
    filenameCounts = {};
  }

  function shortId(value) {
    const raw = String(value || '').replace(/[^a-zA-Z0-9]/g, '');
    return raw.slice(0, 8) || 'item';
  }

  function extractFileId(post, fallback) {
    const keys = [
      'content_id', 'contentId', 'post_id', 'postId', 'display_id', 'displayId',
      'serial_id', 'serialId', 'legacy_id', 'legacyId', 'number', 'id'
    ];
    for (const key of keys) {
      const value = post?.[key];
      if (value !== undefined && value !== null && /^\d+$/.test(String(value))) return String(value);
    }
    return shortId(fallback || post?.id || post?.uuid);
  }

  function creatorFromUrl() {
    const parts = location.pathname.split('/').filter(Boolean);
    if (!parts[0] || RESERVED_ROUTES.has(parts[0])) return '';
    return parts[0];
  }

  function planIdFromUrl() {
    const match = location.pathname.match(/^\/[^/]+\/plans\/([0-9a-f-]{20,})/i);
    return match ? match[1] : '';
  }

  function postIdFromUrl() {
    const match = location.pathname.match(/^\/posts\/([0-9a-f-]{20,})/i);
    return match ? match[1] : '';
  }

  function getCookieValue(name) {
    const parts = document.cookie.split(';').map(part => part.trim());
    const hit = parts.find(part => part.startsWith(name + '='));
    if (!hit) return '';
    return decodeURIComponent(hit.slice(name.length + 1));
  }

  function authHeaders() {
    const token = getCookieValue('_mfans_token');
    const headers = {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      'X-Mf-Locale': 'ja',
      'x-vercel-env': 'production',
      'google-ga-data': 'event328'
    };
    if (token) headers.Authorization = `Token token=${token}`;
    return headers;
  }

  function setupPageBridge() {
    if (document.getElementById('mfd-page-bridge')) return;
    const script = document.createElement('script');
    script.id = 'mfd-page-bridge';
    script.textContent = `
      (() => {
        if (window.__mfdApiBridge) return;
        window.__mfdApiBridge = true;

        const cookieValue = (name) => {
          const hit = document.cookie.split(';').map(v => v.trim()).find(v => v.startsWith(name + '='));
          return hit ? decodeURIComponent(hit.slice(name.length + 1)) : '';
        };

        const defaultHeaders = () => {
          const token = cookieValue('_mfans_token');
          const headers = {
            'Accept': 'application/json',
            'Content-Type': 'application/json',
            'X-Mf-Locale': 'ja',
            'x-vercel-env': 'production',
            'google-ga-data': 'event328'
          };
          if (token) headers.Authorization = 'Token token=' + token;
          return headers;
        };

        window.addEventListener('message', (event) => {
          if (event.source !== window) return;
          const msg = event.data;
          if (!msg || msg.type !== 'MFD_API_REQUEST' || !msg.id || !msg.url) return;
          if (!/^https:\\/\\/api\\.myfans\\.jp\\/api\\//.test(msg.url)) {
            window.postMessage({ type: 'MFD_API_RESPONSE', id: msg.id, ok: false, status: 0, error: 'Blocked URL' }, '*');
            return;
          }

          const xhr = new XMLHttpRequest();
          xhr.open(msg.method || 'GET', msg.url, true);
          xhr.responseType = msg.responseType || 'text';
          xhr.withCredentials = true;
          const headers = { ...defaultHeaders(), ...(msg.headers || {}) };
          for (const [key, value] of Object.entries(headers)) {
            try { xhr.setRequestHeader(key, value); } catch (_) {}
          }
          xhr.onload = () => {
            window.postMessage({
              type: 'MFD_API_RESPONSE',
              id: msg.id,
              ok: xhr.status >= 200 && xhr.status < 300,
              status: xhr.status,
              statusText: xhr.statusText,
              response: xhr.response
            }, '*');
          };
          xhr.onerror = () => window.postMessage({ type: 'MFD_API_RESPONSE', id: msg.id, ok: false, status: xhr.status || 0, error: 'XHR failed' }, '*');
          xhr.onabort = () => window.postMessage({ type: 'MFD_API_RESPONSE', id: msg.id, ok: false, status: xhr.status || 0, error: 'XHR aborted' }, '*');
          xhr.send(msg.body || null);
        });
      })();
    `;
    document.documentElement.appendChild(script);
    script.remove();
  }

  function pageApiRequest(url, options = {}) {
    setupPageBridge();
    const id = `mfd-${Date.now()}-${++bridgeSeq}`;
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        window.removeEventListener('message', onMessage);
        reject(new Error(`API bridge timeout: ${url}`));
      }, options.timeout || 60000);

      function onMessage(event) {
        if (event.source !== window) return;
        const msg = event.data;
        if (!msg || msg.type !== 'MFD_API_RESPONSE' || msg.id !== id) return;
        clearTimeout(timeout);
        window.removeEventListener('message', onMessage);
        if (msg.error) {
          reject(new Error(`${msg.error}: ${url}`));
        } else {
          resolve(msg);
        }
      }

      window.addEventListener('message', onMessage);
      window.postMessage({
        type: 'MFD_API_REQUEST',
        id,
        url,
        method: options.method || 'GET',
        headers: options.headers || {},
        responseType: options.responseType || 'text',
        body: options.body || null
      }, '*');
    });
  }

  function xhrRequest(url, options = {}) {
    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open(options.method || 'GET', url, true);
      xhr.responseType = options.responseType || 'text';
      xhr.withCredentials = options.withCredentials !== false;
      for (const [key, value] of Object.entries(options.headers || {})) {
        xhr.setRequestHeader(key, value);
      }
      xhr.onload = () => {
        resolve({
          ok: xhr.status >= 200 && xhr.status < 300,
          status: xhr.status,
          statusText: xhr.statusText,
          response: xhr.response
        });
      };
      xhr.onerror = () => reject(new Error(`XHR failed: ${url}`));
      xhr.onabort = () => reject(new Error(options.signal?.aborted ? 'Scan stopped.' : 'XHR aborted'));
      if (options.signal) {
        options.signal.addEventListener('abort', () => {
          try { xhr.abort(); } catch (_) {}
        }, { once: true });
      }
      xhr.send(options.body || null);
    });
  }

  function gmRequest(url, options = {}) {
    return new Promise((resolve, reject) => {
      if (typeof GM_xmlhttpRequest !== 'function') {
        reject(new Error('GM_xmlhttpRequest is unavailable'));
        return;
      }
      if (options.signal?.aborted) {
        reject(new Error('Scan stopped.'));
        return;
      }
      let requestHandle = null;
      const abortRequest = () => {
        try { requestHandle?.abort?.(); } catch (_) {}
      };
      const cleanup = () => {
        if (options.signal) options.signal.removeEventListener('abort', abortRequest);
      };
      if (options.signal) options.signal.addEventListener('abort', abortRequest, { once: true });
      requestHandle = GM_xmlhttpRequest({
        method: options.method || 'GET',
        url,
        headers: options.headers || {},
        data: options.body || undefined,
        responseType: options.responseType || 'text',
        timeout: options.timeout || 60000,
        anonymous: options.anonymous === true,
        onload: resp => {
          cleanup();
          resolve({
            ok: resp.status >= 200 && resp.status < 300,
            status: resp.status,
            statusText: resp.statusText,
            response: resp.response
          });
        },
        onerror: err => {
          cleanup();
          reject(new Error(`GM request failed: ${url} ${err?.error || ''}`.trim()));
        },
        ontimeout: () => {
          cleanup();
          reject(new Error(`GM request timeout: ${url}`));
        },
        onabort: () => {
          cleanup();
          reject(new Error('Scan stopped.'));
        }
      });
    });
  }

  function request(url, options = {}) {
    if (typeof GM_xmlhttpRequest === 'function') {
      return gmRequest(url, options);
    }
    return xhrRequest(url, options);
  }

  function throwIfAborted(signal) {
    if (signal?.aborted) throw new Error('Scan stopped.');
  }

  function isScanStopped(err) {
    return String(err?.message || err) === 'Scan stopped.';
  }

  async function apiFetch(path, params, signal) {
    throwIfAborted(signal);
    const url = new URL(path.replace(/^\//, ''), API_BASE.endsWith('/') ? API_BASE : API_BASE + '/');
    if (params) {
      Object.entries(params).forEach(([key, value]) => {
        if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, value);
      });
    }
    const resp = await request(url.toString(), {
      headers: authHeaders(),
      timeout: 60000,
      signal
    });
    throwIfAborted(signal);
    const text = String(resp.response || '');
    let json = null;
    if (text) {
      try { json = JSON.parse(text); } catch (_) { json = null; }
    }
    if (!resp.ok) {
      const detail = json?.message || json?.error || text.slice(0, 160) || resp.statusText;
      throw new Error(`API ${resp.status}: ${detail}`);
    }
    return json;
  }

  function arrayFromPayload(payload, names) {
    if (Array.isArray(payload)) return payload;
    const root = payload?.data ?? payload;
    if (Array.isArray(root)) return root;
    for (const name of names) {
      if (Array.isArray(root?.[name])) return root[name];
      if (Array.isArray(root?.data?.[name])) return root.data[name];
    }
    return [];
  }

  function postsFromPayload(payload) {
    return arrayFromPayload(payload, ['posts', 'items', 'results', 'list']);
  }

  function nextPageFromPayload(payload) {
    const root = payload?.data ?? payload;
    return root?.pagination?.next
      ?? root?.pagination?.next_page
      ?? root?.meta?.next
      ?? root?.meta?.next_page
      ?? payload?.pagination?.next
      ?? null;
  }

  function firstTitleLine(value) {
    const lines = String(value || '')
      .replace(/\r/g, '\n')
      .split('\n')
      .map(line => line.trim())
      .filter(Boolean)
      .filter(line => !/^https?:\/\//i.test(line))
      .filter(line => !/^※/.test(line));
    const first = lines[0] || String(value || '').trim();
    return first.split(' | ')[0].trim();
  }

  function postTitle(post) {
    return choosePostTitle(post).title;
  }

  function cleanTitle(value) {
    return firstTitleLine(value);
  }

  function normalizeTitleKey(value) {
    return String(value || '')
      .normalize('NFKC')
      .replace(/^\s*【[^】]*限定[^】]*】\s*/u, '')
      .replace(/^\s*\[[^\]]*限定[^\]]*\]\s*/u, '')
      .replace(/\s+/g, '')
      .toLowerCase();
  }

  function isGenericPlanTitle(value) {
    return /プラン.*です[!!。]?$/u.test(String(value || ''));
  }

  function titleScore(value) {
    const title = cleanTitle(value);
    if (!title) return -1000;
    let score = Math.min(title.length, 90);
    if (/【[^】]+】/u.test(title)) score += 18;
    if (/限定/u.test(title)) score += 24;
    if (/未公開|先行/u.test(title)) score += 20;
    if (isGenericPlanTitle(title)) score -= 55;
    if (/^(MyFans|マイファンズ)$/i.test(title)) score -= 120;
    if (title.length < 6) score -= 40;
    return score;
  }

  function titleCandidatesFromPost(post) {
    const candidates = [];
    const add = (source, value) => {
      const title = cleanTitle(value);
      if (title) candidates.push({ source, title, score: titleScore(title) });
    };
    const explicit = [
      'displayTitle', 'display_title', 'postTitle', 'post_title', 'headline',
      'subject', 'name', 'title', 'body', 'content', 'description', 'text'
    ];
    for (const key of explicit) add(key, post?.[key]);

    const nested = [
      ['plan.title', post?.plan?.title],
      ['plan.name', post?.plan?.name],
      ['product.title', post?.product?.title],
      ['product.name', post?.product?.name],
      ['article.title', post?.article?.title],
      ['article.name', post?.article?.name]
    ];
    for (const [source, value] of nested) add(source, value);

    const seen = new Set();
    return candidates.filter(item => {
      const key = `${item.source}:${item.title}`;
      if (seen.has(key)) return false;
      seen.add(key);
      return true;
    });
  }

  function choosePostTitle(post, fallback) {
    const candidates = titleCandidatesFromPost(post);
    if (fallback) candidates.push({ source: 'fallback', title: cleanTitle(fallback), score: titleScore(fallback) });
    if (!candidates.length) return { title: cleanTitle(`post_${post?.id || ''}`), source: 'fallback', candidates: [] };
    const best = candidates.slice().sort((a, b) => b.score - a.score)[0];
    return { title: best.title, source: best.source, candidates };
  }

  function preferTitle(base, extra) {
    const current = cleanTitle(base?.title || base || '');
    const next = cleanTitle(extra?.title || extra || '');
    if (!next) return base;
    if (!current) return typeof extra === 'string' ? { title: next, source: 'extra' } : extra;
    const sameBase = normalizeTitleKey(current) === normalizeTitleKey(next);
    const nextHasLimitedPrefix = /^【[^】]*限定[^】]*】/u.test(next) || /^\[[^\]]*限定[^\]]*\]/u.test(next);
    const currentHasLimitedPrefix = /^【[^】]*限定[^】]*】/u.test(current) || /^\[[^\]]*限定[^\]]*\]/u.test(current);
    if (sameBase && nextHasLimitedPrefix && !currentHasLimitedPrefix) {
      return typeof extra === 'string' ? { title: next, source: 'extra' } : extra;
    }
    const nextScore = titleScore(next);
    const currentScore = titleScore(current);
    if (nextScore > currentScore + 12) return typeof extra === 'string' ? { title: next, source: 'extra' } : extra;
    return typeof base === 'string' ? { title: current, source: 'base' } : base;
  }

  function extractPostObject(payload) {
    const root = payload?.data ?? payload;
    return root?.post ?? root?.item ?? root?.result ?? root;
  }

  function cleanPageTitle(value) {
    return cleanTitle(String(value || '')
      .replace(/\s*[-|]\s*MyFans.*$/i, '')
      .replace(/\s*[-|]\s*マイファンズ.*$/i, ''));
  }

  async function fetchPostPageTitle(postId, signal) {
    throwIfAborted(signal);
    const resp = await fetch(`/posts/${encodeURIComponent(postId)}`, {
      credentials: 'include',
      headers: { 'Accept': 'text/html,application/xhtml+xml' },
      signal
    });
    throwIfAborted(signal);
    if (!resp.ok) throw new Error(`Page ${resp.status}`);
    const html = await resp.text();
    const doc = new DOMParser().parseFromString(html, 'text/html');
    const metaTitle = doc.querySelector('meta[property="og:title"], meta[name="twitter:title"]')?.getAttribute('content');
    return cleanPageTitle(metaTitle || doc.querySelector('title')?.textContent || '');
  }

  function titleFromVisibleCard(card, apiTitle) {
    if (!card) return '';
    const lines = String(card.textContent || '')
      .replace(/\r/g, '\n')
      .split('\n')
      .map(line => cleanTitle(line))
      .filter(Boolean)
      .filter(line => !/^\d+:\d{2}/.test(line))
      .filter(line => !/^(いいね|コメント|シェア|保存|購入|動画|画像|もっと見る)$/u.test(line));
    const limited = lines.find(line => /^【[^】]*限定[^】]*】/u.test(line) || /^\[[^\]]*限定[^\]]*\]/u.test(line));
    if (limited) return limited;
    const apiKey = normalizeTitleKey(apiTitle);
    if (apiKey) {
      const same = lines.find(line => normalizeTitleKey(line).includes(apiKey) || apiKey.includes(normalizeTitleKey(line)));
      if (same) return same;
    }
    return lines.sort((a, b) => titleScore(b) - titleScore(a))[0] || '';
  }

  function visiblePostTitles() {
    const posts = collectVisiblePosts();
    const map = new Map();
    for (const [postId, post] of posts) {
      if (post.visibleTitle) map.set(postId, post.visibleTitle);
    }
    return map;
  }

  function durationFromText(text) {
    const match = String(text || '').match(/(?:^|\n)\s*(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\s*(?:\n|$)/);
    if (!match) return 0;
    const hours = Number(match[1] || 0);
    const minutes = Number(match[2] || 0);
    const seconds = Number(match[3] || 0);
    return hours * 3600 + minutes * 60 + seconds;
  }

  function collectVisiblePosts() {
    const map = new Map();
    const links = Array.from(document.querySelectorAll('a[href*="/posts/"]'));
    for (const link of links) {
      const match = String(link.href || link.getAttribute('href') || '').match(/\/posts\/([0-9a-f-]{20,})/i);
      if (!match || map.has(match[1])) continue;
      let card = link;
      let hops = 0;
      while (card && card !== document.body) {
        const text = String(card.textContent || '');
        const postLinks = card.querySelectorAll?.('a[href*="/posts/"]').length || 0;
        if (text.length >= 8 && text.length <= 1800 && postLinks <= 3) break;
        card = card.parentElement;
        hops += 1;
        if (hops >= 8) break;
      }
      const text = String(card?.textContent || link.textContent || '').trim();
      const title = titleFromVisibleCard(card, link.textContent || '');
      if (title || text) {
        map.set(match[1], {
          postId: match[1],
          postUrl: `https://myfans.jp/posts/${match[1]}`,
          visibleTitle: title,
          visibleText: text,
          visibleDuration: durationFromText(text),
          hasBracketLimited: text.includes('【限定】'),
          hasAnyLimited: text.includes('限定'),
          hasUnpublishedFirst: text.includes('未公開先行版')
        });
      }
    }
    return map;
  }

  async function autoLoadVisiblePosts(onProgress, signal) {
    let stable = 0;
    let lastHeight = 0;
    for (let i = 0; i < 80; i++) {
      throwIfAborted(signal);
      window.scrollTo(0, document.body.scrollHeight);
      await new Promise(resolve => setTimeout(resolve, 800));
      const height = document.body.scrollHeight;
      const count = document.querySelectorAll('a[href*="/posts/"]').length;
      if (onProgress && i % 4 === 0) onProgress(`Loading visible posts (${count} links)...`);
      if (height === lastHeight) stable += 1;
      else stable = 0;
      lastHeight = height;
      if (stable >= 6) break;
    }
  }

  function extractDurationSec(obj) {
    const candidates = [
      obj?.duration,
      obj?.duration_sec,
      obj?.duration_seconds,
      obj?.duration_s,
      obj?.video_duration,
      obj?.attachment_length,
      obj?.metadata?.duration,
      obj?.metadata?.video?.duration,
      obj?.metadata?.video?.duration_sec
    ];
    for (const value of candidates) {
      const n = Number(value);
      if (Number.isFinite(n) && n > 0) return n;
    }
    const msCandidates = [
      obj?.duration_ms,
      obj?.metadata?.duration_ms,
      obj?.metadata?.video?.duration_ms
    ];
    for (const value of msCandidates) {
      const n = Number(value);
      if (Number.isFinite(n) && n > 0) return n / 1000;
    }
    return 0;
  }

  function normalizeVideos(payload) {
    const root = payload?.data ?? payload;
    const videos = [];
    const pushGroup = (source, arr) => {
      if (!Array.isArray(arr)) return;
      for (const item of arr) {
        if (item && item.url) videos.push({ ...item, source });
      }
    };
    pushGroup('main', root?.main);
    pushGroup('trial', root?.trial);
    pushGroup('main', root?.videos);
    pushGroup('main', root?.items);
    pushGroup('main', root?.data);
    if (Array.isArray(root)) pushGroup('main', root);
    return videos;
  }

  function resolutionValue(video) {
    const resolution = String(video?.resolution || '');
    const match = resolution.match(/(\d{3,4})/);
    if (match) return Number(match[1]);
    const height = Number(video?.height);
    if (Number.isFinite(height)) return height;
    return 0;
  }

  function chooseVideo(videos, quality) {
    const usable = videos.filter(v => v.url && v.source !== 'trial');
    const pool = usable.length ? usable : videos.filter(v => v.url);
    if (!pool.length) return null;

    if (quality && quality !== 'best') {
      const target = Number(quality.replace(/\D/g, ''));
      const exact = pool.find(v => resolutionValue(v) === target);
      if (exact) return exact;
      const below = pool.filter(v => resolutionValue(v) > 0 && resolutionValue(v) <= target)
        .sort((a, b) => resolutionValue(b) - resolutionValue(a))[0];
      if (below) return below;
    }

    return pool.slice().sort((a, b) => {
      const byResolution = resolutionValue(b) - resolutionValue(a);
      if (byResolution) return byResolution;
      return (Number(b.bitrate) || 0) - (Number(a.bitrate) || 0);
    })[0];
  }

  async function fetchPostTitleDetails(postId, skipDetailApi = false, signal) {
    throwIfAborted(signal);
    const details = {
      detailTitle: '',
      detailTitleSource: '',
      pageTitle: ''
    };

    if (!skipDetailApi) {
      try {
        const payload = await apiFetch(`/v2/posts/${postId}`, null, signal);
        const picked = choosePostTitle(extractPostObject(payload));
        details.detailTitle = picked.title;
        details.detailTitleSource = picked.source;
      } catch (err) {
        if (isScanStopped(err)) throw err;
        details.detailError = String(err.message || err).slice(0, 120);
      }
    }

    try {
      details.pageTitle = await fetchPostPageTitle(postId, signal);
    } catch (err) {
      if (isScanStopped(err)) throw err;
      details.pageError = String(err.message || err).slice(0, 120);
    }

    return details;
  }

  function isDemoLike(post, video, options) {
    const duration = extractDurationSec(video) || extractDurationSec(post);
    if (duration > 0 && duration < options.minDuration) return true;
    const keywords = String(options.demoKeywords || '')
      .split(',')
      .map(v => v.trim().toLowerCase())
      .filter(Boolean);
    if (!keywords.length) return false;
    const haystack = [
      postTitle(post),
      post?.body,
      post?.content,
      post?.description,
      post?.text
    ].filter(Boolean).join(' ').toLowerCase();
    return keywords.some(keyword => haystack.includes(keyword));
  }

  function buildFilename(item) {
    const fileId = sanitizeFilename(item.fileId || item.postId || 'item');
    const title = sanitizeFilename(item.title || '');
    const stem = title ? `${fileId}_${title}` : fileId;
    const base = stem.slice(0, 150).replace(/_+$/g, '');
    filenameCounts[base] = (filenameCounts[base] || 0) + 1;
    return `${base}${filenameCounts[base] > 1 ? '_' + filenameCounts[base] : ''}.mp4`;
  }

  function formatDuration(sec) {
    if (!sec) return '-';
    const total = Math.round(sec);
    const h = Math.floor(total / 3600);
    const m = Math.floor((total % 3600) / 60);
    const s = total % 60;
    if (h) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
    return `${m}:${String(s).padStart(2, '0')}`;
  }

  function formatBytes(bytes) {
    if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
    if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
    return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
  }

  async function scanPlan(options, onProgress) {
    const items = [];
    const skipped = [];
    const seen = new Set();
    throwIfAborted(options.signal);
    if (options.includeVisiblePosts) {
      await autoLoadVisiblePosts(onProgress, options.signal);
    }
    const visiblePosts = collectVisiblePosts();
    let page = 1;
    let detailApiUnavailable = false;
    resetFilenameCounts();

    while (page && page <= options.maxPages) {
      throwIfAborted(options.signal);
      onProgress(`Scanning page ${page}...`);
      const payload = await apiFetch(`/v2/plans/${options.planId}/posts`, {
        kind: 'video',
        sort_key: options.sortKey,
        page
      }, options.signal);
      const posts = postsFromPayload(payload);
      if (!posts.length) break;

      for (let idx = 0; idx < posts.length; idx++) {
        throwIfAborted(options.signal);
        const post = posts[idx];
        const postId = post?.id || post?.post_id || post?.uuid;
        if (!postId || seen.has(postId)) continue;
        seen.add(postId);
        if (post.kind && post.kind !== 'video') continue;

        const postDur = extractDurationSec(post);
        if (options.skipDemos && postDur > 0 && isDemoLike(post, { duration: postDur }, options)) {
          skipped.push({ postId, reason: 'demo/short' });
          continue;
        }

        onProgress(`Fetching video ${items.length + 1} (${idx + 1}/${posts.length})...`);
        let videoPayload;
        try {
          videoPayload = await apiFetch(`/v2/posts/${postId}/videos`, null, options.signal);
        } catch (err) {
          if (isScanStopped(err)) throw err;
          skipped.push({ postId, reason: err.message });
          continue;
        }

        const videoChoices = normalizeVideos(videoPayload);
        const chosen = chooseVideo(videoChoices, options.quality);
        if (!chosen) {
          skipped.push({ postId, reason: 'no main video url' });
          continue;
        }
        const duration = extractDurationSec(chosen) || postDur;
        const demo = isDemoLike(post, chosen, options);
        if (options.skipDemos && demo) {
          skipped.push({ postId, reason: 'demo/short' });
          continue;
        }

        const apiPicked = choosePostTitle(post);
        const apiTitle = apiPicked.title;
        let chosenTitle = { title: apiTitle, source: `api:${apiPicked.source}` };
        const visiblePost = visiblePosts.get(String(postId));
        const visibleTitle = visiblePost?.visibleTitle || '';
        if (visibleTitle) chosenTitle = preferTitle(chosenTitle, { title: visibleTitle, source: 'visible' });

        let titleDetails = { detailTitle: '', detailTitleSource: '', pageTitle: '' };
        if (options.fetchDetails) {
          onProgress(`Checking title ${items.length + 1} (${idx + 1}/${posts.length})...`);
          titleDetails = await fetchPostTitleDetails(postId, detailApiUnavailable, options.signal);
          if (/API 404|API 405|API 501/.test(titleDetails.detailError || '')) detailApiUnavailable = true;
        if (!visibleTitle && !detailApiUnavailable && titleDetails.detailTitle) {
          chosenTitle = preferTitle(chosenTitle, { title: titleDetails.detailTitle, source: `detail:${titleDetails.detailTitleSource || 'title'}` });
        }
          if (!visibleTitle && titleDetails.pageTitle) chosenTitle = preferTitle(chosenTitle, { title: titleDetails.pageTitle, source: 'page' });
        }

        const title = chosenTitle.title;
        const fileId = extractFileId(post, postId);
        items.push({
          postId,
          fileId,
          postUrl: `https://myfans.jp/posts/${postId}`,
          title,
          titleSource: chosenTitle.source,
          apiTitle,
          visibleTitle,
          visibleLimited: visiblePost?.hasBracketLimited || false,
          detailTitle: titleDetails.detailTitle,
          pageTitle: titleDetails.pageTitle,
          detailTitleSource: titleDetails.detailTitleSource,
          creator: options.creator,
          duration,
          resolution: chosen.resolution || (chosen.height ? `${chosen.height}p` : ''),
          width: chosen.width || '',
          height: chosen.height || '',
          url: chosen.url,
          source: chosen.source,
          demo,
          filename: buildFilename({ postId, fileId, title })
        });
      }

      const next = nextPageFromPayload(payload);
      if (!next || Number(next) === page) break;
      page = Number(next);
      if (!Number.isFinite(page)) break;
    }

    if (options.includeVisiblePosts) {
      const extraPosts = [...visiblePosts.values()].filter(post => !seen.has(post.postId));
      for (let idx = 0; idx < extraPosts.length; idx++) {
        throwIfAborted(options.signal);
        const visiblePost = extraPosts[idx];
        const postId = visiblePost.postId;
        if (!postId || seen.has(postId)) continue;
        seen.add(postId);

        const postLike = {
          id: postId,
          title: visiblePost.visibleTitle,
          body: visiblePost.visibleText,
          content: visiblePost.visibleText,
          duration: visiblePost.visibleDuration
        };
        if (options.skipDemos && isDemoLike(postLike, { duration: visiblePost.visibleDuration }, options)) {
          skipped.push({ postId, reason: 'visible demo/short' });
          continue;
        }

        onProgress(`Fetching visible post ${idx + 1}/${extraPosts.length}...`);
        let videoPayload;
        try {
          videoPayload = await apiFetch(`/v2/posts/${postId}/videos`, null, options.signal);
        } catch (err) {
          if (isScanStopped(err)) throw err;
          skipped.push({ postId, reason: `visible ${err.message}` });
          continue;
        }

        const videoChoices = normalizeVideos(videoPayload);
        const chosen = chooseVideo(videoChoices, options.quality);
        if (!chosen) {
          skipped.push({ postId, reason: 'visible no main video url' });
          continue;
        }
        const duration = extractDurationSec(chosen) || visiblePost.visibleDuration;
        const demo = isDemoLike(postLike, chosen, options);
        if (options.skipDemos && demo) {
          skipped.push({ postId, reason: 'visible demo/short' });
          continue;
        }

        let chosenTitle = { title: visiblePost.visibleTitle || `post_${postId}`, source: 'visible' };
        let titleDetails = { detailTitle: '', detailTitleSource: '', pageTitle: '' };
        if (options.fetchDetails) {
          titleDetails = await fetchPostTitleDetails(postId, detailApiUnavailable, options.signal);
          if (/API 404|API 405|API 501/.test(titleDetails.detailError || '')) detailApiUnavailable = true;
          if (!visiblePost.visibleTitle && !detailApiUnavailable && titleDetails.detailTitle) {
            chosenTitle = preferTitle(chosenTitle, { title: titleDetails.detailTitle, source: `detail:${titleDetails.detailTitleSource || 'title'}` });
          }
          if (!visiblePost.visibleTitle && titleDetails.pageTitle) chosenTitle = preferTitle(chosenTitle, { title: titleDetails.pageTitle, source: 'page' });
        }

        const title = cleanTitle(chosenTitle.title);
        const fileId = extractFileId(null, postId);
        items.push({
          postId,
          fileId,
          postUrl: visiblePost.postUrl,
          title,
          titleSource: chosenTitle.source,
          apiTitle: '',
          visibleTitle: visiblePost.visibleTitle,
          visibleLimited: visiblePost.hasBracketLimited || false,
          detailTitle: titleDetails.detailTitle,
          pageTitle: titleDetails.pageTitle,
          detailTitleSource: titleDetails.detailTitleSource,
          creator: options.creator,
          duration,
          resolution: chosen.resolution || (chosen.height ? `${chosen.height}p` : ''),
          width: chosen.width || '',
          height: chosen.height || '',
          url: chosen.url,
          source: `visible:${chosen.source || 'main'}`,
          demo,
          filename: buildFilename({ postId, fileId, title })
        });
      }
    }

    return { creator: options.creator, planId: options.planId, items, skipped };
  }

  function generateTSV(data, indices) {
    const selected = indices ? data.items.filter((_, i) => indices.has(i)) : data.items;
    const header = 'post_id\tduration_sec\tresolution\tfilename\tdemo\tvisible_limited\ttitle\ttitle_source\tapi_title\tvisible_title\tdetail_title\tpage_title\turl\tpost_url';
    const rows = selected.map(item => [
      item.postId,
      Math.round(item.duration || 0),
      item.resolution || '',
      item.filename,
      item.demo ? 'Y' : '',
      item.visibleLimited ? 'Y' : '',
      String(item.title || '').replace(/\t/g, ' ').replace(/\r?\n/g, ' '),
      String(item.titleSource || '').replace(/\t/g, ' ').replace(/\r?\n/g, ' '),
      String(item.apiTitle || '').replace(/\t/g, ' ').replace(/\r?\n/g, ' '),
      String(item.visibleTitle || '').replace(/\t/g, ' ').replace(/\r?\n/g, ' '),
      String(item.detailTitle || '').replace(/\t/g, ' ').replace(/\r?\n/g, ' '),
      String(item.pageTitle || '').replace(/\t/g, ' ').replace(/\r?\n/g, ' '),
      item.url,
      item.postUrl
    ].join('\t'));
    return [header, ...rows].join('\n');
  }

  function triggerTextDownload(content, filename) {
    const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    setTimeout(() => URL.revokeObjectURL(a.href), 1000);
  }

  function resolveUrl(base, value) {
    if (/^https?:\/\//i.test(value)) return value;
    return new URL(value, base).toString();
  }

  function streamResolution(line) {
    const match = line.match(/RESOLUTION=(\d+)x(\d+)/i);
    if (match) return Number(match[2]);
    return 0;
  }

  async function parseM3U8(m3u8Url, quality, signal) {
    const resp = await request(m3u8Url, { signal, withCredentials: false, timeout: 60000 });
    if (!resp.ok) throw new Error(`M3U8 ${resp.status}`);
    const text = String(resp.response || '');
    const lines = text.split('\n').map(line => line.trim()).filter(Boolean);
    const base = m3u8Url.slice(0, m3u8Url.lastIndexOf('/') + 1);

    if (lines.some(line => /^#EXT-X-KEY/i.test(line) && !/METHOD=NONE/i.test(line))) {
      throw new Error('Encrypted HLS is not supported');
    }

    const variants = [];
    for (let i = 0; i < lines.length; i++) {
      if (!lines[i].startsWith('#EXT-X-STREAM-INF')) continue;
      const bandwidth = Number((lines[i].match(/BANDWIDTH=(\d+)/i) || [])[1] || 0);
      const height = streamResolution(lines[i]);
      if (i + 1 < lines.length) {
        variants.push({ url: resolveUrl(base, lines[i + 1]), bandwidth, height });
      }
    }
    if (variants.length) {
      let chosen = null;
      if (quality && quality !== 'best') {
        const target = Number(quality.replace(/\D/g, ''));
        chosen = variants.find(v => v.height === target)
          || variants.filter(v => v.height > 0 && v.height <= target).sort((a, b) => b.height - a.height)[0];
      }
      chosen = chosen || variants.sort((a, b) => (b.height - a.height) || (b.bandwidth - a.bandwidth))[0];
      return parseM3U8(chosen.url, quality, signal);
    }

    return lines.filter(line => !line.startsWith('#')).map(line => resolveUrl(base, line));
  }

  function transmuxTsToMp4(tsData) {
    return new Promise((resolve, reject) => {
      try {
        if (typeof muxjs === 'undefined') throw new Error('mux.js is unavailable');
        const transmuxer = new muxjs.mp4.Transmuxer({ remux: true });
        const out = [];
        transmuxer.on('data', segment => {
          const buf = new Uint8Array(segment.initSegment.byteLength + segment.data.byteLength);
          buf.set(segment.initSegment, 0);
          buf.set(segment.data, segment.initSegment.byteLength);
          out.push(buf);
        });
        transmuxer.on('done', () => {
          const total = out.reduce((sum, chunk) => sum + chunk.byteLength, 0);
          const merged = new Uint8Array(total);
          let offset = 0;
          for (const chunk of out) {
            merged.set(chunk, offset);
            offset += chunk.byteLength;
          }
          resolve(merged);
        });
        transmuxer.push(tsData);
        transmuxer.flush();
      } catch (err) {
        reject(err);
      }
    });
  }

  async function downloadHLS(item, options, onStatus, signal) {
    onStatus('busy', 'playlist');
    const segments = await parseM3U8(item.url, options.quality, signal);
    if (!segments.length) throw new Error('empty playlist');

    const chunks = [];
    let totalBytes = 0;
    for (let i = 0; i < segments.length; i++) {
      if (signal?.aborted) throw new Error('cancelled');
      onStatus('busy', `${i + 1}/${segments.length} ${formatBytes(totalBytes)}`);
      const resp = await request(segments[i], {
        signal,
        responseType: 'arraybuffer',
        withCredentials: false
      });
      if (!resp.ok) throw new Error(`segment ${i + 1}: ${resp.status}`);
      const buf = resp.response;
      chunks.push(buf);
      totalBytes += buf.byteLength;
    }

    onStatus('busy', `remux ${formatBytes(totalBytes)}`);
    const tsBuffer = new Uint8Array(totalBytes);
    let offset = 0;
    for (const chunk of chunks) {
      tsBuffer.set(new Uint8Array(chunk), offset);
      offset += chunk.byteLength;
    }

    let blob;
    try {
      const mp4Data = await transmuxTsToMp4(tsBuffer);
      blob = new Blob([mp4Data], { type: 'video/mp4' });
    } catch (err) {
      console.warn('[MyFans DL] Remux failed, saving TS data instead:', err);
      blob = new Blob(chunks, { type: 'video/mp2t' });
      item.filename = item.filename.replace(/\.mp4$/i, '.ts');
    }

    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = item.filename;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    setTimeout(() => URL.revokeObjectURL(a.href), 10000);
    onStatus('ok', formatBytes(blob.size));
  }

  function setSelection(panel, videos, mode) {
    if (mode === 'all') selectedIndices = new Set(videos.map((_, i) => i));
    if (mode === 'none') selectedIndices.clear();
    if (mode === 'invert') {
      const next = new Set();
      videos.forEach((_, i) => { if (!selectedIndices.has(i)) next.add(i); });
      selectedIndices = next;
    }
    panel.querySelectorAll('.mfd-row-cb').forEach(cb => {
      cb.checked = selectedIndices.has(Number(cb.dataset.idx));
    });
    const master = panel.querySelector('#mfd-select-all');
    if (master) master.checked = videos.length > 0 && selectedIndices.size === videos.length;
    updateSelectionCount(panel, videos);
  }

  function updateSelectionCount(panel, videos) {
    const target = panel.querySelector('#mfd-sel-count');
    if (target) target.textContent = `${selectedIndices.size}/${videos.length} selected`;
  }

  function renderRows(panel) {
    const list = panel.querySelector('#mfd-list');
    const videos = results.items || [];
    if (!videos.length) {
      list.innerHTML = '<div class="mfd-muted" style="padding:10px;">No videos after filters.</div>';
      return;
    }
    selectedIndices = new Set(videos.map((_, i) => i));
    let html = '<table><thead><tr>';
    html += '<th style="width:28px;"><input type="checkbox" id="mfd-select-all" checked></th>';
    html += '<th style="width:34px;">#</th><th style="width:78px;">Post</th><th style="width:62px;">Dur</th><th style="width:62px;">Res</th><th>Title</th><th style="width:82px;">Status</th>';
    html += '</tr></thead><tbody>';
    videos.forEach((item, i) => {
      html += `<tr id="mfd-row-${i}">`;
      html += `<td><input type="checkbox" class="mfd-row-cb" data-idx="${i}" checked></td>`;
      html += `<td>${i + 1}</td>`;
      html += `<td title="${escapeHtml(item.postId)}"><a href="${escapeHtml(item.postUrl)}" target="_blank" style="color:#7dd3fc;">${escapeHtml(shortId(item.postId))}</a></td>`;
      html += `<td>${escapeHtml(formatDuration(item.duration))}</td>`;
      html += `<td>${escapeHtml(item.resolution || '-')}</td>`;
      html += `<td title="${escapeHtml([item.titleSource, item.title].filter(Boolean).join(': '))}">${escapeHtml(item.title)}</td>`;
      html += `<td class="mfd-status" id="mfd-st-${i}">-</td>`;
      html += '</tr>';
    });
    html += '</tbody></table>';
    list.innerHTML = html;

    const master = panel.querySelector('#mfd-select-all');
    master?.addEventListener('change', event => setSelection(panel, videos, event.target.checked ? 'all' : 'none'));
    panel.querySelectorAll('.mfd-row-cb').forEach(cb => {
      cb.addEventListener('change', event => {
        const idx = Number(event.target.dataset.idx);
        if (event.target.checked) selectedIndices.add(idx);
        else selectedIndices.delete(idx);
        if (master) master.checked = selectedIndices.size === videos.length;
        updateSelectionCount(panel, videos);
      });
    });
    updateSelectionCount(panel, videos);
  }

  function updateExportData(panel) {
    const target = panel.querySelector('#mfd-export-json');
    if (target) target.value = JSON.stringify(results);
  }

  function collectOptions(panel) {
    return {
      creator: sanitizeFilename(panel.querySelector('#mfd-creator').value.trim() || creatorFromUrl() || 'myfans'),
      planId: panel.querySelector('#mfd-plan').value.trim() || planIdFromUrl(),
      sortKey: panel.querySelector('#mfd-sort').value,
      quality: panel.querySelector('#mfd-quality').value,
      maxPages: Math.max(1, Number(panel.querySelector('#mfd-pages').value) || 20),
      minDuration: Math.max(0, Number(panel.querySelector('#mfd-min-dur').value) || 0),
      skipDemos: panel.querySelector('#mfd-skip-demo').checked,
      fetchDetails: panel.querySelector('#mfd-fetch-details').checked,
      includeVisiblePosts: panel.querySelector('#mfd-include-visible').checked,
      demoKeywords: panel.querySelector('#mfd-demo-keywords').value
    };
  }

  function updateRowStatus(panel, idx, cls, text) {
    const el = panel.querySelector(`#mfd-st-${idx}`);
    if (!el) return;
    el.className = `mfd-status ${cls || ''}`;
    el.textContent = text || '';
  }

  async function downloadSelected(panel) {
    if (downloading) return;
    const videos = results.items || [];
    const selected = [...selectedIndices].sort((a, b) => a - b);
    if (!selected.length) return;

    const options = collectOptions(panel);
    const status = panel.querySelector('#mfd-status');
    const startBtn = panel.querySelector('#mfd-download');
    const stopBtn = panel.querySelector('#mfd-stop');
    downloading = true;
    abortController = new AbortController();
    startBtn.disabled = true;
    stopBtn.style.display = '';

    let done = 0;
    for (const idx of selected) {
      if (abortController.signal.aborted) break;
      done += 1;
      status.textContent = `Downloading ${done}/${selected.length}...`;
      try {
        await downloadHLS(videos[idx], options, (cls, msg) => updateRowStatus(panel, idx, cls, msg), abortController.signal);
      } catch (err) {
        updateRowStatus(panel, idx, 'err', String(err.message || err).slice(0, 80));
      }
      if (done < selected.length && !abortController.signal.aborted) {
        await new Promise(resolve => setTimeout(resolve, 400));
      }
    }

    downloading = false;
    startBtn.disabled = false;
    stopBtn.style.display = 'none';
    status.textContent = abortController.signal.aborted ? 'Download stopped.' : 'Download complete.';
  }

  function buildPanel() {
    if (document.getElementById('mfd-fab')) return;
    injectStyles();

    const fab = document.createElement('button');
    fab.id = 'mfd-fab';
    fab.title = 'MyFans Downloader';
    fab.textContent = 'MF';
    document.body.appendChild(fab);

    const panel = document.createElement('div');
    panel.id = 'mfd-panel';
    panel.innerHTML = `
      <h3>MyFans Downloader</h3>

      <div class="mfd-row">
        <div>
          <label>Creator</label>
          <input type="text" id="mfd-creator" value="${escapeHtml(creatorFromUrl())}" placeholder="creator username" />
        </div>
        <div>
          <label>Plan ID</label>
          <input type="text" id="mfd-plan" value="${escapeHtml(planIdFromUrl())}" placeholder="plan UUID" />
        </div>
      </div>

      <div class="mfd-row">
        <div>
          <label>Quality</label>
          <select id="mfd-quality">
            <option value="best">Best available</option>
            <option value="2160p">2160p or lower</option>
            <option value="1080p">1080p or lower</option>
            <option value="720p">720p or lower</option>
            <option value="480p">480p or lower</option>
          </select>
        </div>
        <div>
          <label>Sort</label>
          <select id="mfd-sort">
            <option value="publish_start_at">Newest</option>
            <option value="publish_start_at_asc">Oldest</option>
            <option value="views">Views</option>
            <option value="likes">Likes</option>
            <option value="last_viewed_at">Last viewed</option>
          </select>
        </div>
      </div>

      <div class="mfd-row">
        <div>
          <label>Min duration (sec)</label>
          <input type="number" id="mfd-min-dur" value="60" min="0" step="10" />
        </div>
        <div>
          <label>Max pages</label>
          <input type="number" id="mfd-pages" value="20" min="1" step="1" />
        </div>
      </div>

      <label class="mfd-check"><input type="checkbox" id="mfd-skip-demo" checked> Skip demo/short videos</label>
      <label class="mfd-check"><input type="checkbox" id="mfd-fetch-details" checked> Fetch detail/page titles</label>
      <label class="mfd-check"><input type="checkbox" id="mfd-include-visible"> Include visible page posts (may scan many)</label>

      <label>Demo keywords</label>
      <input type="text" id="mfd-demo-keywords" value="${escapeHtml(DEFAULT_KEYWORDS)}" />

      <button class="mfd-btn mfd-btn-primary" id="mfd-scan">Scan plan</button>
      <button class="mfd-btn mfd-btn-outline" id="mfd-scan-post" style="margin-top:8px;">Scan current post</button>
      <button class="mfd-btn mfd-btn-outline" id="mfd-stop" style="display:none;margin-top:8px;">Stop</button>

      <div id="mfd-progress-wrap"><div id="mfd-progress-bar"></div></div>
      <div id="mfd-status"></div>
      <div id="mfd-stats"></div>

      <div id="mfd-results">
        <textarea id="mfd-export-json" style="display:none;"></textarea>
        <div class="mfd-select-bar">
          <button class="mfd-btn mfd-btn-outline mfd-btn-sm" id="mfd-sel-all">All</button>
          <button class="mfd-btn mfd-btn-outline mfd-btn-sm" id="mfd-sel-none">None</button>
          <button class="mfd-btn mfd-btn-outline mfd-btn-sm" id="mfd-sel-invert">Invert</button>
          <span id="mfd-sel-count"></span>
        </div>
        <div id="mfd-list"></div>
        <div class="mfd-actions">
          <button class="mfd-btn mfd-btn-green" id="mfd-download">Download selected</button>
        </div>
        <button class="mfd-btn mfd-btn-outline" id="mfd-export" style="margin-top:8px;">Export selected TSV</button>
        <button class="mfd-btn mfd-btn-outline" id="mfd-export-json-file" style="margin-top:8px;">Export JSON manifest</button>
      </div>
    `;
    document.body.appendChild(panel);

    const status = panel.querySelector('#mfd-status');
    const stats = panel.querySelector('#mfd-stats');
    const progressWrap = panel.querySelector('#mfd-progress-wrap');
    const progressBar = panel.querySelector('#mfd-progress-bar');
    const resultsWrap = panel.querySelector('#mfd-results');
    const scanBtn = panel.querySelector('#mfd-scan');
    const stopBtn = panel.querySelector('#mfd-stop');

    fab.addEventListener('click', () => panel.classList.toggle('open'));

    scanBtn.addEventListener('click', async () => {
      if (scanning) return;
      const options = collectOptions(panel);
      if (!options.planId) {
        status.textContent = 'Plan ID is required.';
        return;
      }

      abortController = new AbortController();
      options.signal = abortController.signal;
      scanning = true;
      scanBtn.disabled = true;
      scanBtn.textContent = 'Scanning...';
      stopBtn.style.display = '';
      progressWrap.style.display = 'block';
      progressBar.style.width = '4%';
      resultsWrap.classList.remove('show');
      status.textContent = 'Starting scan...';
      stats.textContent = '';
      results = { items: [] };
      selectedIndices.clear();

      try {
        results = await scanPlan(options, msg => {
          status.textContent = msg;
          const current = Number(progressBar.style.width.replace('%', '')) || 4;
          progressBar.style.width = `${Math.min(95, current + 3)}%`;
        });
        progressBar.style.width = '100%';
        const totalDur = results.items.reduce((sum, item) => sum + (item.duration || 0), 0);
        status.textContent = 'Scan complete.';
        stats.innerHTML = `<b>${escapeHtml(results.creator)}</b> - ${results.items.length} videos (${formatDuration(totalDur)}), skipped ${results.skipped.length}`;
        updateExportData(panel);
        renderRows(panel);
        resultsWrap.classList.add('show');
      } catch (err) {
        if (isScanStopped(err)) {
          status.textContent = 'Scan stopped.';
        } else {
          status.textContent = `Error: ${err.message || err}`;
          console.error('[MyFans DL]', err);
        }
      } finally {
        scanning = false;
        scanBtn.disabled = false;
        scanBtn.textContent = 'Scan plan';
        stopBtn.style.display = 'none';
        abortController = null;
      }
    });

    panel.querySelector('#mfd-scan-post').addEventListener('click', async () => {
      const postId = postIdFromUrl();
      if (!postId) {
        status.textContent = 'Open a /posts/... page first.';
        return;
      }
      const options = collectOptions(panel);
      status.textContent = 'Fetching current post video...';
      results = { creator: options.creator, planId: options.planId, items: [], skipped: [] };
      resetFilenameCounts();
      try {
        const videoPayload = await apiFetch(`/v2/posts/${postId}/videos`);
        const chosen = chooseVideo(normalizeVideos(videoPayload), options.quality);
        if (!chosen) throw new Error('No video URL found.');
        results.items.push({
          postId,
          fileId: extractFileId(null, postId),
          postUrl: `https://myfans.jp/posts/${postId}`,
          title: cleanTitle(document.title || `post_${postId}`),
          creator: options.creator,
          duration: extractDurationSec(chosen),
          resolution: chosen.resolution || (chosen.height ? `${chosen.height}p` : ''),
          width: chosen.width || '',
          height: chosen.height || '',
          url: chosen.url,
          source: chosen.source,
          demo: false,
          filename: buildFilename({ postId, fileId: extractFileId(null, postId), title: cleanTitle(document.title || '') })
        });
        status.textContent = 'Current post ready.';
        stats.innerHTML = `<b>${escapeHtml(options.creator)}</b> - 1 video`;
        updateExportData(panel);
        renderRows(panel);
        resultsWrap.classList.add('show');
      } catch (err) {
        status.textContent = `Error: ${err.message || err}`;
      }
    });

    panel.querySelector('#mfd-sel-all').addEventListener('click', () => setSelection(panel, results.items || [], 'all'));
    panel.querySelector('#mfd-sel-none').addEventListener('click', () => setSelection(panel, results.items || [], 'none'));
    panel.querySelector('#mfd-sel-invert').addEventListener('click', () => setSelection(panel, results.items || [], 'invert'));
    panel.querySelector('#mfd-download').addEventListener('click', () => downloadSelected(panel));
    panel.querySelector('#mfd-stop').addEventListener('click', () => abortController?.abort());
    panel.querySelector('#mfd-export').addEventListener('click', () => {
      const creator = collectOptions(panel).creator;
      const tsv = generateTSV(results, selectedIndices);
      triggerTextDownload(tsv, `${creator}_myfans_manifest.tsv`);
    });
    panel.querySelector('#mfd-export-json-file').addEventListener('click', () => {
      const creator = collectOptions(panel).creator;
      triggerTextDownload(JSON.stringify(results, null, 2), `${creator}_myfans_manifest.json`);
    });

    const refreshFields = () => {
      const creator = creatorFromUrl();
      const plan = planIdFromUrl();
      if (creator) panel.querySelector('#mfd-creator').value = sanitizeFilename(creator);
      if (plan) panel.querySelector('#mfd-plan').value = plan;
    };
    let lastUrl = location.href;
    setInterval(() => {
      if (location.href !== lastUrl) {
        lastUrl = location.href;
        refreshFields();
      }
    }, 1000);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', buildPanel);
  } else {
    buildPanel();
  }
})();