Scan and download subscribed MyFans plan videos.
// ==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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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(); } })();