首頁/演員頁/作品列表頁(日期・新着・搜尋・標籤)/作品詳情頁 統一改版:全寬(高 specificity 版)、自訂欄數、大封面卡片、統一浮層(封面+預覽圖+試看+VR自動偵測+試看網址多重猜測)、sukebei 種子搜尋(含複製磁鏈)、missav 快捷、演員頁側欄精簡、標籤頁分類側欄精簡
// ==UserScript==
// @name AvBase Ultimate Enhancer
// @namespace http://tampermonkey.net/
// @version 4.0.3
// @description 首頁/演員頁/作品列表頁(日期・新着・搜尋・標籤)/作品詳情頁 統一改版:全寬(高 specificity 版)、自訂欄數、大封面卡片、統一浮層(封面+預覽圖+試看+VR自動偵測+試看網址多重猜測)、sukebei 種子搜尋(含複製磁鏈)、missav 快捷、演員頁側欄精簡、標籤頁分類側欄精簡
// @match https://www.avbase.net/*
// @match https://avbase.net/*
// @run-at document-idle
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @connect sukebei.nyaa.si
// @license MIT
// ==/UserScript==
/*
* ============================================================================
* 維護須知(給未來要修這支腳本的人/AI 看)
* ----------------------------------------------------------------------------
* AvBase 是 Next.js 網站,class 大多是 Tailwind 自動產生,改版時很容易全部
* 跑掉。這支腳本盡量避免死綁一長串 class,而是抓「結構特徵」:
*
* - 作品卡片:辨識依據是卡片內部一定有 `.flex.min-w-0.border-y.border-light`
* 這個「小圖 + 標題」列,以及一個 `a[href^="/works/"]` 標題連結。
*
* - 全寬:目前發現網站有兩種「限制寬度」的容器 class:`.max-w-7xl`(首頁/
* 演員頁/作品詳情頁)與 `.cl-container`(/works/date、/works/recent、
* /works?q=... 這類列表頁)。兩個都要蓋掉 max-width,將來如果又出現第三
* 種,同樣加進 GM_addStyle 那段最上面的全寬區塊即可。
* 這條規則用「重複 class 拉高 specificity」的寫法(例如
* `.cl-container.cl-container`),是因為 /works/date、/works/recent、
* /works?q=... 這類搜尋結果頁在 SPA 切頁時,Next.js 有時會晚一步才把
* 該頁專屬的 CSS chunk 插進 <head>,順序排在我們的 GM_addStyle 後面;
* 雙方都用 !important 時瀏覽器就會比 specificity 而不是比誰後宣告,所以
* 故意疊 class 把分數拉高,確保不管誰先誰後都是我們贏。
*
* - 封面大圖網址:DMM/FANZA 圖檔命名規則固定「資料夾名 = 檔名去掉 ps/pl/jp」,
* 所以直接用 cid 組 pl.jpg 網址,不依賴頁面上有沒有現成的大圖連結。
*
* - 試看影片網址 / 預覽圖 / 是否為 VR:全部從 Next.js 內建的
* `<script id="__NEXT_DATA__">` 這個 JSON 標籤直接 JSON.parse 出來,
* 走 props.pageProps.work.products 陣列(每個 product 是不同的販售通路,
* 各自可能有自己的 sample_movie_url / sample_image_urls)。這比起用正規
* 表示式在整段 HTML 裡亂抓穩定非常多,是改版後最不容易失效的地方。
* 如果 Next.js 資料結構改了(例如 work 欄位改名),要修的地方只有
* extractWorkInfoFromNextData() 這個函式。
* 萬一哪天 __NEXT_DATA__ 整個消失(改用別的資料傳遞方式),才需要回頭用
* extractSampleVideoUrlFallback() 這類正規表示式硬找,那只是保險用的備援。
* 另外 __NEXT_DATA__ 給的 sample_movie_url 常常是 null(尤其 duga 來源、
* 或還沒上架的預售品),但實測發現只要照 DMM 的檔名規則自己組網址,
* 很多時候影片其實是存在的,只是官方沒把網址寫進資料裡。
* buildTrailerUrlCandidates() 就是把目前已知會中的幾種猜測網址準備好,
* 播放時依序試(跟封面圖的 fallback chain 是同一個精神),全部試過還是
* 不行才真的代表沒有試看影片。這幾條規則是實測整理出來的,不保證每支
* 影片都適用,DMM 那邊換路徑規則的話要重新抓包更新這裡。
*
* - VR 判斷:目前用 product_id / 標題裡有沒有出現「VR」「vrkm」「savr」
* 「urvrsp」等已知字樣來判斷,寫在 isVRProduct()。這是最土炮但最不依賴
* 頁面結構的作法;如果之後發現誤判,調整這裡的關鍵字即可。
*
* - sukebei 種子搜尋:讀 sukebei.nyaa.si 的 RSS(?page=rss&q=...),解析
* <item> 底下 nyaa 命名空間欄位(seeders/leechers/size/infoHash)。
* 磁力連結是自己用 infoHash 組出來的(sukebei RSS 沒有直接給 magnet
* 欄位)。如果 sukebei 改了 RSS 結構,要修的地方是 parseSukebeiRss()。
*
* - 標籤頁 (/tags/xxx) 左側「分類」欄位跟演員頁側欄根本是同一套版面(外層
* 都是那個 4 欄 grid,左欄是 `.lg:col-span-1`),所以用同樣的手法處理:
* 外層 grid 疊上 avbase-talent-stack 變成上下堆疊,側欄本身再精簡成一排
* 靠左橫向排列(分類名稱+子標籤展開鈕+編輯鈕+フォロー鈕),這樣就不
* 會在作品列表左邊留一大塊空白。寫在 rebuildTagSidebar()。
* ============================================================================
*/
(function () {
'use strict';
if (window.__avbaseReforgeLoaded) return;
window.__avbaseReforgeLoaded = true;
/* =========================================================
* 0. 常數
* ========================================================= */
const COLS_KEY = 'avbase_reforge_cols_v1';
const COLS_MIN = 3;
const COLS_MAX = 12;
const COLS_DEFAULT = 6;
const css = `
/* 1. 強制移除所有外層容器的最大寬度限制 */
.max-w-7xl,
.cl-container,
[class*="max-w-7xl"],
[class*="max-w-6xl"] {
max-width: 100% !important;
width: 100% !important;
}
/* 2. 縮減兩側 Margin / Padding 留白 */
.cl-container,
nav > div,
header > div {
margin-left: 0 !important;
margin-right: 0 !important;
padding-left: 12px !important;
padding-right: 12px !important;
}
/* 3. 重新建立卡片自適應網格 (RWD Grid) */
/* 將原先固定的 2 欄,改為依螢幕寬度自動填滿 (每張卡片最小 280px) */
.avbase-grid2,
div[data-avbase-grid] {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)) !important;
gap: 16px !important;
}
`;
/* =========================================================
* 0-1. 頁面類型判斷(全部功能的總開關,改版後路徑變了先來這裡改)
* ========================================================= */
function isHomePage() {
return location.pathname === '/' || location.pathname === '';
}
function isTalentPage() {
if (!/^\/talents\//.test(location.pathname)) return false;
if (/^\/talents\/(recent|popular|edit)(\/|$)/.test(location.pathname)) return false;
return true;
}
// 作品「列表」頁:日期瀏覽 /works/date(/YYYY-MM-DD)、新着 /works/recent、
// 搜尋結果 /works(?q=...)
function isWorksListPage() {
const p = location.pathname;
if (p === '/works') return true;
if (p === '/works/date' || p.startsWith('/works/date/')) return true;
if (p === '/works/recent') return true;
return false;
}
// 標籤頁 /tags/xxx,觀察到跟其他列表頁共用同一套卡片元件
function isTagPage() {
return /^\/tags\//.test(location.pathname);
}
// 作品「詳情」頁:/works/<maker:CODE> 或 /works/<CODE>,且不是上面的列表頁
function isWorkDetailPage() {
const p = location.pathname;
if (!p.startsWith('/works/')) return false;
if (isWorksListPage()) return false;
const rest = p.slice('/works/'.length);
return rest.length > 0;
}
// 這幾種頁面共用「全寬 + 網格化 + 卡片重建」那一套邏輯
function isCardGridPage() {
return isHomePage() || isTalentPage() || isWorksListPage() || isTagPage();
}
function injectStyle() {
if (document.getElementById('custom-fullwidth-style')) return;
const style = document.createElement('style');
style.id = 'custom-fullwidth-style';
style.type = 'text/css';
style.appendChild(document.createTextNode(css));
(document.head || document.documentElement).appendChild(style);
}
// 立即注入 + DOM 載入後二次確保(防範 SPA 頁面動態渲染覆蓋)
injectStyle();
document.addEventListener('DOMContentLoaded', injectStyle);
// 監聽頁面變更 (適應 Next.js 路由切換)
const observer = new MutationObserver(injectStyle);
observer.observe(document.documentElement, { childList: true, subtree: true });
/* =========================================================
* 1. 樣式
* ========================================================= */
GM_addStyle(`
:root { --avbase-cols: ${COLS_DEFAULT}; }
/* ---- 全站全寬:去掉左右大留白(首頁/演員頁/作品詳情頁用 .max-w-7xl,
/works/date /works/recent /works?q=... 這類列表頁用 .cl-container,
兩種都要蓋掉。用重複 class 拉高 specificity,確保就算 Next.js 之後
又插入一份同樣用 !important 的頁面專屬 CSS,我們還是贏 ---- */
.max-w-7xl, .max-w-7xl.max-w-7xl,
.cl-container, .cl-container.cl-container, .cl-container.cl-container.cl-container {
max-width: 100% !important;
}
/* ---- 首頁「本日発売の作品 / 最近登録された作品」左右並排容器改成上下堆疊 ---- */
.avbase-full-stack { display: block !important; }
.avbase-full-stack > * { width: 100% !important; }
.avbase-full-stack > * + * { margin-top: 12px; }
/* ---- 作品卡片網格(首頁 / 演員頁 / 作品列表頁 / 標籤頁 共用)---- */
.avbase-grid2 {
display: grid !important;
grid-template-columns: repeat(var(--avbase-cols), minmax(0, 1fr)) !important;
gap: 14px !important;
}
.avbase-grid2 > .relative { width: 100%; margin: 0 !important; }
/* ---- 卡片本體:作品代碼/日期 -> 封面(橫向為主) -> 標題(最多三行) -> 演員 ---- */
.avbase-card2 { display: flex; flex-direction: column; height: 100%; }
.avbase-meta2 {
order: 1; padding: 8px 10px; font-size: 11px; color: #9ca3af;
border-bottom: 1px dashed rgba(148,163,184,.25);
}
.avbase-meta2 a { color: inherit; }
.avbase-cover2 {
order: 2;
position: relative;
width: 100%;
aspect-ratio: 16 / 10;
background: #0b1220;
overflow: hidden;
cursor: zoom-in;
display: flex; align-items: center; justify-content: center;
}
.avbase-cover2-img { width: 100%; height: 100%; object-fit: contain; display: block; transition: transform .25s ease; }
.avbase-cover2:hover .avbase-cover2-img { transform: scale(1.03); }
/* 註:故意不做放大鏡圖示,滑鼠移上去 cursor 本身就是 zoom-in 了 */
.avbase-title2 {
order: 3;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
overflow: hidden; padding: 10px 10px 4px; font-weight: 600; font-size: 13px; line-height: 1.4;
color: inherit; text-decoration: none;
}
.avbase-title2:hover { text-decoration: underline; }
.avbase-actors2 { order: 4; margin-top: auto; display: flex; flex-wrap: wrap; gap: 6px; padding: 4px 10px 10px; }
/* ---- 設定面板 ---- */
.avbase-settings-btn {
position: fixed; right: 18px; bottom: 18px; z-index: 99998;
width: 44px; height: 44px; border-radius: 999px;
background: #111827; color: #fff; border: 1px solid #374151; font-size: 18px; cursor: pointer;
box-shadow: 0 4px 14px rgba(0,0,0,.4);
}
.avbase-settings-panel {
position: fixed; right: 18px; bottom: 70px; z-index: 99998;
background: #111827; border: 1px solid #374151; border-radius: 10px;
padding: 12px 14px; display: none; min-width: 210px;
box-shadow: 0 8px 24px rgba(0,0,0,.5);
}
.avbase-settings-panel.show { display: block; }
.avbase-settings-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.avbase-settings-row label { color: #cbd5e1; font-size: 13px; }
.avbase-settings-row input {
width: 64px; background: #0b1220; color: #e5e7eb; border: 1px solid #374151;
border-radius: 6px; padding: 4px 8px;
}
.avbase-settings-hint { margin-top: 6px; color: #64748b; font-size: 11px; line-height: 1.5; }
/* =========================================================
* 浮層(Lightbox / Gallery)
* 版面:左=目前作品自己的預覽圖(固定5格視窗,淡入淡出)/中=純圖片(或播放器)
* /右=標題演員按鈕。下方=所有作品的封面列(切換「作品」用)。
* 縮小視窗時:左側預覽圖搬頂部橫排、右側資訊搬到圖片下方。
* ========================================================= */
.avbase-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.94); z-index: 100000;
display: flex; flex-direction: column; opacity: 0; transition: opacity .2s ease;
}
.avbase-overlay.show { opacity: 1; }
.avbase-ov-close {
position: absolute; top: 12px; right: 12px; z-index: 20;
width: 40px; height: 40px; border-radius: 999px; border: none;
background: rgba(0,0,0,.55); color: #fff; font-size: 18px; cursor: pointer;
}
.avbase-ov-close:hover { background: rgba(0,0,0,.85); }
.avbase-ov-shell { flex: 1; min-height: 0; display: flex; flex-direction: row; }
/* 左:目前作品自己的封面+預覽圖,固定顯示 5 格,目前那張置中並放大 */
.avbase-ov-thumbside { flex: 0 0 100px; border-right: 1px solid rgba(255,255,255,.08); background: rgba(255,255,255,.02); }
.avbase-ov-thumbside-inner {
height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 10px; padding: 12px 8px; overflow: hidden;
}
.avbase-ov-thumb {
flex: 0 0 auto; width: 72px; height: 72px; border-radius: 6px; overflow: hidden; cursor: pointer;
opacity: .5; border: 2px solid transparent; background: #0b1220;
display: flex; align-items: center; justify-content: center;
transition: opacity .3s ease, transform .3s ease;
}
.avbase-ov-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.avbase-ov-thumb.active { opacity: 1; border-color: #60a5fa; transform: scale(1.15); }
/* 換圖當下暫時套用這個 class 讓它先「淡出+往下位移」,拿掉後 CSS transition 會自動補完淡入+歸位的動畫 */
.avbase-ov-thumb.avbase-ov-thumb-anim { opacity: 0 !important; transform: translateY(10px) scale(.85) !important; }
/* 中:純圖片/播放器,左右鍵切換「作品」 */
.avbase-ov-main { flex: 1; min-width: 0; position: relative; display: flex; align-items: center; justify-content: center; padding: 16px; }
.avbase-ov-img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; transition: opacity .2s ease; }
.avbase-ov-player { display: none; width: 100%; max-width: 960px; }
.avbase-ov-player video.avbase-trailer-video { width: 100%; max-height: 70vh; border-radius: 6px; background: #000; display: block; }
.avbase-vr-wrap { width: 100%; position: relative; }
.avbase-vr-wrap a-scene { width: 100%; height: 60vh; border-radius: 6px; overflow: hidden; display: block; }
/* 來源 video 不能用 display:none,否則部分瀏覽器停止解碼,改用縮到 1px + opacity:0 */
.avbase-vr-source-video { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.avbase-ov-prev, .avbase-ov-next {
position: absolute; top: 50%; transform: translateY(-50%);
background: rgba(0,0,0,.5); color: #fff; border: none; border-radius: 999px;
width: 44px; height: 44px; font-size: 20px; cursor: pointer; z-index: 3;
}
.avbase-ov-prev { left: 8px; }
.avbase-ov-next { right: 8px; }
.avbase-ov-prev:hover, .avbase-ov-next:hover { background: rgba(0,0,0,.8); }
/* 右:標題/演員/操作按鈕 */
.avbase-ov-side {
flex: 0 0 320px; max-width: 320px; border-left: 1px solid rgba(255,255,255,.08);
background: rgba(255,255,255,.02); overflow-y: auto; padding: 16px;
display: flex; flex-direction: column; gap: 12px;
}
.avbase-ov-title { color: #f3f4f6; font-size: 14px; line-height: 1.5; font-weight: 600; }
.avbase-ov-actors { display: flex; flex-wrap: wrap; gap: 6px; }
.avbase-ov-controls { display: flex; flex-direction: column; gap: 8px; margin-top: auto; padding-top: 8px; }
.avbase-ov-controls button, .avbase-ov-controls a {
width: 100%; box-sizing: border-box; text-align: center;
background: #1f2937; color: #e5e7eb; border: 1px solid #374151; border-radius: 8px;
padding: 10px 12px; font-size: 13px; cursor: pointer; text-decoration: none;
}
.avbase-ov-controls button:hover, .avbase-ov-controls a:hover { background: #374151; }
.avbase-ov-controls button:disabled { opacity: .5; cursor: default; }
/* 下:所有「作品」的封面列,點了切換目前顯示哪一部作品(跟左側「同一部作品的預覽圖」是兩回事)*/
.avbase-ov-bottomthumbs {
flex: 0 0 auto; display: flex; align-items: center; gap: 8px; padding: 10px 16px;
overflow-x: auto; background: rgba(255,255,255,.03); border-top: 1px solid rgba(255,255,255,.08);
}
.avbase-ov-bottomthumb {
flex: 0 0 auto; width: auto; height: 86px; border-radius: 6px; overflow: hidden; cursor: pointer;
opacity: .45; border: 2px solid transparent; background: #0b1220;
display: flex; align-items: center; justify-content: center; transition: opacity .2s, border-color .2s;
}
.avbase-ov-bottomthumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.avbase-ov-bottomthumb:hover { opacity: .8; }
.avbase-ov-bottomthumb.active { opacity: 1; border-color: #60a5fa; }
@media (max-width: 860px) {
.avbase-ov-shell { flex-direction: column; }
.avbase-ov-thumbside { order: 1; flex: 0 0 auto; width: 100%; height: 100px; border-right: none; border-bottom: 1px solid rgba(255,255,255,.08); }
.avbase-ov-thumbside-inner { flex-direction: row; }
.avbase-ov-main { order: 2; flex: 1 1 auto; min-height: 200px; }
.avbase-ov-side { order: 3; flex: 0 0 auto; width: 100%; max-width: none; max-height: 40vh; border-left: none; border-top: 1px solid rgba(255,255,255,.08); }
.avbase-ov-controls { flex-direction: row; flex-wrap: wrap; margin-top: 0; }
.avbase-ov-controls button, .avbase-ov-controls a { width: auto; flex: 1 1 120px; }
}
/* ---- Sukebei 種子搜尋(浮層中再開一層) ---- */
.avbase-sukebei-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 100010;
display: flex; align-items: center; justify-content: center; padding: 20px;
opacity: 0; transition: opacity .2s ease;
}
.avbase-sukebei-overlay.show { opacity: 1; }
.avbase-sukebei-card {
background: #111827; border: 1px solid #374151; border-radius: 12px;
width: 100%; max-width: 640px; max-height: 80vh; display: flex; flex-direction: column;
box-shadow: 0 12px 40px rgba(0,0,0,.5);
}
.avbase-sukebei-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid #374151; color: #f3f4f6; }
.avbase-sukebei-close { width: 28px; height: 28px; border-radius: 999px; border: none; background: rgba(255,255,255,.1); color: #fff; cursor: pointer; }
.avbase-sukebei-search-row { display: flex; gap: 8px; padding: 12px 16px; }
.avbase-sukebei-input { flex: 1; background: #0b1220; color: #e5e7eb; border: 1px solid #374151; border-radius: 6px; padding: 8px 10px; }
.avbase-sukebei-go { background: #2563eb; color: #fff; border: none; border-radius: 6px; padding: 8px 14px; cursor: pointer; }
.avbase-sukebei-go:hover { background: #1d4ed8; }
.avbase-sukebei-results { overflow-y: auto; padding: 0 16px 16px; display: flex; flex-direction: column; gap: 8px; }
.avbase-sukebei-hint { color: #9ca3af; font-size: 13px; text-align: center; padding: 20px 0; }
.avbase-sukebei-item { background: #1f2937; border: 1px solid #374151; border-radius: 8px; padding: 10px 12px; color: #e5e7eb; }
.avbase-sukebei-item-title { font-size: 13px; line-height: 1.4; word-break: break-all; }
.avbase-sukebei-item-meta { margin-top: 4px; display: flex; gap: 10px; font-size: 11px; color: #9ca3af; flex-wrap: wrap; }
.avbase-sukebei-item-actions { margin-top: 8px; display: flex; gap: 8px; }
.avbase-sukebei-item-actions a, .avbase-sukebei-item-actions button {
background: #263142; color: #e5e7eb; border: 1px solid #374151; border-radius: 6px;
padding: 6px 10px; font-size: 12px; cursor: pointer; text-decoration: none;
}
.avbase-sukebei-item-actions a:hover, .avbase-sukebei-item-actions button:hover { background: #33415a; }
/* ---- 演員頁:整塊資訊改成上下堆疊(原本左欄下方留白的問題)
標籤頁的分類側欄也共用這個 class ---- */
.avbase-talent-stack { display: flex !important; flex-direction: column !important; gap: 16px !important; }
.avbase-talent-stack > * { width: 100% !important; }
/* ---- 演員頁:左側精簡資訊(置中) ---- */
.avbase-talent-mini { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 10px; padding: 16px 8px; }
.avbase-talent-avatar-btn {
border: none; background: transparent; padding: 0; cursor: pointer;
border-radius: 999px; overflow: hidden; width: 128px; height: 128px;
transition: transform .2s ease, box-shadow .2s ease;
}
.avbase-talent-avatar-btn:hover { transform: scale(1.03); box-shadow: 0 4px 16px rgba(0,0,0,.25); }
.avbase-talent-avatar-btn img { width: 100%; height: 100%; object-fit: cover; display: block; }
.avbase-talent-name { font-size: 20px; font-weight: 700; }
.avbase-talent-follow, .avbase-talent-social { display: flex; justify-content: center; }
.avbase-talent-social > div { display: flex; justify-content: center; gap: 12px; }
/* ---- 演員頁:頭像點擊後的完整資料浮卡 ---- */
.avbase-talent-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 100000;
display: flex; align-items: center; justify-content: center; padding: 24px;
opacity: 0; transition: opacity .2s ease;
}
.avbase-talent-overlay.show { opacity: 1; }
.avbase-talent-card {
position: relative; background: var(--background, #111827); color: inherit;
border-radius: 12px; max-width: 480px; width: 100%; max-height: 85vh; overflow-y: auto;
padding: 20px; box-shadow: 0 12px 40px rgba(0,0,0,.5);
}
.avbase-talent-card-close {
position: absolute; top: 10px; right: 10px; width: 32px; height: 32px;
border-radius: 999px; border: none; background: rgba(128,128,128,.25); cursor: pointer; font-size: 14px;
}
.avbase-talent-card-body { text-align: center; }
.avbase-talent-card-body .text-center.grid.grid-cols-2 { grid-template-columns: 1fr; }
/* ---- 標籤頁:分類側欄精簡(合併到作品列上方,靠左橫向排列,不像演員頁那樣置中)---- */
.avbase-tag-mini { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 10px 4px 4px; }
.avbase-tag-mini-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
`);
/* =========================================================
* 2. 共用工具函式
* ========================================================= */
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, ch => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[ch]));
}
function debounce(fn, wait) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), wait);
};
}
function dedupe(arr) {
return Array.from(new Set(arr.filter(Boolean)));
}
// 幫 <img> 掛上「這張失敗就換下一個網址」的重試鏈
function withFallbackChain(imgEl, urls) {
if (!imgEl) return;
if (!urls || !urls.length) return;
let i = 0;
imgEl.onerror = () => {
i++;
if (i < urls.length) imgEl.src = urls[i];
else imgEl.onerror = null;
};
imgEl.src = urls[0];
}
// AV 圖檔命名規則固定為 <資料夾>/<資料夾><ps|pl|jp|jm>.jpg,取出資料夾名稱 (cid)
function extractCid(pathname) {
const decoded = decodeURIComponent(pathname);
let m = decoded.match(/\/([A-Za-z0-9_]+)\/\1(?:ps|pl|jp|jm)(?:-\d+)?\.jpg$/i);
if (m) return m[1];
m = decoded.match(/\/([A-Za-z0-9_]+)\/[^/]+\.jpg$/i);
if (m) return m[1];
return null;
}
function getDmmCid(url) {
try {
const u = new URL(url, location.href);
if (!/(^|\.)dmm\.co\.jp$/i.test(u.hostname)) return null;
return extractCid(u.pathname);
} catch (e) {
return null;
}
}
// 卡片頂部大封面:優先用 pics.dmm.co.jp 的 pl.jpg
function buildCoverCandidates(smallSrc) {
const list = [];
const cid = getDmmCid(smallSrc);
if (cid) {
list.push(`https://pics.dmm.co.jp/digital/video/${cid}/${cid}pl.jpg`);
if (/\/mono\/movie\//i.test(smallSrc)) {
list.push(smallSrc.replace(/ps(?:-\d+)?\.jpg(\?.*)?$/i, 'pl.jpg$1'));
}
list.push(`https://awsimgsrc.dmm.co.jp/pics_dig/digital/video/${cid}/${cid}pl.jpg`);
} else if (/ps(?:-\d+)?\.jpg(\?.*)?$/i.test(smallSrc)) {
list.push(smallSrc.replace(/ps(?:-\d+)?\.jpg(\?.*)?$/i, 'pl.jpg$1'));
}
list.push(smallSrc);
return dedupe(list);
}
// 浮層大圖:優先用 awsimgsrc.dmm.co.jp 的 pl.jpg
function buildLightboxCandidates(smallSrc) {
const list = [];
const cid = getDmmCid(smallSrc);
if (cid) {
list.push(`https://awsimgsrc.dmm.co.jp/pics_dig/digital/video/${cid}/${cid}pl.jpg`);
list.push(`https://pics.dmm.co.jp/digital/video/${cid}/${cid}pl.jpg`);
}
list.push(smallSrc);
return dedupe(list);
}
// 從 /works/attackers:ADN-801 這類網址取出 "ADN-801",給 sukebei / missav 搜尋用
function extractWorkCode(workHref) {
try {
const last = decodeURIComponent((workHref || '').split('/').filter(Boolean).pop() || '');
const parts = last.split(':');
return parts[parts.length - 1] || last;
} catch (e) {
return workHref || '';
}
}
// 統一組出「一個畫廊項目」的資料結構,卡片格、作品詳情頁封面、關聯作品都共用這個
function buildGalleryItem({ smallSrc, workHref, title, actorsHtml, el }) {
return {
workHref,
title: title || '',
actorsHtml: actorsHtml || '',
cid: getDmmCid(smallSrc),
coverCandidates: buildCoverCandidates(smallSrc),
lightboxCandidates: buildLightboxCandidates(smallSrc),
el: el || null,
isCurrentPage: false,
_detailsPromise: null // fetchWorkDetails() 用的快取,見該函式
};
}
/* =========================================================
* 3. 每行顯示數量設定
* ========================================================= */
function getStoredCols() {
const v = parseInt(localStorage.getItem(COLS_KEY), 10);
return Number.isFinite(v) && v >= COLS_MIN && v <= COLS_MAX ? v : COLS_DEFAULT;
}
function setStoredCols(v) {
const c = Math.max(COLS_MIN, Math.min(COLS_MAX, parseInt(v, 10) || COLS_DEFAULT));
localStorage.setItem(COLS_KEY, String(c));
return c;
}
function effectiveCols() {
const stored = getStoredCols();
const w = window.innerWidth;
if (w < 640) return Math.min(stored, 2);
if (w < 1024) return Math.min(stored, 3);
if (w < 1440) return Math.min(stored, 5);
return stored;
}
function applyColumns() {
document.documentElement.style.setProperty('--avbase-cols', String(effectiveCols()));
}
function buildSettingsPanel() {
if (document.querySelector('.avbase-settings-btn')) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'avbase-settings-btn';
btn.textContent = '⚙';
btn.title = 'AvBase 版面設定';
const panel = document.createElement('div');
panel.className = 'avbase-settings-panel';
panel.innerHTML = `
<div class="avbase-settings-row">
<label>每行顯示數量</label>
<input type="number" min="${COLS_MIN}" max="${COLS_MAX}" step="1" class="avbase-cols-input">
</div>
<div class="avbase-settings-hint">小螢幕會自動再縮減欄數(適用於首頁/演員頁/作品列表頁/標籤頁)</div>
`;
document.body.appendChild(btn);
document.body.appendChild(panel);
const input = panel.querySelector('.avbase-cols-input');
input.value = String(getStoredCols());
input.addEventListener('change', () => {
setStoredCols(input.value);
input.value = String(getStoredCols());
applyColumns();
});
btn.addEventListener('click', (e) => {
e.stopPropagation();
panel.classList.toggle('show');
});
document.addEventListener('click', (e) => {
if (!panel.contains(e.target) && e.target !== btn) panel.classList.remove('show');
});
}
/* =========================================================
* 4. 版面重排:全寬 + 網格化(首頁 / 演員頁 / 作品列表頁 / 標籤頁)
* ========================================================= */
function normalizeLayout() {
if (!isCardGridPage()) return;
// 觀察到的卡片格 class 組合有 gap-2(首頁)跟 gap-4(其他頁)兩種,都吃
// ※若某頁版面跟這裡假設的不一樣,卡片就不會被抓到,屬於安全失敗(原生版面照常顯示)
const grids = document.querySelectorAll('.grid.sm\\:grid-cols-2.gap-2, .grid.sm\\:grid-cols-2.gap-4');
grids.forEach(grid => {
if (grid.dataset.avbaseGrid === '1') return;
const hasCards = grid.querySelector('.bg-background.border.border-light.rounded-lg.overflow-hidden');
if (!hasCards) return;
grid.classList.add('avbase-grid2');
grid.dataset.avbaseGrid = '1';
// 首頁「本日発売の作品」「最近登録された作品」左右並排的外層改成上下堆疊、各自全寬
const twoColWrap = grid.closest('.grid.gap-3.lg\\:grid-cols-2');
if (twoColWrap && twoColWrap.dataset.avbaseStack !== '1') {
twoColWrap.classList.add('avbase-full-stack');
twoColWrap.dataset.avbaseStack = '1';
}
});
}
/* =========================================================
* 5. 卡片重建:作品代碼/日期 -> 大封面 -> 標題 -> 演員
* ========================================================= */
function rebuildCard(cardEl) {
if (cardEl.dataset.avbaseDone === '1') return;
const metaHeader = cardEl.querySelector(':scope > .bg-muted.py-2.flex.flex-col.gap-2');
const mediaRow = cardEl.querySelector(':scope > .flex.min-w-0.border-y.border-light');
const actorsWrap = cardEl.querySelector(':scope > .relative.min-h-10.bg-background.p-2 .flex.flex-wrap.gap-2');
if (!mediaRow) return;
const smallImg = mediaRow.querySelector('img');
const titleLink = mediaRow.querySelector('a[href^="/works/"]');
if (!smallImg || !titleLink) return;
const smallSrc = smallImg.getAttribute('src') || '';
const altText = smallImg.getAttribute('alt') || titleLink.textContent.trim();
const workHref = titleLink.getAttribute('href') || '#';
const titleText = titleLink.textContent.trim();
const metaHtml = metaHeader ? metaHeader.innerHTML : '';
const actorsHtml = actorsWrap ? actorsWrap.innerHTML : '';
const item = buildGalleryItem({ smallSrc, workHref, title: titleText, actorsHtml, el: cardEl });
cardEl.innerHTML = `
${metaHtml ? `<div class="avbase-meta2">${metaHtml}</div>` : ''}
<div class="avbase-cover2">
<img class="avbase-cover2-img" alt="${escapeHtml(altText)}" loading="lazy">
</div>
<a class="avbase-title2" href="${escapeHtml(workHref)}">${escapeHtml(titleText)}</a>
${actorsHtml ? `<div class="avbase-actors2">${actorsHtml}</div>` : ''}
`;
const coverImg = cardEl.querySelector('.avbase-cover2-img');
withFallbackChain(coverImg, item.coverCandidates);
cardEl.classList.add('avbase-card2');
cardEl.dataset.avbaseDone = '1';
cardEl._avbaseItem = item;
cardEl.querySelector('.avbase-cover2').addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
openGalleryFromCard(cardEl);
});
}
function rebuildAllCards() {
if (!isCardGridPage()) return;
document
.querySelectorAll('.avbase-grid2 .bg-background.border.border-light.rounded-lg.overflow-hidden')
.forEach(card => {
try { rebuildCard(card); } catch (e) { console.warn('[AvBase Reforge] rebuildCard failed', e); }
});
}
/* =========================================================
* 6. 從 __NEXT_DATA__ 抓試看影片 / VR 試看影片 / 預覽圖
* ========================================================= */
// 把整段 HTML 裡的 <script id="__NEXT_DATA__">...</script> 挖出來 JSON.parse
function extractNextDataFromHtml(html) {
const m = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
if (!m) return null;
try { return JSON.parse(m[1]); } catch (e) { return null; }
}
// 判斷一個 product 是不是 VR 版本:看 product_id / 標題 / 作品標題裡有沒有出現常見 VR 關鍵字
// ※誤判時(例如新的 VR 廠牌代碼沒列到)調整這裡的關鍵字即可
function isVRProduct(product, work) {
const hay = [
product && product.product_id,
product && product.title,
work && work.title
].filter(Boolean).join(' ');
return /【VR】|\bVR\b|vrkm|savr|urvrsp|bibivr|ipvr|atvr|juvr|3dsvr|nkkvr/i.test(hay);
}
// 試看影片網址「猜測」規則:__NEXT_DATA__ 給的 sample_movie_url 常常是 null,
// 但實測發現只要照 DMM 檔名規則自己組網址,很多時候影片其實是存在的,只是
// 官方沒把網址寫進資料裡。這裡回傳一組「候選網址陣列」,播放時依序試,
// 全部失敗才代表真的沒有試看影片。
// ※這是實測整理出來的規則,不保證每支影片都適用;DMM 換路徑規則的話要
// 重新抓包更新這裡。
function buildTrailerUrlCandidates(code, isVR) {
if (!code) return [];
const prefix = code.substr(0, 3);
const list = [];
// 部分較舊番號(1f 開頭但不是 1fsvss、或 1m 開頭)在部分鏡像站上,長影片檔名
// 把番號中間的補零拿掉了(第 7~8 碼),所以另外組一份「去掉這兩碼」的網址
if ((code.startsWith('1f') && !code.startsWith('1fsvss')) || code.startsWith('1m')) {
const shortCode = code.substr(0, 6) + code.substr(8, 3);
list.push(`https://videos.vpdmm.cc/litevideo/freepv/${code.charAt(0)}/${code.substr(0, 3)}/${shortCode}/${shortCode}4k.mp4`);
}
list.push(`https://cc3001.dmm.com/${isVR ? 'vrsample' : 'litevideo/freepv'}/${code.charAt(0)}/${prefix}/${code}/${code}${isVR ? 'vrlite' : 'hhb'}.mp4`);
list.push(`https://cc3001.dmm.com/litevideo/freepv/${code.charAt(0)}/${prefix}/${code}/${code}_dmb_w.mp4`);
return list;
}
// 從已經 JSON.parse 好的 __NEXT_DATA__ 物件,整理出:一般試看網址/VR試看網址/
// 各自的候選網址陣列(官方網址 + 猜測網址)/預覽圖列表
function extractWorkInfoFromNextData(nextData) {
const work = nextData && nextData.props && nextData.props.pageProps && nextData.props.pageProps.work;
const products = (work && work.products) || [];
let normalTrailerUrl = null;
let vrTrailerUrl = null;
let normalTrailerCode = null;
let vrTrailerCode = null;
let sampleImages = [];
products.forEach(p => {
if (!p) return;
const vr = isVRProduct(p, work);
if (p.sample_movie_url) {
if (vr && !vrTrailerUrl) vrTrailerUrl = p.sample_movie_url;
if (!vr && !normalTrailerUrl) normalTrailerUrl = p.sample_movie_url;
}
// 就算這個 product 沒有現成的 sample_movie_url,也先記下它的番號,
// 待會 buildTrailerUrlCandidates() 要用番號去猜網址
if (vr && !vrTrailerCode && p.product_id) vrTrailerCode = p.product_id;
if (!vr && !normalTrailerCode && p.product_id) normalTrailerCode = p.product_id;
if (!sampleImages.length && Array.isArray(p.sample_image_urls) && p.sample_image_urls.length) {
sampleImages = p.sample_image_urls
.map(s => ({ s: s && s.s, l: (s && s.l) || (s && s.s) }))
.filter(s => s.s || s.l);
}
});
const normalTrailerCandidates = dedupe([normalTrailerUrl].concat(buildTrailerUrlCandidates(normalTrailerCode, false)));
const vrTrailerCandidates = dedupe([vrTrailerUrl].concat(buildTrailerUrlCandidates(vrTrailerCode, true)));
return { normalTrailerUrl, vrTrailerUrl, normalTrailerCandidates, vrTrailerCandidates, sampleImages };
}
// 備援:__NEXT_DATA__ 抓不到時,用正規表示式硬找 sample_movie_url / sample_image_urls
// ※只在 JSON.parse 失敗時才會用到,正常情況不會走這條路
function extractSampleVideoUrlFallback(html) {
const patterns = [
/"sample_movie_url"\s*:\s*"([^"]+)"/i,
/<video[^>]+src="([^"]+?\.mp4[^"]*)"/i,
/(https?:\\\/\\\/[^"'<>\s]+?\.mp4)/i,
/(https?:\/\/[^"'<>\s]+?\.mp4)/i
];
for (const re of patterns) {
const m = html.match(re);
if (m && m[1] && m[1] !== 'null') return m[1].replace(/\\\//g, '/');
}
return null;
}
function extractSampleImageUrlsFallback(html) {
const m = html.match(/"sample_image_urls"\s*:\s*(\[[\s\S]*?\])/);
if (!m) return [];
try {
const arr = JSON.parse(m[1].replace(/\\\//g, '/'));
return arr.map(o => ({ s: o && o.s, l: (o && o.l) || (o && o.s) })).filter(o => o.s || o.l);
} catch (e) {
return [];
}
}
// 作品詳情頁畫面上實際的縮圖列,當 __NEXT_DATA__ 抓不到預覽圖時的最後備援
// 命名規則跟封面一樣:<cid>-<N>.jpg 或 <cid>jp-<N>.jpg
function findWorkDetailSampleImagesFromDom(cid) {
if (!cid) return [];
const re = new RegExp(cid + '(?:jp)?-(\\d+)\\.jpg', 'i');
const imgs = Array.from(document.querySelectorAll(`img[src*="${cid}"]`));
const seen = new Map();
imgs.forEach(img => {
const src = img.getAttribute('src') || '';
const m = src.match(re);
if (m) {
const n = parseInt(m[1], 10);
if (!seen.has(n)) seen.set(n, src);
}
});
return Array.from(seen.entries()).sort((a, b) => a[0] - b[0]).map(([, src]) => src);
}
// 抓單一作品的「一般試看候選網址/VR試看候選網址/預覽圖列表」,同一個作品只查一次(快取在 item 上)
function fetchWorkDetails(it) {
if (it._detailsPromise) return it._detailsPromise;
it._detailsPromise = (async () => {
let nextData = null;
let html = null;
if (it.isCurrentPage) {
const el = document.getElementById('__NEXT_DATA__');
if (el) { try { nextData = JSON.parse(el.textContent); } catch (e) { nextData = null; } }
} else {
try {
const res = await fetch(it.workHref, { credentials: 'same-origin' });
if (res.ok) {
html = await res.text();
nextData = extractNextDataFromHtml(html);
}
} catch (e) {
console.warn('[AvBase Reforge] 抓取作品頁失敗:', it.workHref, e);
}
}
let details = {
normalTrailerUrl: null,
vrTrailerUrl: null,
normalTrailerCandidates: [],
vrTrailerCandidates: [],
sampleImages: []
};
if (nextData) {
details = extractWorkInfoFromNextData(nextData);
} else if (html) {
// __NEXT_DATA__ 解析失敗時的備援路徑,沒有番號可以猜網址,只能用正規表示式硬找的那一條
const fallbackUrl = extractSampleVideoUrlFallback(html);
details.normalTrailerUrl = fallbackUrl;
details.normalTrailerCandidates = dedupe([fallbackUrl]);
details.vrTrailerCandidates = [];
details.sampleImages = extractSampleImageUrlsFallback(html);
}
if (it.isCurrentPage && !details.sampleImages.length && it.cid) {
details.sampleImages = findWorkDetailSampleImagesFromDom(it.cid).map(src => ({ s: src, l: src }));
}
return details;
})();
return it._detailsPromise;
}
/* =========================================================
* 7. 浮層(封面/預覽圖 + 試看播放 + VR 自動偵測 + sukebei/missav 快捷)
* ========================================================= */
function openGalleryFromCard(cardEl) {
// 收集同一個 avbase-grid2 網格內所有已重建的卡片,一起放進同一個浮層瀏覽
const grid = cardEl.closest('.avbase-grid2');
const scope = grid ? grid.querySelectorAll('.avbase-card2') : document.querySelectorAll('.avbase-grid2 .avbase-card2');
const items = Array.from(scope).map(c => c._avbaseItem).filter(Boolean);
const idx = items.findIndex(it => it.el === cardEl);
openGallery(items, idx >= 0 ? idx : 0);
}
// items:這次浮層可以左右切換的「作品」清單
// startIndex:一開始顯示 items 裡的第幾個作品
// initialPreviewIndex:只有第一次顯示那個作品時,要直接跳到它自己第幾張預覽圖
// (目前只有「作品詳情頁點下面縮圖列」會用到這個參數,其餘情況都是 0=封面)
function openGallery(items, startIndex, initialPreviewIndex) {
if (!items.length) return;
let index = startIndex;
let vrMode = false;
let previewList = []; // 目前這部作品自己的圖(封面 + 預覽圖),給左側視窗 + 上下鍵切換
let previewIndex = 0;
let requestToken = 0; // 防止「切作品切太快,舊的 fetch 結果蓋掉新的畫面」
const overlay = document.createElement('div');
overlay.className = 'avbase-overlay';
overlay.innerHTML = `
<button type="button" class="avbase-ov-close" aria-label="關閉">✕</button>
<div class="avbase-ov-shell">
<div class="avbase-ov-thumbside"><div class="avbase-ov-thumbside-inner"></div></div>
<div class="avbase-ov-main">
<button type="button" class="avbase-ov-prev" aria-label="上一個作品">❮</button>
<img class="avbase-ov-img" alt="">
<div class="avbase-ov-player"></div>
<button type="button" class="avbase-ov-next" aria-label="下一個作品">❯</button>
</div>
<div class="avbase-ov-side">
<div class="avbase-ov-title"></div>
<div class="avbase-ov-actors"></div>
<div class="avbase-ov-controls">
<button type="button" class="avbase-ov-play">▶ 播放預告</button>
<button type="button" class="avbase-ov-vr">🕶 VR 模式:關</button>
<button type="button" class="avbase-ov-sukebei">🔍 搜尋種子 (sukebei)</button>
<a class="avbase-ov-missav" target="_blank" rel="noopener">missav.ai ↗</a>
<a class="avbase-ov-open" target="_blank" rel="noopener">前往作品頁 ↗</a>
</div>
</div>
</div>
<div class="avbase-ov-bottomthumbs"></div>
`;
document.body.appendChild(overlay);
document.body.style.overflow = 'hidden';
const shellEl = overlay.querySelector('.avbase-ov-shell');
const thumbsideEl = overlay.querySelector('.avbase-ov-thumbside');
const thumbsideInner = overlay.querySelector('.avbase-ov-thumbside-inner');
const mainEl = overlay.querySelector('.avbase-ov-main');
const sideEl = overlay.querySelector('.avbase-ov-side');
const imgEl = overlay.querySelector('.avbase-ov-img');
const playerEl = overlay.querySelector('.avbase-ov-player');
const playBtn = overlay.querySelector('.avbase-ov-play');
const vrBtn = overlay.querySelector('.avbase-ov-vr');
const sukebeiBtn = overlay.querySelector('.avbase-ov-sukebei');
const missavLink = overlay.querySelector('.avbase-ov-missav');
const openLink = overlay.querySelector('.avbase-ov-open');
const titleEl = overlay.querySelector('.avbase-ov-title');
const actorsEl = overlay.querySelector('.avbase-ov-actors');
const prevBtn = overlay.querySelector('.avbase-ov-prev');
const nextBtn = overlay.querySelector('.avbase-ov-next');
const closeBtn = overlay.querySelector('.avbase-ov-close');
const bottomthumbsEl = overlay.querySelector('.avbase-ov-bottomthumbs');
// ---- 左側「這部作品自己的預覽圖」:固定 5 個格子,永遠只更新內容,不整批重建 DOM ----
const THUMBSIDE_SLOTS = 5;
const slotEls = [];
for (let s = 0; s < THUMBSIDE_SLOTS; s++) {
const el = document.createElement('div');
el.className = 'avbase-ov-thumb';
el.innerHTML = '<img loading="lazy">';
thumbsideInner.appendChild(el);
slotEls.push(el);
}
function renderThumbside() {
const total = previewList.length;
if (!total) { slotEls.forEach(el => { el.style.visibility = 'hidden'; }); return; }
const windowSize = Math.min(THUMBSIDE_SLOTS, total);
const start = total <= THUMBSIDE_SLOTS ? 0 : Math.min(Math.max(previewIndex - 2, 0), total - THUMBSIDE_SLOTS);
slotEls.forEach((el, slotPos) => {
const dataIndex = start + slotPos;
if (slotPos >= windowSize) { el.style.visibility = 'hidden'; return; }
el.style.visibility = '';
const entry = previewList[dataIndex];
const img = el.querySelector('img');
const candidates = [entry.thumbSrc].filter(Boolean).concat(entry.candidates || []);
// 觸發「淡出+下移」再拿掉,靠 CSS transition 自動補完「淡入+歸位」的動畫
el.classList.add('avbase-ov-thumb-anim');
withFallbackChain(img, candidates);
el.classList.toggle('active', dataIndex === previewIndex);
el.onclick = () => showImage(dataIndex);
requestAnimationFrame(() => {
requestAnimationFrame(() => el.classList.remove('avbase-ov-thumb-anim'));
});
});
}
// ---- 中間主圖:切換「目前這張是第幾張預覽圖」,上/下鍵、點縮圖都走這裡 ----
function showImage(i) {
if (!previewList.length) return;
previewIndex = (i + previewList.length) % previewList.length;
playerEl.style.display = 'none';
playerEl.innerHTML = '';
imgEl.style.display = '';
imgEl.style.opacity = '0.3';
withFallbackChain(imgEl, previewList[previewIndex].candidates);
imgEl.onload = () => { imgEl.style.opacity = '1'; };
renderThumbside();
}
// ---- 下方「所有作品」封面列:畫一次,之後只切換 active 狀態 ----
bottomthumbsEl.style.display = items.length > 1 ? '' : 'none';
items.forEach((it, i) => {
const t = document.createElement('div');
t.className = 'avbase-ov-bottomthumb';
t.innerHTML = '<img loading="lazy">';
withFallbackChain(t.querySelector('img'), it.lightboxCandidates);
t.addEventListener('click', () => render(i));
bottomthumbsEl.appendChild(t);
});
const bottomThumbEls = Array.from(bottomthumbsEl.children);
// ---- 切換「作品」:左右鍵、上/下一個作品按鈕、下方封面列都走這裡 ----
async function render(i, forcedPreviewIndex) {
index = (i + items.length) % items.length;
const it = items[index];
const myToken = ++requestToken;
// 先用已經有的封面資料把畫面畫出來,預覽圖之後非同步補上
previewList = [{
thumbSrc: (it.coverCandidates && it.coverCandidates[it.coverCandidates.length - 1]) || it.lightboxCandidates[0],
candidates: it.lightboxCandidates
}];
showImage(0);
titleEl.textContent = it.title || '(無標題)';
actorsEl.innerHTML = it.actorsHtml || '';
if (it.isCurrentPage) { openLink.style.display = 'none'; }
else { openLink.style.display = ''; openLink.href = it.workHref; }
missavLink.href = `https://missav.ai/${encodeURIComponent(extractWorkCode(it.workHref))}`;
playBtn.textContent = '▶ 播放預告';
playBtn.disabled = false;
vrMode = false;
vrBtn.textContent = '🕶 VR 模式:關';
prevBtn.style.display = items.length > 1 ? '' : 'none';
nextBtn.style.display = items.length > 1 ? '' : 'none';
bottomThumbEls.forEach((t, ti) => t.classList.toggle('active', ti === index));
if (bottomThumbEls[index]) bottomThumbEls[index].scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
const details = await fetchWorkDetails(it);
if (myToken !== requestToken) return; // 使用者已經切走了,這次結果作廢
it._details = details;
if (details.sampleImages && details.sampleImages.length) {
const extra = details.sampleImages.map(s => ({ thumbSrc: s.s || s.l, candidates: [s.l, s.s].filter(Boolean) }));
previewList = [previewList[0]].concat(extra);
}
if (typeof forcedPreviewIndex === 'number' && forcedPreviewIndex >= 0 && forcedPreviewIndex < previewList.length) {
showImage(forcedPreviewIndex);
} else {
renderThumbside();
}
}
// ---- 試看播放:先試一般試看候選網址(依序 fallback),全部失敗(或本來就沒有)才試 VR 候選網址;
// 成功用 VR 網址播出來的話,自動把 VR 模式切成「開」。過程中不顯示任何提示文字。 ----
function playSelected() {
const it = items[index];
const details = it._details;
if (!details) return;
const normalList = details.normalTrailerCandidates || [];
const vrList = details.vrTrailerCandidates || [];
const primaryList = vrMode ? (vrList.length ? vrList : normalList) : (normalList.length ? normalList : vrList);
const secondaryList = vrMode ? normalList : vrList;
if (!primaryList.length) return;
const primaryIsVR = vrMode ? !!vrList.length : false;
imgEl.style.display = 'none';
playerEl.style.display = 'block';
if (primaryIsVR) {
mountVRPlayer(playerEl, primaryList);
} else {
mountFlatPlayer(playerEl, primaryList, () => {
if (secondaryList && secondaryList.length) {
vrMode = true;
vrBtn.textContent = '🕶 VR 模式:開';
mountVRPlayer(playerEl, secondaryList);
}
});
}
}
async function handlePlay() {
const it = items[index];
playBtn.disabled = true;
playBtn.textContent = '載入中…';
if (!it._details) it._details = await fetchWorkDetails(it);
playBtn.disabled = false;
playBtn.textContent = '▶ 播放預告';
const hasNormal = it._details.normalTrailerCandidates && it._details.normalTrailerCandidates.length;
const hasVr = it._details.vrTrailerCandidates && it._details.vrTrailerCandidates.length;
if (!hasNormal && !hasVr) return; // 安靜略過,不顯示任何訊息
playSelected();
}
playBtn.addEventListener('click', handlePlay);
vrBtn.addEventListener('click', () => {
vrMode = !vrMode;
vrBtn.textContent = `🕶 VR 模式:${vrMode ? '開' : '關'}`;
if (playerEl.style.display === 'block' && playerEl.firstChild) playSelected();
});
sukebeiBtn.addEventListener('click', () => {
openSukebeiSearch(extractWorkCode(items[index].workHref));
});
function close() {
overlay.remove();
document.body.style.overflow = '';
document.removeEventListener('keydown', onKey);
}
function onKey(e) {
if (e.key === 'Escape') close();
else if (e.key === 'ArrowLeft') render(index - 1);
else if (e.key === 'ArrowRight') render(index + 1);
else if (e.key === 'ArrowUp') { e.preventDefault(); showImage(previewIndex - 1); }
else if (e.key === 'ArrowDown') { e.preventDefault(); showImage(previewIndex + 1); }
}
// 點空白處關閉:外層背景、shell、主圖區留白、左側縮圖列背景、右側面板背景、底部封面列背景都算「空白」
const blankEls = new Set([overlay, shellEl, mainEl, thumbsideEl, sideEl, bottomthumbsEl]);
closeBtn.addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (blankEls.has(e.target)) close(); });
prevBtn.addEventListener('click', () => render(index - 1));
nextBtn.addEventListener('click', () => render(index + 1));
document.addEventListener('keydown', onKey);
render(index, initialPreviewIndex);
requestAnimationFrame(() => overlay.classList.add('show'));
}
/* ---- 一般播放器:接受一組候選網址,依序試(跟封面圖 fallback chain 同精神),
全部試過都失敗才呼叫 onError(播放浮層的自動 VR 備援機制靠這個)---- */
function mountFlatPlayer(container, urls, onError) {
container.innerHTML = '';
const video = document.createElement('video');
video.className = 'avbase-trailer-video';
video.controls = true;
video.playsInline = true;
video.autoplay = true;
container.appendChild(video);
const list = dedupe(Array.isArray(urls) ? urls : [urls]);
let idx = 0;
function tryPlay() {
video.play().catch(() => { video.muted = true; video.play().catch(() => {}); });
}
function tryNext() {
if (idx >= list.length) {
video.removeEventListener('error', tryNext);
if (onError) onError();
return;
}
video.src = list[idx];
idx++;
tryPlay();
}
video.addEventListener('error', tryNext);
tryNext();
}
/* ---- VR 模式:只顯示 A-Frame videosphere 本身,同樣接受一組候選網址依序試 ---- */
let aframeLoadPromise = null;
function ensureAFrame() {
if (window.AFRAME) return Promise.resolve();
if (aframeLoadPromise) return aframeLoadPromise;
aframeLoadPromise = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://aframe.io/releases/1.5.0/aframe.min.js';
s.onload = () => resolve();
s.onerror = () => reject(new Error('aframe load failed'));
document.head.appendChild(s);
setTimeout(() => reject(new Error('aframe load timeout')), 8000);
});
return aframeLoadPromise;
}
async function mountVRPlayer(container, urls) {
const list = dedupe(Array.isArray(urls) ? urls : [urls]);
if (!list.length) return;
try {
await ensureAFrame();
} catch (e) {
mountFlatPlayer(container, list);
return;
}
container.innerHTML = `
<div class="avbase-vr-wrap">
<video id="avbase-vr-video" class="avbase-vr-source-video" crossorigin="anonymous" playsinline loop autoplay muted></video>
<a-scene embedded vr-mode-ui="enabled: true">
<a-videosphere src="#avbase-vr-video" rotation="0 -90 0"></a-videosphere>
<a-camera></a-camera>
</a-scene>
</div>
`;
const vid = container.querySelector('#avbase-vr-video');
let idx = 0;
function tryNext() {
if (idx >= list.length) { vid.removeEventListener('error', tryNext); return; } // 全部候選都失敗,安靜放棄,不顯示錯誤訊息
vid.src = list[idx];
idx++;
vid.play().catch(() => {});
}
vid.addEventListener('error', tryNext);
tryNext();
}
/* =========================================================
* 8. Sukebei (sukebei.nyaa.si) 種子搜尋
* 用 GM_xmlhttpRequest 是因為 sukebei 不會回應一般網頁的 CORS,
* 直接用 fetch() 會被瀏覽器擋,要靠 Tampermonkey 的跨網域權限繞過。
* ========================================================= */
function searchSukebei(query) {
return new Promise((resolve, reject) => {
if (typeof GM_xmlhttpRequest !== 'function') {
reject(new Error('此瀏覽器/腳本管理器不支援 GM_xmlhttpRequest'));
return;
}
const url = `https://sukebei.nyaa.si/?page=rss&q=${encodeURIComponent(query)}&sort=seeders&order=desc`;
GM_xmlhttpRequest({
method: 'GET',
url,
timeout: 15000,
onload: (res) => {
try { resolve(parseSukebeiRss(res.responseText)); }
catch (e) { reject(e); }
},
onerror: () => reject(new Error('network error')),
ontimeout: () => reject(new Error('timeout'))
});
});
}
// 解析 sukebei 的 RSS。磁力連結 sukebei 沒有直接給,靠 nyaa:infoHash 自己組
// magnet:?xt=urn:btih:<infoHash>&dn=<標題>。若 sukebei 改了 RSS 結構,要修的地方就是這裡。
function parseSukebeiRss(xmlText) {
const doc = new DOMParser().parseFromString(xmlText, 'text/xml');
const items = Array.from(doc.querySelectorAll('item'));
return items.map(it => {
const title = it.querySelector('title')?.textContent || '';
const link = it.querySelector('link')?.textContent || '';
const pubDate = it.querySelector('pubDate')?.textContent || '';
const size = it.getElementsByTagNameNS('*', 'size')[0]?.textContent || '';
const seeders = it.getElementsByTagNameNS('*', 'seeders')[0]?.textContent || '';
const leechers = it.getElementsByTagNameNS('*', 'leechers')[0]?.textContent || '';
const infoHash = it.getElementsByTagNameNS('*', 'infoHash')[0]?.textContent || '';
const magnet = infoHash ? `magnet:?xt=urn:btih:${infoHash}&dn=${encodeURIComponent(title)}` : '';
return { title, link, size, seeders, leechers, pubDate, magnet };
});
}
function copyTextToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
// 舊瀏覽器 / clipboard API 被擋掉時的備援
return new Promise((resolve, reject) => {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
resolve();
} catch (e) {
reject(e);
} finally {
ta.remove();
}
});
}
function openSukebeiSearch(defaultQuery) {
const overlay = document.createElement('div');
overlay.className = 'avbase-sukebei-overlay';
overlay.innerHTML = `
<div class="avbase-sukebei-card">
<div class="avbase-sukebei-head">
<strong>Sukebei 種子搜尋</strong>
<button type="button" class="avbase-sukebei-close" aria-label="關閉">✕</button>
</div>
<div class="avbase-sukebei-search-row">
<input type="text" class="avbase-sukebei-input" value="${escapeHtml(defaultQuery)}">
<button type="button" class="avbase-sukebei-go">搜尋</button>
</div>
<div class="avbase-sukebei-results"><p class="avbase-sukebei-hint">搜尋中…</p></div>
</div>
`;
document.body.appendChild(overlay);
const input = overlay.querySelector('.avbase-sukebei-input');
const resultsEl = overlay.querySelector('.avbase-sukebei-results');
const closeBtn = overlay.querySelector('.avbase-sukebei-close');
const goBtn = overlay.querySelector('.avbase-sukebei-go');
function close() {
overlay.remove();
document.removeEventListener('keydown', onKey);
}
function onKey(e) { if (e.key === 'Escape') close(); }
closeBtn.addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
document.addEventListener('keydown', onKey);
async function runSearch() {
const q = input.value.trim();
if (!q) return;
resultsEl.innerHTML = '<p class="avbase-sukebei-hint">搜尋中…</p>';
try {
const items = await searchSukebei(q);
if (!items.length) {
resultsEl.innerHTML = '<p class="avbase-sukebei-hint">找不到結果。</p>';
return;
}
resultsEl.innerHTML = '';
items.forEach(it => {
const row = document.createElement('div');
row.className = 'avbase-sukebei-item';
row.innerHTML = `
<div class="avbase-sukebei-item-title">${escapeHtml(it.title)}</div>
<div class="avbase-sukebei-item-meta">
<span>📦 ${escapeHtml(it.size || '-')}</span>
<span>🌱 ${escapeHtml(it.seeders || '0')}</span>
<span>🌊 ${escapeHtml(it.leechers || '0')}</span>
<span>${escapeHtml(it.pubDate || '')}</span>
</div>
<div class="avbase-sukebei-item-actions">
<a href="${escapeHtml(it.link || '#')}" target="_blank" rel="noopener">開啟頁面 ↗</a>
${it.magnet ? '<button type="button" class="avbase-sukebei-copy">複製磁鏈接</button>' : ''}
</div>
`;
const copyBtn = row.querySelector('.avbase-sukebei-copy');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
copyTextToClipboard(it.magnet)
.then(() => { copyBtn.textContent = '已複製 ✓'; setTimeout(() => { copyBtn.textContent = '複製磁鏈接'; }, 1500); })
.catch(() => {});
});
}
resultsEl.appendChild(row);
});
} catch (e) {
resultsEl.innerHTML = '<p class="avbase-sukebei-hint">搜尋失敗,可能是網路問題或來源網站改版。</p>';
console.warn('[AvBase Reforge] sukebei 搜尋失敗', e);
}
}
goBtn.addEventListener('click', runSearch);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') runSearch(); });
requestAnimationFrame(() => overlay.classList.add('show'));
runSearch();
}
/* =========================================================
* 9. 演員頁:整塊資訊搬到作品列表上方 + 側欄精簡(頭像/名字/フォロー/社交)
* ========================================================= */
function rebuildTalentLayout() {
if (!isTalentPage()) return;
// 外層 4 欄 grid(左:演員資訊欄,右:作品列表欄)改成單欄上下堆疊,
// 解決「演員資訊欄比較短、右邊作品列表比較長,左邊下方留白」的問題
const outerGrid = document.querySelector('.grid.grid-cols-1.lg\\:grid-cols-4.lg\\:gap-8');
if (outerGrid && outerGrid.dataset.avbaseStack !== '1') {
outerGrid.classList.add('avbase-talent-stack');
outerGrid.dataset.avbaseStack = '1';
}
}
function rebuildTalentSidebar() {
if (!isTalentPage()) return;
const avatarEl = document.querySelector('.lg\\:col-span-1 span[data-slot="avatar"]');
if (!avatarEl) return;
const sideCol = avatarEl.closest('.lg\\:col-span-1');
if (!sideCol || sideCol.dataset.avbaseDone === '1') return;
const nameEl = sideCol.querySelector('h1');
const followBtn = sideCol.querySelector('button[aria-label="フォローする"]');
const socialWrap = sideCol.querySelector('.group\\/social');
if (!nameEl) return; // 版面跟預期不符就不動它,避免破壞頁面
const avatarSrc = avatarEl.querySelector('img')?.getAttribute('src') || '';
const avatarAlt = avatarEl.querySelector('img')?.getAttribute('alt') || nameEl.textContent.trim();
const nameHtml = nameEl.innerHTML;
const followHtml = followBtn ? followBtn.outerHTML : '';
const socialHtml = socialWrap ? socialWrap.innerHTML : '';
// 其餘資訊(暱稱標籤/基本情報/プロフィール/編集履歴…)整塊原始 HTML 先存起來,
// 收進點擊頭像才打開的浮卡
const fullDetailHtml = sideCol.innerHTML;
sideCol.innerHTML = `
<div class="avbase-talent-mini">
<button type="button" class="avbase-talent-avatar-btn" aria-label="查看完整資料">
<img src="${escapeHtml(avatarSrc)}" alt="${escapeHtml(avatarAlt)}">
</button>
<div class="avbase-talent-name">${nameHtml}</div>
<div class="avbase-talent-follow">${followHtml}</div>
<div class="avbase-talent-social">${socialHtml}</div>
</div>
`;
sideCol.dataset.avbaseDone = '1';
sideCol.querySelector('.avbase-talent-avatar-btn').addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
openTalentDetailCard(fullDetailHtml);
});
}
function openTalentDetailCard(fullDetailHtml) {
const overlay = document.createElement('div');
overlay.className = 'avbase-talent-overlay';
overlay.innerHTML = `
<div class="avbase-talent-card">
<button type="button" class="avbase-talent-card-close" aria-label="關閉">✕</button>
<div class="avbase-talent-card-body"></div>
</div>
`;
overlay.querySelector('.avbase-talent-card-body').innerHTML = fullDetailHtml;
document.body.appendChild(overlay);
document.body.style.overflow = 'hidden';
function close() {
overlay.remove();
document.body.style.overflow = '';
document.removeEventListener('keydown', onKey);
}
function onKey(e) { if (e.key === 'Escape') close(); }
overlay.querySelector('.avbase-talent-card-close').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
document.addEventListener('keydown', onKey);
requestAnimationFrame(() => overlay.classList.add('show'));
}
/* =========================================================
* 9-1. 標籤頁:分類側欄精簡(跟演員頁側欄同一套版面,同樣手法處理)
* ========================================================= */
function rebuildTagSidebar() {
if (!isTagPage()) return;
// 標籤頁左欄跟演員頁左欄是同一種 `.lg:col-span-1` 版位,用「裡面有 /tags/ 連結」
// 這個結構特徵去找,順便排除已經處理過的
const sideCol = Array.from(document.querySelectorAll('.lg\\:col-span-1'))
.find(el => el.dataset.avbaseDone !== '1' && el.querySelector('a[href^="/tags/"]'));
if (!sideCol) return;
// 跟演員頁一樣:外層 4 欄 grid 改成上下堆疊,側欄精簡後才不會被拉開留白
const outerGrid = sideCol.closest('.grid');
if (outerGrid && outerGrid.dataset.avbaseStack !== '1') {
outerGrid.classList.add('avbase-talent-stack');
outerGrid.dataset.avbaseStack = '1';
}
// 結構:sideCol > 外層 wrap div > box(border 卡片) > [row1(標籤名稱+子標籤鈕+編輯鈕), followBtn]
const box = sideCol.querySelector(':scope > div > div');
if (!box) return;
const row1 = box.querySelector(':scope > div');
if (!row1) return;
const tagInfoWrap = row1.querySelector(':scope > div'); // 圖示 + 分類連結 + 子標籤展開鈕
const editBtn = row1.querySelector(':scope > button'); // 編輯(鉛筆)鈕
const followBtn = box.querySelector(':scope > button'); // フォロー鈕
const tagLink = sideCol.querySelector('a[href^="/tags/"]');
if (!tagLink) return;
sideCol.innerHTML = `
<div class="avbase-tag-mini">
<div class="avbase-tag-mini-row">
${tagInfoWrap ? tagInfoWrap.outerHTML : tagLink.outerHTML}
${editBtn ? editBtn.outerHTML : ''}
</div>
${followBtn ? followBtn.outerHTML : ''}
</div>
`;
sideCol.dataset.avbaseDone = '1';
}
/* =========================================================
* 10. 作品詳情頁:大封面 + 下方縮圖列點擊都開浮層,關聯作品也套同一套浮層
* ========================================================= */
let currentPageItem = null; // 作品詳情頁自己的畫廊項目,封面跟下方縮圖列共用同一份
// 從「出演者・メモ」區塊撈演員名單的 HTML(找標題是「出演者」的 h2,抓它同區塊底下的內容)
function extractWorkDetailActorsHtml() {
const headings = Array.from(document.querySelectorAll('h2'));
const h = headings.find(el => el.textContent.trim().startsWith('出演者'));
if (!h) return '';
const section = h.closest('section');
const wrap = section ? section.querySelector('.flex.flex-wrap.gap-2') : null;
return wrap ? wrap.innerHTML : '';
}
function rebuildWorkDetailCover() {
if (!isWorkDetailPage()) return;
// 找主封面:DMM 命名規則的大圖固定叫 pl.jpg,直接用這個特徵找,比對 class 穩定
const coverImg = document.querySelector('img[src*="pl.jpg"]');
if (!coverImg || coverImg.dataset.avbaseDone === '1') return;
coverImg.dataset.avbaseDone = '1';
const smallSrc = coverImg.getAttribute('src') || '';
const title = (document.querySelector('h1')?.textContent || '').trim();
const actorsHtml = extractWorkDetailActorsHtml();
const item = buildGalleryItem({ smallSrc, workHref: location.pathname, title, actorsHtml });
item.isCurrentPage = true;
currentPageItem = item;
const clickTarget = coverImg.closest('a') || coverImg;
clickTarget.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
openGallery([item], 0);
});
clickTarget.style.cursor = 'zoom-in';
}
// 封面下方那排可以左右滑動的預覽圖縮圖,點了也開同一個浮層,直接跳到那張圖
function rebuildWorkDetailSampleStrip() {
if (!isWorkDetailPage() || !currentPageItem || !currentPageItem.cid) return;
const cid = currentPageItem.cid;
const re = new RegExp(cid + '(?:jp)?-(\\d+)\\.jpg', 'i');
const links = Array.from(document.querySelectorAll('a[target="_blank"]')).filter(a => {
const img = a.querySelector('img');
return img && re.test(img.getAttribute('src') || '');
});
links.forEach(a => {
if (a.dataset.avbaseDone === '1') return;
a.dataset.avbaseDone = '1';
const img = a.querySelector('img');
const m = (img.getAttribute('src') || '').match(re);
const n = m ? parseInt(m[1], 10) : 1; // 第幾張預覽圖(1-based,跟 previewList 裡「封面之後」的順序對得上)
a.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
openGallery([currentPageItem], 0, n);
});
a.style.cursor = 'zoom-in';
});
}
// 「同じ女優の作品」「同じシリーズなどの作品」等側欄推薦卡片,點了也開同一套浮層
function rebuildRelatedArticles() {
if (!isWorkDetailPage()) return;
const asideSections = document.querySelectorAll('aside[aria-label="関連作品"] section');
asideSections.forEach(section => {
if (section.dataset.avbaseDone === '1') return;
const articles = Array.from(section.querySelectorAll('article'));
if (!articles.length) return;
const items = [];
articles.forEach(article => {
const link = article.querySelector('a[href^="/works/"]');
const img = article.querySelector('img');
const span = article.querySelector('span.line-clamp-3, span');
if (!link || !img) return;
const smallSrc = img.getAttribute('src') || '';
if (!smallSrc) return;
const title = (span ? span.textContent : link.textContent || '').trim();
const item = buildGalleryItem({ smallSrc, workHref: link.getAttribute('href') || '#', title, actorsHtml: '' });
items.push(item);
link.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const idx = items.indexOf(item);
openGallery(items, idx >= 0 ? idx : 0);
});
});
section.dataset.avbaseDone = '1';
});
}
/* =========================================================
* 11. 初始化 / 監聽 SPA 變化
* ========================================================= */
function runPageEnhancements() {
if (isCardGridPage()) {
normalizeLayout();
applyColumns();
rebuildAllCards();
}
if (isTalentPage()) {
rebuildTalentLayout();
rebuildTalentSidebar();
}
if (isTagPage()) {
rebuildTagSidebar();
}
if (isWorkDetailPage()) {
rebuildWorkDetailCover();
rebuildWorkDetailSampleStrip();
rebuildRelatedArticles();
}
}
function init() {
buildSettingsPanel();
applyColumns();
runPageEnhancements();
const mo = new MutationObserver(debounce(runPageEnhancements, 150));
mo.observe(document.body, { childList: true, subtree: true });
window.addEventListener('resize', debounce(applyColumns, 200));
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();