// ==UserScript== // @name AniList to OTT Sites // @namespace https://github.com/downwarjers/WebTweaks // @version 2.3.0 // @description AniList 清單新增外部OTT按鈕,直接跳轉搜尋作品,支援巴哈自動跳轉集數 // @author downwarjers // @license MIT // @match https://anilist.co/* // @grant GM_xmlhttpRequest // @grant GM_openInTab // @connect ani.gamer.com.tw // @connect anilist.co // @downloadURL https://raw.githubusercontent.com/downwarjers/WebTweaks/main/UserScripts/anilist-external-ott-services/anilist-external-ott-services.user.js // @updateURL https://raw.githubusercontent.com/downwarjers/WebTweaks/main/UserScripts/anilist-external-ott-services/anilist-external-ott-services.user.js // ==/UserScript== (function () { 'use strict'; // ========================================== // 設定區 (按鈕定義) // ========================================== const services = [ { id: 'bahamut', label: '巴', color: '#00b4d8', // 藍色 hoverColor: '#0077b6', tooltip: '前往巴哈姆特動畫瘋 (智慧跳轉)', action: (title, progress, btn, animeUrl) => { const cleanTitleText = cleanTitle(title); getSmartDate(animeUrl, (aniDate) => { runBahaLogic(cleanTitleText, progress, btn, aniDate); }); }, }, { id: 'netflix', label: 'N', color: '#E50914', // 紅色 hoverColor: '#B20710', tooltip: '前往 Netflix 搜尋', action: (title) => { const url = `https://www.netflix.com/search?q=${encodeURIComponent(title)}`; GM_openInTab(url, { active: true }); }, }, ]; // ========================================== // === 0. 輔助函式區 === // ========================================== function cleanTitle(title) { if (!title) { return ''; } let clean = title.replace(/\s*\((TV|Movie|OVA|ONA|Special)\)/gi, ''); clean = clean.replace(/[::\--–—_]/g, ' '); return clean.replace(/\s+/g, ' ').trim(); } function parseDateObj(dateStr) { if (!dateStr) { return null; } const date = new Date(dateStr); if (!isNaN(date.getTime())) { return { year: date.getFullYear(), month: date.getMonth() + 1, }; } return null; } function findDateInScope(scope) { const dataSets = scope.querySelectorAll('.data-set'); for (const set of dataSets) { const typeEl = set.querySelector('.type'); if (typeEl && typeEl.innerText.includes('Start Date')) { const valueEl = set.querySelector('.value'); if (valueEl) { const result = parseDateObj(valueEl.innerText.trim()); return result; } } } return null; } // 背景爬蟲 function fetchDateFromBackground(url, callback) { if (!url) { // console.log('[AniList-OTT] 無 URL,略過 API 請求'); callback(null); return; } // 從網址提取 ID const idMatch = url.match(/\/anime\/(\d+)/); if (!idMatch) { console.error('[AniList-OTT] 無法從 URL 解析 Anime ID:', url); callback(null); return; } const animeId = parseInt(idMatch[1], 10); // console.log(`[AniList-OTT] 準備透過 API 查詢 ID: ${animeId}`); const query = ` query ($id: Int) { Media (id: $id, type: ANIME) { startDate { year month } } } `; GM_xmlhttpRequest({ method: 'POST', url: 'https://graphql.anilist.co', headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, data: JSON.stringify({ query: query, variables: { id: animeId }, }), onload: function (response) { if (response.status !== 200) { console.error(`[AniList-OTT] API 請求失敗 Status: ${response.status}`); callback(null); return; } try { const resJson = JSON.parse(response.responseText); const dateData = resJson.data?.Media?.startDate; if (dateData && dateData.year) { // console.log(`[AniList-OTT] API 成功取得日期:`, dateData); callback({ year: dateData.year, month: dateData.month || 1, // 如果月份缺失,預設為 1 }); } else { // console.log('[AniList-OTT] API 回傳資料中無日期'); callback(null); } } catch (e) { console.error('[AniList-OTT] API 回應解析錯誤:', e); callback(null); } }, onerror: function (err) { console.error('[AniList-OTT] API 連線錯誤:', err); callback(null); }, }); } // 判斷入口 function getSmartDate(animeUrl, callback) { // 1. 如果有傳入 animeUrl,代表這是列表頁,需要背景爬蟲 if (animeUrl) { fetchDateFromBackground(animeUrl, callback); } // 2. 否則代表是詳情頁,直接由 document 抓取 else { const date = findDateInScope(document); callback(date); } } function getMonthDiff(date1, date2) { if (!date1 || !date2) { return 999; } return Math.abs(date1.year * 12 + date1.month - (date2.year * 12 + date2.month)); } // ========================================== // === 1. 樣式注入 (CSS) === // ========================================== const style = document.createElement('style'); let cssRules = ` /* === 清單模式 (List View) 容器設定 === */ .list-head .custom-links-col, .entry .custom-links-col { width: ${services.length * 30 + 10}px; text-align: center; display: flex; justify-content: center; align-items: center; gap: 4px; /* 按鈕間距縮小 */ } /* === 卡片模式 (Grid View) 容器設定 - 懸浮於左上角 === */ .entry-card { position: relative !important; } .entry-card .custom-links-col { position: absolute; top: 5px; left: 5px; z-index: 50; width: auto; display: flex; flex-direction: column; /* 垂直排列 */ gap: 4px; background: rgba(0, 0, 0, 0.4); padding: 3px; border-radius: 4px; } /* === 按鈕通用樣式 (縮小版) === */ .link-btn { display: inline-flex; justify-content: center; align-items: center; width: 24px; height: 24px; border-radius: 4px; cursor: pointer; font-weight: bold; font-family: sans-serif; font-size: 12px; line-height: 1; transition: opacity 0.2s; text-decoration: none !important; color: #ffffff !important; box-shadow: 0 1px 3px rgba(0,0,0,0.3); } .link-btn:hover { opacity: 0.85; } /* === 標題旁 (Header) 的按鈕微調 === */ .header-ott-buttons { display: inline-flex; align-items: center; gap: 8px; margin-left: 10px; vertical-align: middle; position: relative; top: -2px; } .header-ott-buttons .link-btn { width: 28px; height: 28px; font-size: 14px; } .list-editor .header-ott-buttons { display: flex; align-items: center; gap: 5px; margin: 5px 0 0 0; /* 微調位置 */ z-index: 10; } `; services.forEach((service) => { cssRules += ` .btn-${service.id} { background-color: ${service.color}; } .btn-${service.id}:hover { background-color: ${service.hoverColor}; } `; }); style.innerHTML = cssRules; document.head.appendChild(style); // ========================================== // === 2. 主程式 (介面渲染) === // ========================================== const observer = new MutationObserver(() => { initButtons(); }); observer.observe(document.body, { childList: true, subtree: true }); function initButtons() { initListButtons(); initCardButtons(); initHeaderButtons(); initListEditorButtons(); } function initListButtons() { document.querySelectorAll('.list-head').forEach((header) => { if (!header.querySelector('.custom-links-col')) { const div = document.createElement('div'); div.className = 'custom-links-col'; div.innerText = 'Links'; header.appendChild(div); } }); document.querySelectorAll('.entry.row').forEach((entry) => { if (entry.querySelector('.custom-links-col')) { return; } const titleEl = entry.querySelector('.title a'); if (!titleEl) { return; } const title = titleEl.innerText.trim(); // 抓取連結 const animeHref = titleEl.getAttribute('href'); let progress = 0; const progressEl = entry.querySelector('.progress span') || entry.querySelector('.progress'); if (progressEl) { const raw = entry.querySelector('.progress').innerText; const clean = raw.replace('+', '').trim().split('/')[0]; progress = parseInt(clean, 10) || 0; } const btnDiv = document.createElement('div'); btnDiv.className = 'custom-links-col'; // 傳入 animeHref 供背景爬蟲使用 createButtons( btnDiv, title, () => { return progress; }, animeHref, ); entry.appendChild(btnDiv); }); } function initCardButtons() { document.querySelectorAll('.entry-card').forEach((card) => { // 1. 檢查是否已經加過按鈕,避免重複 if (card.querySelector('.custom-links-col')) { return; } // 2. 抓取標題與連結 const titleEl = card.querySelector('.title a'); if (!titleEl) { return; } const title = titleEl.innerText.trim(); const animeHref = titleEl.getAttribute('href'); // 3. 抓取進度 (處理