新增字幕直传115作品目录,支持多目录选择

This commit is contained in:
lzyme.dev@gmail.com
2026-08-30 03:08:17 +08:00
parent 8da3ef2ed5
commit 06e9f4e8b4
10 changed files with 2362 additions and 209 deletions
+937 -194
View File
File diff suppressed because it is too large Load Diff
+259
View File
@@ -454,6 +454,264 @@ const mainCss = `
</style> </style>
`; `;
const subtitleModalCss = `
<style>
.pdb-sub-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999999;
display: flex;
align-items: center;
justify-content: center;
}
.pdb-sub-modal {
background: #fff;
border-radius: 10px;
width: 85vw;
max-width: 1100px;
height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.2);
}
.pdb-sub-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
flex-shrink: 0;
}
.pdb-sub-search-wrap {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
}
.pdb-sub-title {
font-weight: 600;
font-size: 14px;
color: #374151;
white-space: nowrap;
}
.pdb-sub-input {
flex: 1;
max-width: 400px;
padding: 8px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.pdb-sub-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.pdb-sub-btn {
padding: 8px 18px;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
}
.pdb-sub-btn:hover {
background: #2563eb;
}
.pdb-sub-115-wrap {
display: flex;
align-items: center;
gap: 8px;
margin-left: 16px;
flex-shrink: 0;
}
.pdb-sub-checkbox-group {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.pdb-sub-checkbox-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #374151;
cursor: pointer;
white-space: nowrap;
}
.pdb-sub-checkbox {
margin: 0;
cursor: pointer;
}
.pdb-sub-close {
font-size: 26px;
color: #9ca3af;
cursor: pointer;
padding: 0 8px;
line-height: 1;
transition: color 0.2s;
}
.pdb-sub-close:hover {
color: #ef4444;
}
.pdb-sub-body {
display: flex;
flex: 1;
overflow: hidden;
}
.pdb-sub-content {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.pdb-sub-preview-wrap {
width: 45%;
display: flex;
flex-direction: column;
border-left: 1px solid #e5e7eb;
background: #1e1e1e;
}
.pdb-sub-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid #333;
color: #d1d5db;
font-size: 13px;
flex-shrink: 0;
}
.pdb-sub-preview-status {
font-size: 12px;
color: #6b7280;
}
.pdb-sub-textarea {
flex: 1;
background: #1e1e1e;
color: #e5e7eb;
border: none;
padding: 14px;
font-family: Consolas, Monaco, monospace;
font-size: 13px;
line-height: 1.5;
resize: none;
outline: none;
overflow-y: auto;
}
.pdb-sub-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.pdb-sub-table thead {
position: sticky;
top: 0;
z-index: 1;
}
.pdb-sub-th {
padding: 10px 12px;
background: #f3f4f6;
color: #374151;
font-weight: 600;
text-align: left;
border-bottom: 2px solid #e5e7eb;
white-space: nowrap;
}
.pdb-sub-tr {
transition: background 0.15s;
}
.pdb-sub-tr:hover {
background: #f9fafb;
}
.pdb-sub-tr.is-previewing {
background: #eff6ff;
outline: 2px solid #3b82f6;
outline-offset: -2px;
}
.pdb-sub-td,
.pdb-sub-td-lang,
.pdb-sub-td-ext,
.pdb-sub-td-provider {
padding: 9px 12px;
border-bottom: 1px solid #f3f4f6;
vertical-align: middle;
}
.pdb-sub-td {
word-break: break-all;
}
.pdb-sub-td-lang,
.pdb-sub-td-ext {
text-align: center;
white-space: nowrap;
}
.pdb-sub-td-actions {
padding: 9px 12px;
border-bottom: 1px solid #f3f4f6;
text-align: center;
white-space: nowrap;
}
.pdb-sub-provider {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.pdb-sub-provider--xunlei {
background: #dbeafe;
color: #1d4ed8;
}
.pdb-sub-provider--subtitlecat {
background: #fef3c7;
color: #92400e;
}
.pdb-sub-action-btn {
padding: 4px 12px;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
margin: 0 2px;
transition: opacity 0.2s;
}
.pdb-sub-btn-preview {
background: #e0f2fe;
color: #0369a1;
}
.pdb-sub-btn-preview:hover {
background: #bae6fd;
}
.pdb-sub-btn-download {
background: #dcfce7;
color: #166534;
}
.pdb-sub-btn-download:hover {
background: #bbf7d0;
}
.pdb-sub-btn-upload {
background: #ede9fe;
color: #5b21b6;
}
.pdb-sub-btn-upload:hover {
background: #ddd6fe;
}
.pdb-sub-msg {
text-align: center;
padding: 40px 20px;
color: #6b7280;
font-size: 14px;
}
</style>
`;
function insertStyle(css) { function insertStyle(css) {
if (css) if (css.includes("<style>")) document.head.insertAdjacentHTML("beforeend", css); else { if (css) if (css.includes("<style>")) document.head.insertAdjacentHTML("beforeend", css); else {
const style = document.createElement("style"); const style = document.createElement("style");
@@ -470,6 +728,7 @@ insertStyle(
); );
insertStyle(mainCss); insertStyle(mainCss);
insertStyle(subtitleModalCss);
export { export {
__defProp, __defProp,
-2
View File
@@ -2,7 +2,6 @@ import $ from 'jquery';
import localforage from 'localforage'; import localforage from 'localforage';
import Toastify from 'toastify-js'; import Toastify from 'toastify-js';
import md5 from 'blueimp-md5'; import md5 from 'blueimp-md5';
import { TabulatorFull as Tabulator } from 'tabulator-tables';
import Viewer from 'viewerjs'; import Viewer from 'viewerjs';
import QRCode from 'qrcodejs'; import QRCode from 'qrcodejs';
import { currentHref, isJavDb, isJavBus, isSearchPage, Status_RUNNING, Status_SUCCESS, Status_FAIL, Status_LOADING, Status_FILTER, Status_FAVORITE, Status_HAS_DOWN, Status_HAS_WATCH, NO, YES } from './constants.js'; import { currentHref, isJavDb, isJavBus, isSearchPage, Status_RUNNING, Status_SUCCESS, Status_FAIL, Status_LOADING, Status_FILTER, Status_FAVORITE, Status_HAS_DOWN, Status_HAS_WATCH, NO, YES } from './constants.js';
@@ -11,7 +10,6 @@ window.$ = window.jQuery = $;
window.localforage = localforage; window.localforage = localforage;
window.Toastify = Toastify; window.Toastify = Toastify;
window.md5 = md5; window.md5 = md5;
window.Tabulator = Tabulator;
window.Viewer = Viewer; window.Viewer = Viewer;
window.QRCode = QRCode; window.QRCode = QRCode;
+3 -1
View File
@@ -17,6 +17,8 @@ import { DetailPagePlugin } from './plugins/detail-page.js';
import { PreviewVideoPlugin } from './plugins/preview-video.js'; import { PreviewVideoPlugin } from './plugins/preview-video.js';
import { JavTrailersPlugin } from './plugins/jav-trailers.js'; import { JavTrailersPlugin } from './plugins/jav-trailers.js';
import { SubTitleCatPlugin } from './plugins/subtitle-cat.js'; import { SubTitleCatPlugin } from './plugins/subtitle-cat.js';
import {JavPackSubtitle} from './plugins/subtitles.js';
import {Req115} from './plugins/req115.js';
import { Fc2Plugin } from './plugins/fc2.js'; import { Fc2Plugin } from './plugins/fc2.js';
import { HighlightMagnetPlugin } from './plugins/highlight-magnet.js'; import { HighlightMagnetPlugin } from './plugins/highlight-magnet.js';
import { FoldCategoryPlugin } from './plugins/fold-category.js'; import { FoldCategoryPlugin } from './plugins/fold-category.js';
@@ -847,7 +849,7 @@ utils.importResource("https://cdn.jsdelivr.net/npm/toastify-js@1.12.0/src/toasti
utils.importResource("https://cdn.jsdelivr.net/npm/viewerjs@1.11.1/dist/viewer.min.css"); utils.importResource("https://cdn.jsdelivr.net/npm/viewerjs@1.11.1/dist/viewer.min.css");
utils.importResource("https://cdn.jsdelivr.net/npm/tabulator-tables@6.3.1/dist/css/tabulator_semanticui.min.css"); utils.importResource("https://cdn.jsdelivr.net/npm/tabulator-tables@6.5.2/dist/css/tabulator_semanticui.min.css");
const pluginManager = function() { const pluginManager = function() {
const pluginManager2 = new PluginManager; const pluginManager2 = new PluginManager;
+12 -4
View File
@@ -22,7 +22,8 @@ class DetailPageButtonPlugin extends BasePlugin {
window.isDetailPage && this.createMenuBtn(); window.isDetailPage && this.createMenuBtn();
} }
async createMenuBtn() { async createMenuBtn() {
const pageInfo = this.getPageInfo(), carNum2 = pageInfo.carNum, buttonsHtml = '\n <div style="margin: 10px auto; display: flex; justify-content: space-between; align-items: center; flex-wrap:wrap;gap: 20px;">\n <div style="display: flex; gap: 10px; flex-wrap:wrap;">\n <a id="filterBtn" class="menu-btn" style="width: 120px; background-color:#de3333; color: white; text-align: center; padding: 8px 0;">\n <span>🚫 屏蔽</span>\n </a>\n <a id="favoriteBtn" class="menu-btn" style="width: 120px; background-color:#25b1dc; color: white; text-align: center; padding: 8px 0;">\n <span>⭐ 收藏</span>\n </a>\n <a id="hasDownBtn" class="menu-btn" style="width: 120px; background-color:#7bc73b; color: white; text-align: center; padding: 8px 0;">\n <span>📥️ 已下载</span>\n </a>\n <a id="hasWatchBtn" class="menu-btn" style="width: 120px; background-color:#d7a80c; color: white; text-align: center; padding: 8px 0;">\n <span>🔍 已观看</span>\n </a>\n </div>\n \n <div style="display: flex; gap: 10px; flex-wrap:wrap;">\n <a id="enable-magnets-filter" class="menu-btn" style="width: 140px; background-color: #c2bd4c; color: white; text-align: center; padding: 8px 0;">\n <span id="magnets-span">关闭磁力过滤</span>\n </a>\n <a id="magnetSearchBtn" class="menu-btn" style="width: 120px; background: linear-gradient(to right, rgb(245,140,1), rgb(84,161,29)); color: white; text-align: center; padding: 8px 0;">\n <span>磁力搜索</span>\n </a>\n <a id="xunLeiSubtitleBtn" class="menu-btn" style="width: 120px; background: linear-gradient(to left, #375f7c, #2196F3); color: white; text-align: center; padding: 8px 0;">\n <span>字幕 (迅雷)</span>\n </a>\n <a id="search-subtitle-btn" class="menu-btn" style="width: 160px; background: linear-gradient(to bottom, #8d5656, rgb(196,159,91)); color: white; text-align: center; padding: 8px 0;">\n <span>字幕 (SubTitleCat)</span>\n </a>\n </div>\n </div>\n '; const pageInfo = this.getPageInfo(), carNum2 = pageInfo.carNum,
buttonsHtml = '\n <div style="margin: 10px auto; display: flex; justify-content: space-between; align-items: center; flex-wrap:wrap;gap: 20px;">\n <div style="display: flex; gap: 10px; flex-wrap:wrap;">\n <a id="filterBtn" class="menu-btn" style="width: 120px; background-color:#de3333; color: white; text-align: center; padding: 8px 0;">\n <span>🚫 屏蔽</span>\n </a>\n <a id="favoriteBtn" class="menu-btn" style="width: 120px; background-color:#25b1dc; color: white; text-align: center; padding: 8px 0;">\n <span>⭐ 收藏</span>\n </a>\n <a id="hasDownBtn" class="menu-btn" style="width: 120px; background-color:#7bc73b; color: white; text-align: center; padding: 8px 0;">\n <span>📥️ 已下载</span>\n </a>\n <a id="hasWatchBtn" class="menu-btn" style="width: 120px; background-color:#d7a80c; color: white; text-align: center; padding: 8px 0;">\n <span>🔍 已观看</span>\n </a>\n </div>\n \n <div style="display: flex; gap: 10px; flex-wrap:wrap;">\n <a id="enable-magnets-filter" class="menu-btn" style="width: 140px; background-color: #c2bd4c; color: white; text-align: center; padding: 8px 0;">\n <span id="magnets-span">关闭磁力过滤</span>\n </a>\n <a id="magnetSearchBtn" class="menu-btn" style="width: 120px; background: linear-gradient(to right, rgb(245,140,1), rgb(84,161,29)); color: white; text-align: center; padding: 8px 0;">\n <span>磁力搜索</span>\n </a>\n <a id="subtitleSearchBtn" class="menu-btn" style="width: 120px; background: linear-gradient(to left, #375f7c, #2196F3); color: white; text-align: center; padding: 8px 0;">\n <span>字幕搜索</span>\n </a>\n <!--\n <a id="xunLeiSubtitleBtn" class="menu-btn" style="width: 120px; background: linear-gradient(to left, #375f7c, #2196F3); color: white; text-align: center; padding: 8px 0;">\n <span>字幕 (迅雷)</span>\n </a>\n <a id="search-subtitle-btn" class="menu-btn" style="width: 160px; background: linear-gradient(to bottom, #8d5656, rgb(196,159,91)); color: white; text-align: center; padding: 8px 0;">\n <span>字幕 (SubTitleCat)</span>\n </a>\n -->\n </div>\n </div>\n ';
isJavDb && $(".tabs").after(buttonsHtml); isJavDb && $(".tabs").after(buttonsHtml);
isJavBus && $("#mag-submit-show").before(buttonsHtml); isJavBus && $("#mag-submit-show").before(buttonsHtml);
$("#favoriteBtn").on("click", (() => this.favoriteOne())); $("#favoriteBtn").on("click", (() => this.favoriteOne()));
@@ -57,8 +58,12 @@ class DetailPageButtonPlugin extends BasePlugin {
storageManager.saveSettingItem("enableMagnetsFilter", YES); storageManager.saveSettingItem("enableMagnetsFilter", YES);
} }
})); }));
$("#search-subtitle-btn").on("click", (event => utils.openPage(`https://subtitlecat.com/index.php?search=${carNum2}`, carNum2, !1, event))); $("#subtitleSearchBtn").on("click", (() => {
$("#xunLeiSubtitleBtn").on("click", (() => this.searchXunLeiSubtitle(carNum2))); window.JavPackSubtitle.openSearchModal({details: {code: carNum2}});
}));
window.JavPackSubtitle.preload115Matches(carNum2);
// $("#search-subtitle-btn").on("click", (event => utils.openPage(`https://subtitlecat.com/index.php?search=${carNum2}`, carNum2, !1, event)));
// $("#xunLeiSubtitleBtn").on("click", (() => this.searchXunLeiSubtitle(carNum2)));
this.showStatus(carNum2).then(); this.showStatus(carNum2).then();
} }
async showStatus(carNum2) { async showStatus(carNum2) {
@@ -124,6 +129,8 @@ class DetailPageButtonPlugin extends BasePlugin {
window.refresh(); window.refresh();
utils.closePage(); utils.closePage();
} }
/*
searchXunLeiSubtitle(carNum2) { searchXunLeiSubtitle(carNum2) {
let loadObj = loading(); let loadObj = loading();
gmHttp.get(`https://api-shoulei-ssl.xunlei.com/oracle/subtitle?gcid=&cid=&name=${carNum2}`).then((res => { gmHttp.get(`https://api-shoulei-ssl.xunlei.com/oracle/subtitle?gcid=&cid=&name=${carNum2}`).then((res => {
@@ -205,6 +212,7 @@ class DetailPageButtonPlugin extends BasePlugin {
loadObj.close(); loadObj.close();
})); }));
} }
*/
async filterOne(event, noAlert) { async filterOne(event, noAlert) {
event && event.preventDefault(); event && event.preventDefault();
let pageInfo = this.getPageInfo(); let pageInfo = this.getPageInfo();
@@ -333,4 +341,4 @@ class DetailPageButtonPlugin extends BasePlugin {
} }
export { DetailPageButtonPlugin }; export { DetailPageButtonPlugin };
+8 -4
View File
@@ -171,7 +171,7 @@ class Fc2By123AvPlugin extends BasePlugin {
async open123AvFc2Dialog(carNum2, href) { async open123AvFc2Dialog(carNum2, href) {
let otherHtml = ""; let otherHtml = "";
await storageManager.getSetting("enableLoadOtherSite", YES) === YES && (otherHtml = '<div class="movie-panel-info fc2-movie-panel-info" style="margin-top:20px"><strong>第三方站点: </strong></div>'); await storageManager.getSetting("enableLoadOtherSite", YES) === YES && (otherHtml = '<div class="movie-panel-info fc2-movie-panel-info" style="margin-top:20px"><strong>第三方站点: </strong></div>');
let pageHtml = `\n <div class="movie-detail-container">\n \x3c!-- <div class="movie-poster-container">\n <iframe class="movie-trailer" frameborder="0" allowfullscreen scrolling="no"></iframe>\n </div>\n <div class="right-box">--\x3e\n <div class="movie-info-container">\n <div class="search-loading">加载中...</div>\n </div>\n \n ${otherHtml}\n \n <div style="margin: 10px 0">\n <a id="filterBtn" class="menu-btn" style="background-color:#de3333"><span>🚫 屏蔽</span></a>\n <a id="favoriteBtn" class="menu-btn" style="background-color:#25b1dc"><span>⭐ 收藏</span></a>\n <a id="hasDownBtn" class="menu-btn" style="background-color:#7bc73b"><span>📥️ 已下载</span></a>\n <a id="hasWatchBtn" class="menu-btn" style="background-color:#d7a80c;"><span>🔍 已观看</span></a>\n \n <a id="search-subtitle-btn" class="menu-btn fr-btn" style="background:linear-gradient(to bottom, #8d5656, rgb(196,159,91))">\n <span>字幕 (SubTitleCat)</span>\n </a>\n <a id="xunLeiSubtitleBtn" class="menu-btn fr-btn" style="background:linear-gradient(to left, #375f7c, #2196F3)">\n <span>字幕 (迅雷)</span>\n </a>\n </div>\n <div class="message video-panel" style="margin-top:20px">\n <div id="magnets-content" class="magnet-links">\n </div>\n </div>\n <div id="reviews-content">\n </div>\n <div id="related-content">\n </div>\n <span id="data-actress" style="display: none"></span>\n \x3c!-- </div>--\x3e\n </div>\n `; let pageHtml = `\n <div class="movie-detail-container">\n \x3c!-- <div class="movie-poster-container">\n <iframe class="movie-trailer" frameborder="0" allowfullscreen scrolling="no"></iframe>\n </div>\n <div class="right-box">--\x3e\n <div class="movie-info-container">\n <div class="search-loading">加载中...</div>\n </div>\n \n ${otherHtml}\n \n <div style="margin: 10px 0">\n <a id="filterBtn" class="menu-btn" style="background-color:#de3333"><span>🚫 屏蔽</span></a>\n <a id="favoriteBtn" class="menu-btn" style="background-color:#25b1dc"><span>⭐ 收藏</span></a>\n <a id="hasDownBtn" class="menu-btn" style="background-color:#7bc73b"><span>📥️ 已下载</span></a>\n <a id="hasWatchBtn" class="menu-btn" style="background-color:#d7a80c;"><span>🔍 已观看</span></a>\n \n <a id="subtitleSearchBtn" class="menu-btn fr-btn" style="background:linear-gradient(to left, #375f7c, #2196F3)">\n <span>字幕搜索</span>\n </a>\n <!--\n <a id="search-subtitle-btn" class="menu-btn fr-btn" style="background:linear-gradient(to bottom, #8d5656, rgb(196,159,91))">\n <span>字幕 (SubTitleCat)</span>\n </a>\n <a id="xunLeiSubtitleBtn" class="menu-btn fr-btn" style="background:linear-gradient(to left, #375f7c, #2196F3)">\n <span>字幕 (迅雷)</span>\n </a>\n -->\n </div>\n <div class="message video-panel" style="margin-top:20px">\n <div id="magnets-content" class="magnet-links">\n </div>\n </div>\n <div id="reviews-content">\n </div>\n <div id="related-content">\n </div>\n <span id="data-actress" style="display: none"></span>\n \x3c!-- </div>--\x3e\n </div>\n `;
layer.open({ layer.open({
type: 1, type: 1,
title: carNum2, title: carNum2,
@@ -235,8 +235,12 @@ class Fc2By123AvPlugin extends BasePlugin {
window.refresh(); window.refresh();
layer.closeAll(); layer.closeAll();
})); }));
$("#search-subtitle-btn").on("click", (event => utils.openPage(`https://subtitlecat.com/index.php?search=${carNum2}`, carNum2, !1, event))); $("#subtitleSearchBtn").on("click", (() => {
$("#xunLeiSubtitleBtn").on("click", (() => this.getBean("DetailPageButtonPlugin").searchXunLeiSubtitle(carNum2))); window.JavPackSubtitle.openSearchModal({details: {code: carNum2}});
}));
window.JavPackSubtitle.preload115Matches(carNum2);
// $("#search-subtitle-btn").on("click", (event => utils.openPage(`https://subtitlecat.com/index.php?search=${carNum2}`, carNum2, !1, event)));
// $("#xunLeiSubtitleBtn").on("click", (() => this.getBean("DetailPageButtonPlugin").searchXunLeiSubtitle(carNum2)));
let tempCarNum = carNum2.replace("FC2-", ""); let tempCarNum = carNum2.replace("FC2-", "");
this.getBean("OtherSitePlugin").loadOtherSite(tempCarNum, carNum2).then(); this.getBean("OtherSitePlugin").loadOtherSite(tempCarNum, carNum2).then();
} }
@@ -351,4 +355,4 @@ class Fc2By123AvPlugin extends BasePlugin {
} }
export { Fc2By123AvPlugin }; export { Fc2By123AvPlugin };
+7 -3
View File
@@ -33,7 +33,7 @@ class Fc2Plugin extends BasePlugin {
layer.open({ layer.open({
type: 1, type: 1,
title: carNum2, title: carNum2,
content: '\n <div class="movie-detail-container">\n \x3c!--<div class="movie-poster-container">\n <iframe class="movie-trailer" frameborder="0" allowfullscreen scrolling="no"></iframe>\n </div>--\x3e\n \x3c!-- <div class="right-box">--\x3e\n <div class="movie-info-container">\n <div class="search-loading">加载中...</div>\n </div>\n \n <div class="movie-panel-info fc2-movie-panel-info" style="margin-top:20px"><strong>第三方资源: </strong></div>\n \n <div style="margin: 30px 0">\n <a id="filterBtn" class="menu-btn" style="background-color:#de3333"><span>🚫 屏蔽</span></a>\n <a id="favoriteBtn" class="menu-btn" style="background-color:#25b1dc"><span>⭐ 收藏</span></a>\n <a id="hasDownBtn" class="menu-btn" style="background-color:#7bc73b"><span>📥️ 已下载</span></a>\n <a id="hasWatchBtn" class="menu-btn" style="background-color:#d7a80c;"><span>🔍 已观看</span></a>\n \n <a id="search-subtitle-btn" class="menu-btn fr-btn" style="background:linear-gradient(to bottom, #8d5656, rgb(196,159,91))">\n <span>字幕 (SubTitleCat)</span>\n </a>\n <a id="xunLeiSubtitleBtn" class="menu-btn fr-btn" style="background:linear-gradient(to left, #375f7c, #2196F3)">\n <span>字幕 (迅雷)</span>\n </a>\n <a id="magnetSearchBtn" class="menu-btn fr-btn" style="width: 120px; background: linear-gradient(to right, rgb(245,140,1), rgb(84,161,29)); color: white; text-align: center; padding: 8px 0;">\n <span>磁力搜索</span>\n </a>\n </div>\n <div class="message video-panel" style="margin-top:20px">\n <div id="magnets-content" class="magnet-links" style="margin: 0 0.75rem">\n <div class="search-loading">加载中...</div>\n </div>\n </div>\n <div id="reviews-content">\n </div>\n <div id="related-content">\n </div>\n <span id="data-actress" style="display: none"></span>\n \x3c!--</div>--\x3e\n </div>\n ', content: '\n <div class="movie-detail-container">\n \x3c!--<div class="movie-poster-container">\n <iframe class="movie-trailer" frameborder="0" allowfullscreen scrolling="no"></iframe>\n </div>--\x3e\n \x3c!-- <div class="right-box">--\x3e\n <div class="movie-info-container">\n <div class="search-loading">加载中...</div>\n </div>\n \n <div class="movie-panel-info fc2-movie-panel-info" style="margin-top:20px"><strong>第三方资源: </strong></div>\n \n <div style="margin: 30px 0">\n <a id="filterBtn" class="menu-btn" style="background-color:#de3333"><span>🚫 屏蔽</span></a>\n <a id="favoriteBtn" class="menu-btn" style="background-color:#25b1dc"><span>⭐ 收藏</span></a>\n <a id="hasDownBtn" class="menu-btn" style="background-color:#7bc73b"><span>📥️ 已下载</span></a>\n <a id="hasWatchBtn" class="menu-btn" style="background-color:#d7a80c;"><span>🔍 已观看</span></a>\n \n <a id="subtitleSearchBtn" class="menu-btn fr-btn" style="background:linear-gradient(to left, #375f7c, #2196F3)">\n <span>字幕搜索</span>\n </a>\n <!--\n <a id="search-subtitle-btn" class="menu-btn fr-btn" style="background:linear-gradient(to bottom, #8d5656, rgb(196,159,91))">\n <span>字幕 (SubTitleCat)</span>\n </a>\n <a id="xunLeiSubtitleBtn" class="menu-btn fr-btn" style="background:linear-gradient(to left, #375f7c, #2196F3)">\n <span>字幕 (迅雷)</span>\n </a>\n -->\n <a id="magnetSearchBtn" class="menu-btn fr-btn" style="width: 120px; background: linear-gradient(to right, rgb(245,140,1), rgb(84,161,29)); color: white; text-align: center; padding: 8px 0;">\n <span>磁力搜索</span>\n </a>\n </div>\n <div class="message video-panel" style="margin-top:20px">\n <div id="magnets-content" class="magnet-links" style="margin: 0 0.75rem">\n <div class="search-loading">加载中...</div>\n </div>\n </div>\n <div id="reviews-content">\n </div>\n <div id="related-content">\n </div>\n <span id="data-actress" style="display: none"></span>\n \x3c!--</div>--\x3e\n </div>\n ',
area: utils.getResponsiveArea([ "70%", "90%" ]), area: utils.getResponsiveArea([ "70%", "90%" ]),
skin: "movie-detail-layer", skin: "movie-detail-layer",
scrollbar: !1, scrollbar: !1,
@@ -90,8 +90,12 @@ class Fc2Plugin extends BasePlugin {
window.refresh(); window.refresh();
layer.closeAll(); layer.closeAll();
})); }));
$("#search-subtitle-btn").on("click", (event => utils.openPage(`https://subtitlecat.com/index.php?search=${carNum2}`, carNum2, !1, event))); $("#subtitleSearchBtn").on("click", (() => {
$("#xunLeiSubtitleBtn").on("click", (() => this.getBean("DetailPageButtonPlugin").searchXunLeiSubtitle(carNum2))); window.JavPackSubtitle.openSearchModal({details: {code: carNum2}});
}));
window.JavPackSubtitle.preload115Matches(carNum2);
// $("#search-subtitle-btn").on("click", (event => utils.openPage(`https://subtitlecat.com/index.php?search=${carNum2}`, carNum2, !1, event)));
// $("#xunLeiSubtitleBtn").on("click", (() => this.getBean("DetailPageButtonPlugin").searchXunLeiSubtitle(carNum2)));
$("#magnetSearchBtn").on("click", (() => { $("#magnetSearchBtn").on("click", (() => {
let magnetHub = this.getBean("MagnetHubPlugin").createMagnetHub(carNum2); let magnetHub = this.getBean("MagnetHubPlugin").createMagnetHub(carNum2);
layer.open({ layer.open({
+528
View File
@@ -0,0 +1,528 @@
import {GM_xmlhttpRequest} from 'vite-plugin-monkey/dist/client';
class Req {
static DEFAULT_GET_RESPONSE_TYPE = "document";
static DEFAULT_POST_RESPONSE_TYPE = "json";
static DEFAULT_TIMEOUT = 30000;
static DEFAULT_METHOD = "GET";
static _isRawBody = (v) => v instanceof FormData || v instanceof Blob || v instanceof ArrayBuffer || v instanceof URLSearchParams;
static _isPlainObject = (v) => Object.prototype.toString.call(v) === "[object Object]";
static _serializeToFormData(formData, data, parentKey = "") {
Object.entries(data).forEach(([key, val]) => {
const fullKey = parentKey ? `${parentKey}[${key}]` : key;
if (val === undefined) return;
if (val !== null && typeof val === "object" && !(val instanceof Blob || val instanceof Date)) {
this._serializeToFormData(formData, val, fullKey);
} else {
formData.append(fullKey, val instanceof Date ? val.toISOString() : val);
}
});
}
static request(config) {
let options;
if (typeof config === "string") {
options = {url: config};
} else if (config !== null && typeof config === "object") {
options = {...config};
} else {
return Promise.reject(new TypeError("Request config must be a string or an object"));
}
if (!options.url) return Promise.reject(new Error("Request URL is required"));
options.method = (options.method || this.DEFAULT_METHOD).toUpperCase();
options.timeout ||= this.DEFAULT_TIMEOUT;
const {params, method, data, signal} = options;
try {
const urlInstance = new URL(options.url, location.origin);
if (params) {
const cleanParams = this._isPlainObject(params) ? Object.fromEntries(Object.entries(params).filter(([_, v]) => v != null)) : params;
new URLSearchParams(cleanParams).forEach((v, k) => urlInstance.searchParams.set(k, v));
}
options.url = urlInstance.toString();
} catch {
return Promise.reject(new Error(`Invalid Request URL: ${options.url}`));
}
if (method === "POST") {
options.responseType ??= this.DEFAULT_POST_RESPONSE_TYPE;
if (this._isPlainObject(data) && !this._isRawBody(data)) {
const formData = new FormData();
this._serializeToFormData(formData, data);
options.data = formData;
}
} else if (method === "GET") {
options.responseType ??= options.url.split(/[?#]/)[0].endsWith(".json") ? "json" : this.DEFAULT_GET_RESPONSE_TYPE;
}
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(new DOMException("Aborted", "AbortError"));
const {url: targetUrl} = options;
let xhrHandler;
const handleAbort = () => xhrHandler?.abort();
const finalize = (settleFn, ...args) => {
signal?.removeEventListener("abort", handleAbort);
settleFn(...args);
};
xhrHandler = GM_xmlhttpRequest({
onabort: () => reject(new DOMException("Aborted", "AbortError")),
ontimeout: () => finalize(reject, new Error(`Timeout: ${targetUrl}`)),
onerror: () => finalize(reject, new Error(`Network Error: ${targetUrl}`)),
onload: ({
status,
finalUrl,
response
}) => (status >= 400 ? finalize(reject, new Error(`HTTP ${status}: ${targetUrl}`)) : finalize(resolve, method === "HEAD" ? finalUrl : response)),
...options,
params: undefined,
});
signal?.addEventListener("abort", handleAbort, {once: true});
});
}
static async tasks(initial, steps, signal) {
let ctx = initial;
for (const step of steps) {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (typeof ctx === "string" || ctx?.url) ctx = await this.request(typeof ctx === "string" ? {
url: ctx,
signal
} : {...ctx, signal});
ctx = await step(ctx);
}
return ctx;
}
}
class Drive115 extends Req {
static DEFAULT_GET_RESPONSE_TYPE = "json";
static files(cid = "0", params = {}) {
return this.request({
url: "https://webapi.115.com/files",
params: {cid, ...params},
});
}
static filesSearch(search_value, params = {}) {
return this.request({
url: "https://webapi.115.com/files/search",
params: {search_value, ...params},
});
}
static lixianTaskLists(page = 1) {
return this.request({
url: "https://115.com/web/lixian/",
params: {ct: "lixian", ac: "task_lists", page},
});
}
static labelList() {
return this.request({
url: "https://webapi.115.com/label/list",
params: {keyword: "", limit: 11150},
});
}
/**
* @connect 115vod.com
*/
static filesVideo(pickcode) {
return this.request({
url: "https://115vod.com/webapi/files/video",
params: {pickcode, local: 1},
});
}
static post(details) {
return this.request({method: "POST", ...details});
}
static filesAdd(cname, pid) {
return this.post({
url: "https://webapi.115.com/files/add",
data: {cname, pid},
});
}
static lixianAddTaskUrl(url, wp_path_id) {
return this.post({
url: "https://115.com/web/lixian/",
params: {ct: "lixian", ac: "add_task_url"},
data: {url, wp_path_id},
});
}
/**
* Bulk delete offline tasks and source files
* @param {string[]} hash Array of info_hashes
*/
static lixianTaskDel(hash) {
return this.post({
url: "https://115.com/web/lixian/",
params: {ct: "lixian", ac: "task_del"},
data: {hash},
});
}
/**
* Bulk delete files
* @param {string[]} fid Array of file IDs
* @param {string} pid Parent folder ID
*/
static rbDelete(fid, pid = "") {
return this.post({
url: "https://webapi.115.com/rb/delete",
data: {fid, pid, ignore_warn: 1},
});
}
/**
* Batch move files
* @param {string[]} fid Array of file IDs
* @param {string} pid Destination folder ID
*/
static filesMove(fid, pid) {
return this.post({
url: "https://webapi.115.com/files/move",
data: {fid, pid, move_proid: ""},
});
}
/**
* Bulk label files
* @param {string} file_ids fid1,fid2,fid3...
* @param {string} file_label label_id1,label_id2,label_id3...
*/
static filesBatchLabel(file_ids, file_label, action = "add") {
return this.post({
url: "https://webapi.115.com/files/batch_label",
data: {file_ids, file_label, action},
});
}
/**
* Bulk rename files
* @param {object} files_new_name { [fid]: rename }
*/
static filesBatchRename(files_new_name) {
return this.post({
url: "https://webapi.115.com/files/batch_rename",
data: {files_new_name},
});
}
static sampleInitUpload({filename, filesize, cid}) {
return this.post({
url: "https://uplb.115.com/3.0/sampleinitupload.php",
data: {filename, filesize, target: `U_1_${cid}`},
});
}
/**
* @connect aliyuncs.com
*/
static upload({
host: url,
filename: name,
object: key,
policy,
accessid: OSSAccessKeyId,
callback,
signature,
file,
}) {
return this.post({
url,
data: {
name,
key,
policy,
OSSAccessKeyId,
success_action_status: "200",
callback,
signature,
file,
},
});
}
static filesEdit(fid, fid_cover) {
return this.post({
url: "https://webapi.115.com/files/edit",
data: {fid, fid_cover},
});
}
}
export class Req115 extends Drive115 {
static async filesAll(cid, params = {}) {
const res = await this.files(cid, params);
const {count, page_size, data} = res;
return count > page_size && data.length ? this.files(cid, {...params, limit: count}) : res;
}
static filesAllVideos(cid, params = {}) {
return this.filesAll(cid, {...params, type: 4});
}
static filesAllSRTs(cid, params = {}) {
return this.filesAll(cid, {...params, suffix: "srt"});
}
static async filesSearchAll(search_value, params = {}) {
const res = await this.filesSearch(search_value, params);
const {count, page_size, data} = res;
return count > page_size && data.length ? this.filesSearch(search_value, {...params, limit: count}) : res;
}
static filesSearchAllVideos(search_value, params = {}) {
return this.filesSearchAll(search_value, {...params, type: 4});
}
static filesSearchAllFolders(search_value, params = {}) {
return this.filesSearchAll(search_value, {...params, fc: 1});
}
static async handleDir(routes) {
if (routes.length === 1 && /^\d{5,}$/.test(routes[0])) return routes[0];
let cid;
const routesStr = routes.join("/");
const cachedCid = localStorage.getItem(routesStr);
if (cachedCid) {
const res = await this.files(cachedCid);
if (res?.path?.length) {
const path = res.path.slice(1).map((p) => p.name);
if (path.join("/") === routesStr) cid = cachedCid;
}
}
if (!cid) {
cid = "0";
for (const route of routes) {
const {data} = await this.filesSearchAllFolders(route, {cid});
let folder = data.find((folder) => folder.n === route);
if (!folder) folder = await this.filesAdd(route, cid);
cid = folder?.cid;
if (!cid) break;
}
}
const month = new Date().getMonth().toString();
if (localStorage.getItem("115_CD") !== month) {
localStorage.clear();
localStorage.setItem("115_CD", month);
}
if (cid) localStorage.setItem(routesStr, cid);
return cid;
}
static async handleVerify(info_hash, {regex, codes}, {max, filter}) {
const sleep = () => {
return new Promise((r) => {
setTimeout(r, 1000);
});
};
let file_id = "";
let videos = [];
for (let index = 0; index < max; index++) {
if (index) await sleep();
const {tasks} = await this.lixianTaskLists();
const task = tasks.find((task) => task.info_hash === info_hash);
if (!task || task.status === -1) break;
file_id = task.file_id;
if (file_id) break;
}
if (!file_id) return {file_id, videos};
for (let index = 0; index < max; index++) {
if (index) await sleep();
const {data} = await this.filesAllVideos(file_id);
videos = data.filter((item) => regex.test(item.n));
if (videos.length) break;
}
if (!videos.length) {
const {tasks} = await this.lixianTaskLists();
const task = tasks.find((task) => task.info_hash === info_hash);
if (task.status === 2) {
const {data} = await this.filesAllVideos(file_id);
codes = codes.map((code) => code.toUpperCase());
videos = data.filter((item) => {
const name = item.n.toUpperCase();
return codes.some((code) => name.includes(code));
});
}
}
return {videos: videos.filter(filter), file_id};
}
static async handleClean(keepFiles, cid) {
const needMove = keepFiles.filter((file) => file.cid !== cid).map((file) => file.fid);
if (needMove.length) await this.filesMove(needMove, cid);
const {data} = await this.filesAll(cid);
const needRemove = data
.filter((item) => !keepFiles.some((file) => file.fid === item.fid))
.map((item) => item.fid ?? item.cid);
if (needRemove.length) return this.rbDelete(needRemove, cid);
}
static async handleTags(files, tags) {
const {data} = await this.labelList();
if (!data?.list?.length) return;
const {list} = data;
const labels = [];
tags.forEach((tag) => {
const item = list.find((item) => item.name === tag);
if (item) labels.push(item.id);
});
if (labels.length) return this.filesBatchLabel(files.map((it) => it.fid).toString(), labels.toString());
}
static handleRename(files, cid, {rename, renameTxt, zh, crack}) {
rename = rename.replaceAll("$zh", zh ? renameTxt.zh : "");
rename = rename.replaceAll("$crack", crack ? renameTxt.crack : "");
rename = rename.split("$sep").filter(Boolean).join(renameTxt.sep);
rename = rename.trim();
const renameObj = {[cid]: rename};
if (files.length === 1) {
const {fid, ico} = files[0];
renameObj[fid] = `${rename}.${ico}`;
return this.filesBatchRename(renameObj);
}
const icoMap = files.reduce((acc, {ico, ...item}) => {
acc[ico] ??= [];
acc[ico].push(item);
return acc;
}, {});
const noTxt = renameTxt.no;
for (const [ico, items] of Object.entries(icoMap)) {
if (items.length === 1) {
renameObj[items[0].fid] = `${rename}.${ico}`;
continue;
}
items
.toSorted((a, b) => a.n.localeCompare(b.n))
.forEach(({fid}, idx) => {
const no = noTxt.replaceAll(`\${no}`, `${idx + 1}`.padStart(2, "0"));
renameObj[fid] = `${rename}${no}.${ico}`;
});
}
return this.filesBatchRename(renameObj);
}
static async handleCover(url, cid, filename) {
const file = await this.request({url, timeout: 60000, responseType: "blob"});
if (!file) return;
const res = await this.sampleInitUpload({cid, filename, filesize: file.size});
if (res?.host) return this.upload({...res, filename, file});
}
static async handleOffline(
{dir, regex, codes, verifyOptions, code, rename, renameTxt, tags, clean, cover},
magnets,
) {
const res = {status: "error", msg: `获取目录失败: ${dir.join("/")}`};
const cid = await this.handleDir(dir);
if (!cid) return res;
for (let index = 0, {length} = magnets; index < length; index++) {
const {url, zh, crack} = magnets[index];
const {state, error_msg, errcode, info_hash} = await this.lixianAddTaskUrl(url, cid);
if (!state) {
res.msg = error_msg;
res.status = "error";
res.currIdx = index;
if (errcode === 10008) continue;
if (errcode === 911) res.status = "warn";
break;
}
const {videos, file_id} = await this.handleVerify(info_hash, {regex, codes}, verifyOptions);
if (!videos.length) {
if (verifyOptions.clean) this.lixianTaskDel([info_hash]);
if (file_id && clean) this.rbDelete([file_id], cid);
res.msg = `${code} 离线验证失败`;
res.status = "error";
continue;
}
const {data: srts = []} = await this.filesAllSRTs(file_id);
const files = [...videos, ...srts];
if (clean) await this.handleClean(files, file_id);
if (tags.length) this.handleTags(videos, tags);
if (rename) this.handleRename(files, file_id, {rename, renameTxt, zh: zh || srts.length, crack});
if (cover) {
try {
const {data} = await this.handleCover(cover, file_id, `${code}.cover.jpg`);
if (data?.file_id) this.filesEdit(file_id, data.file_id);
} catch (err) {
console.warn("[Req115.handleCover]", err?.message);
}
}
res.msg = `${code} 离线任务成功`;
res.status = "success";
break;
}
return res;
}
static async queueMutation(name, fn) {
return fn();
}
}
window.Req115 = Req115;
+607
View File
@@ -0,0 +1,607 @@
import {GM_xmlhttpRequest} from 'vite-plugin-monkey/dist/client';
/**
* @name JavPack Subtitle Library
* @description Xunlei and SubtitleCat subtitle search, preview, download, and 115 upload for JavDB.
*/
export class JavPackSubtitle {
static BTN_ID = "x-subtitle-search-btn";
static MODAL_ID = "x-subtitle-modal";
static previewCache = new Map();
static cached115Matches = [];
static current115Cids = [];
static last115Code = "";
static SUBTITLECAT_ORIGIN = "https://www.subtitlecat.com";
static SUBTITLECAT_CHINESE_LANGUAGES = new Map([
["zh-CN", "简中"],
["zh-TW", "繁中"],
]);
static escapeHtml(value = "") {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
static sanitizeName(value = "") {
return String(value).replace(/[\\/:*?"<>|]/g, "").replace(/\s+/g, " ").trim();
}
static isSubtitleFile(file = {}) {
return /\.(srt|ass|ssa|vtt|sub)$/i.test(file.n || "");
}
static buildDefaultKeyword(details = {}) {
return this.sanitizeName(details.code || "") || this.sanitizeName([details.code, details.title].filter(Boolean).join(" ")) || document.title;
}
static buildSearchKeywords(details = {}) {
return [this.buildDefaultKeyword(details)].filter(Boolean);
}
static buildSubtitleBaseName(details = {}) {
if (window.JavPackMatch115Console?.buildRename) {
return window.JavPackMatch115Console.buildRename(details, [{n: `${details.code || "subtitle"}.mp4`}]);
}
return this.sanitizeName([details.code, details.title].filter(Boolean).join(" ")) || this.buildDefaultKeyword(details);
}
static buildSubtitleFilename(details = {}, item = {}) {
const ext = this.sanitizeName(item.ext || "srt").replace(/^\.+/, "") || "srt";
return `${this.buildSubtitleBaseName(details)}.${ext}`;
}
static clearPreviewCache() {
this.previewCache.clear();
}
static async checkSubInCloud(req115, cid) {
if (!req115 || !cid) return false;
const res = await req115.filesAll(cid);
return (res?.data || []).some((file) => this.isSubtitleFile(file));
}
static getReq115() {
return window.Req115 || null;
}
static getGrant() {
return window.Grant || null;
}
static getTargetCid() {
return document.querySelector(".x-match-cont .zymatch-item [data-cid]")?.dataset.cid
|| document.querySelector(".x-match-cont .zymatch-item")?.dataset.cid
|| "";
}
static async find115Cid(code) {
if (!code) return "";
const req115 = this.getReq115();
if (!req115) return "";
try {
const {data} = await req115.filesSearchAllFolders(code);
if (!data?.length) return "";
const exact = data.find((f) => f.n === code);
if (exact) return exact.cid;
const upper = data.find((f) => f.n.toUpperCase() === code.toUpperCase());
if (upper) return upper.cid;
const contains = data.find((f) => f.n.toUpperCase().includes(code.toUpperCase()));
return contains?.cid || "";
} catch (e) {
console.warn("[JavPackSubtitle] 115搜索目录失败:", e.message);
return "";
}
}
static async preload115Matches(code) {
this.cached115Matches = [];
this.current115Cids = [];
if (!code) return;
const req115 = this.getReq115();
if (!req115) return;
try {
const {data} = await req115.filesSearchAllFolders(code);
if (!data?.length) return;
const codeUpper = code.toUpperCase();
const matches = data
.filter((f) => f.n.toUpperCase().includes(codeUpper))
.map((f) => ({cid: f.cid, name: f.n}))
.sort((a, b) => {
const aExact = a.name.toUpperCase() === codeUpper;
const bExact = b.name.toUpperCase() === codeUpper;
if (aExact && !bExact) return -1;
if (!aExact && bExact) return 1;
return a.name.localeCompare(b.name);
});
this.cached115Matches = matches;
this.current115Cids = matches.length === 1 ? [matches[0].cid] : matches.map((m) => m.cid);
this.last115Code = code;
} catch (e) {
console.warn("[JavPackSubtitle] 115预加载目录失败:", e.message);
}
}
static ensureDetailButton({details, getTargetCid, getSubtitleEntries} = {}) {
const panel = document.querySelector(".movie-panel-info");
const buttons = panel?.querySelector(".panel-block:last-child .buttons");
if (!buttons || document.getElementById(this.BTN_ID)) return;
const btn = document.createElement("button");
btn.id = this.BTN_ID;
btn.type = "button";
btn.className = "button is-small is-info x-subtitle-search";
btn.textContent = "字幕搜索";
const refreshSubtitleFileTip = () => {
const groups = new Map();
(getSubtitleEntries?.() || []).forEach(({directory, files = []}) => {
const path = String(directory || "").trim();
if (!path) return;
if (!groups.has(path)) groups.set(path, new Set());
files.map((file) => String(file?.n || file?.name || file || "").trim())
.filter(Boolean)
.forEach((name) => groups.get(path).add(name));
});
btn.title = groups.size
? ["已有字幕文件:", ...[...groups].flatMap(([path, files]) => [
`目录:${path}`,
...[...files].map((name) => ` - ${name}`),
])].join("\n")
: "暂无已匹配的字幕文件";
btn.textContent = groups.size ? "已有字幕" : "字幕搜索";
btn.classList.toggle("is-success", Boolean(groups.size));
btn.classList.toggle("is-info", !groups.size);
};
btn.addEventListener("mouseenter", refreshSubtitleFileTip);
btn.addEventListener("focus", refreshSubtitleFileTip);
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
this.openSearchModal({details, getTargetCid});
});
buttons.appendChild(btn);
refreshSubtitleFileTip();
const matchRoot = document.querySelector(".x-match-cont");
if (matchRoot) new MutationObserver(refreshSubtitleFileTip).observe(matchRoot, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["data-has-subtitle", "data-subtitle-files"],
});
}
static modalTemplate(defaultKw) {
return `
<div class="pdb-sub-modal">
<div class="pdb-sub-header">
<div class="pdb-sub-search-wrap">
<span class="pdb-sub-title">字幕检索:</span>
<input type="text" id="sub-search-input" value="${this.escapeHtml(defaultKw)}" class="pdb-sub-input" placeholder="输入检索词..." />
<button id="sub-search-btn" class="pdb-sub-btn">重新搜索</button>
</div>
<div class="pdb-sub-115-wrap" id="sub-115-wrap" style="display:none;">
<span class="pdb-sub-title">115目录:</span>
<div id="sub-115-select" class="pdb-sub-checkbox-group"></div>
</div>
<span class="pdb-sub-close" id="sub-close-btn">&times;</span>
</div>
<div class="pdb-sub-body">
<div class="pdb-sub-content"></div>
<div class="pdb-sub-preview-wrap">
<div class="pdb-sub-preview-header">
<span>字幕内容预览</span><span id="preview-status" class="pdb-sub-preview-status">暂无预览</span>
</div>
<textarea class="pdb-sub-textarea" readonly></textarea>
</div>
</div>
</div>
`;
}
static openSearchModal({details = {}, getTargetCid} = {}) {
const defaultKw = this.buildDefaultKeyword(details);
this.currentDetails = details;
document.getElementById(this.MODAL_ID)?.remove();
const overlay = document.createElement("div");
overlay.id = this.MODAL_ID;
overlay.className = "pdb-sub-overlay";
overlay.innerHTML = this.modalTemplate(defaultKw);
document.body.appendChild(overlay);
const contentWrap = overlay.querySelector(".pdb-sub-content");
const previewBox = overlay.querySelector(".pdb-sub-textarea");
const statusNode = overlay.querySelector("#preview-status");
const input = overlay.querySelector("#sub-search-input");
const dirWrap = overlay.querySelector("#sub-115-wrap");
const dirSelect = overlay.querySelector("#sub-115-select");
const refresh115DirUI = () => {
if (!this.cached115Matches.length) {
dirWrap.style.display = "none";
return;
}
dirWrap.style.display = "";
dirSelect.innerHTML = this.cached115Matches.map((m) => {
const checked = this.current115Cids.includes(m.cid) ? " checked" : "";
return `<label class="pdb-sub-checkbox-label"><input type="checkbox" class="pdb-sub-checkbox" value="${m.cid}"${checked}><span>${this.escapeHtml(m.name)}</span></label>`;
}).join("");
};
dirSelect.addEventListener("change", () => {
this.current115Cids = Array.from(dirSelect.querySelectorAll("input:checked")).map((cb) => cb.value);
});
if (this.last115Code === details.code && this.cached115Matches.length) {
refresh115DirUI();
} else {
this.preload115Matches(details.code).then(refresh115DirUI);
}
const closeModal = () => {
this.clearPreviewCache();
previewBox.value = "";
overlay.remove();
};
overlay.querySelector("#sub-close-btn").addEventListener("click", closeModal);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) closeModal();
});
const performSearch = async (kw) => {
if (!kw) return;
contentWrap.innerHTML = '<div class="pdb-sub-msg">正在搜索字幕来源,请稍候...</div>';
previewBox.value = "";
statusNode.textContent = "暂无预览";
try {
const results = await Promise.allSettled([
this.searchXunlei(kw),
this.searchSubtitleCat(kw),
]);
const dataList = results.flatMap((result) => result.status === "fulfilled" ? result.value : []);
const chineseResults = this.sortResults(dataList, kw, details);
if (!chineseResults.length) {
contentWrap.innerHTML = '<div class="pdb-sub-msg">未找到简中或繁中字幕,请尝试使用完整番号搜索</div>';
return;
}
this.renderTable({
container: contentWrap,
dataList: chineseResults,
previewBox,
statusNode,
overlay,
details,
getTargetCid,
kw
});
if (kw.toUpperCase() !== (details.code || "").toUpperCase()) {
this.preload115Matches(kw).then(refresh115DirUI);
}
} catch (e) {
console.error('字幕搜索失败:', e);
contentWrap.innerHTML = '<div class="pdb-sub-msg">搜索失败,请稍后重试</div>';
}
};
overlay.querySelector("#sub-search-btn").addEventListener("click", () => performSearch(input.value.trim()));
input.addEventListener("keypress", (e) => {
if (e.key === "Enter") performSearch(input.value.trim());
});
performSearch(defaultKw);
}
static requestText(url) {
return window.gmHttp.get(url);
}
static async searchXunlei(kw) {
const root = await this.requestText(`https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name=${encodeURIComponent(kw)}`);
if (!root || root.code !== 0 || !Array.isArray(root.data)) return [];
const results = root.data.slice(0, 20).map((item) => this.normalizeXunleiResult(item));
const labeledResults = await Promise.all(results.map((item) => this.labelXunleiChineseSubtitle(item)));
return labeledResults.filter(Boolean);
}
static normalizeXunleiResult(item = {}) {
const rawLanguages = item.languages ?? item.Languages ?? item.langs ?? item.Langs ?? item.language ?? item.Language ?? [];
return {
...item,
name: item.name || item.Name || item.extra_name || item.Title || "",
languages: Array.isArray(rawLanguages) ? rawLanguages : (rawLanguages ? [String(rawLanguages)] : []),
ext: item.ext || item.Ext || item.format || item.Format || "srt",
url: item.url || item.Url || "",
provider: "迅雷",
};
}
static async labelXunleiChineseSubtitle(item) {
if (this.isWantedChineseSubtitle(item)) return item;
if (!item.url) return null;
try {
const language = this.detectChineseSubtitleLanguage(this.decodeSubtitle(await this.fetchBinaryCached(item.url)));
return language ? {...item, languages: [language]} : null;
} catch (_) {
return null;
}
}
static detectChineseSubtitleLanguage(text = "") {
const sample = String(text).slice(0, 12000);
if (!/[\u3400-\u9fff]/.test(sample)) return "";
const simplifiedCount = (sample.match(/[为与个么开关东风头后发这边过还进时样气应实话说问题现经体万台农国门书车云]/g) || []).length;
const traditionalCount = (sample.match(/[為與個麼開關東風頭後發這邊過還進時樣氣應實話說問題現經體萬臺農國門書車雲]/g) || []).length;
if (simplifiedCount === 0 && traditionalCount === 0) return "";
return traditionalCount > simplifiedCount ? "繁中" : "简中";
}
static async searchSubtitleCat(kw) {
const searchUrl = `${this.SUBTITLECAT_ORIGIN}/index.php?search=${encodeURIComponent(kw)}`;
const searchHtml = await this.requestText(searchUrl);
const entries = this.parseSubtitleCatSearch(searchHtml).slice(0, 20);
const languageGroups = await Promise.all(entries.map(async (entry) => {
try {
return this.parseSubtitleCatLanguages(await this.requestText(entry.detailUrl), entry);
} catch (_) {
return [];
}
}));
return languageGroups.flat();
}
static parseSubtitleCatSearch(html) {
const doc = new DOMParser().parseFromString(html, "text/html");
return [...doc.querySelectorAll("table.sub-table tbody tr")].flatMap((row) => {
const link = row.querySelector('a[href*="subs/"]');
const name = link?.textContent?.trim();
if (!link || !name) return [];
return [{
name,
detailUrl: new URL(link.getAttribute("href"), this.SUBTITLECAT_ORIGIN).href,
}];
});
}
static parseSubtitleCatLanguages(html, entry) {
const doc = new DOMParser().parseFromString(html, "text/html");
return [...doc.querySelectorAll(".sub-single")].flatMap((node) => {
const languageCode = node.querySelector("img.flag")?.getAttribute("alt") || "";
const language = this.SUBTITLECAT_CHINESE_LANGUAGES.get(languageCode);
const link = node.querySelector("a.green-link[href$='.srt']");
if (!language || !link) return [];
return [{
name: entry.name,
languageCode,
languages: [language],
ext: "srt",
provider: "SubtitleCat",
url: new URL(link.getAttribute("href"), this.SUBTITLECAT_ORIGIN).href,
}];
});
}
static isWantedChineseSubtitle(item = {}) {
if (item.provider === "SubtitleCat") return this.SUBTITLECAT_CHINESE_LANGUAGES.has(item.languageCode);
const language = Array.isArray(item.languages) ? item.languages.join(" ") : String(item.languages || "");
const name = `${item.name || ""} ${item.extra_name || ""}`;
return /(zh[-_]?cn|zh[-_]?tw|chs|cht|chinese\s*(simplified|traditional)|简体|繁体|繁體|简中|繁中)/i.test(`${language} ${name}`);
}
static sortResults(dataList, kw = "", details = {}) {
const kwClean = kw.toLowerCase().replace(/[-_.\s]/g, "");
const tokens = kw.toLowerCase().split(/[-_.\s]+/).filter((word) => word.length > 1);
const codeClean = String(details.code || kw).toLowerCase().replace(/[-_.\s]/g, "");
return [...dataList]
.filter((item) => this.isWantedChineseSubtitle(item))
.sort((a, b) => this.scoreResult(b, kwClean, tokens, codeClean) - this.scoreResult(a, kwClean, tokens, codeClean));
}
static scoreResult(item, kwClean, tokens, codeClean = "") {
const name = (item.name || item.extra_name || "").toLowerCase();
const compactName = name.replace(/[-_.\s]/g, "");
const lang = ((item.languages && item.languages[0]) || "").toLowerCase();
let score = 0;
if (codeClean && compactName.includes(codeClean)) score += 800;
tokens.forEach((token) => {
if (name.includes(token)) score += 50;
});
if (kwClean && compactName.includes(kwClean)) score += 500;
if (/(zh|cn|chs|cht|chinese|简中|繁中)/i.test(lang) || /(zh|cn|chs|cht|chinese|简中|繁中)/i.test(name)) score += 100;
if (item.ext === "srt" || item.ext === "ass") score += 20;
return score;
}
static renderTable({container, dataList, previewBox, statusNode, overlay, details, getTargetCid, kw = ""}) {
const words = kw.split(/[-_.\s]+/).filter((word) => word.length > 1);
const highlightRegex = words.length ? new RegExp(`(${words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`, "gi") : null;
container.innerHTML = `
<table class="pdb-sub-table">
<thead>
<tr>
<th class="pdb-sub-th" style="width: 90px;">来源</th>
<th class="pdb-sub-th">原始字幕名称</th>
<th class="pdb-sub-th" style="width: 80px;">语言</th>
<th class="pdb-sub-th" style="width: 60px;">格式</th>
<th class="pdb-sub-th" style="width: 170px; text-align:center;">操作</th>
</tr>
</thead>
<tbody>
${dataList.map((item, index) => this.renderRow(item, index, highlightRegex)).join("")}
</tbody>
</table>
`;
container.querySelectorAll(".sub-action-btn").forEach((btn) => {
btn.addEventListener("click", () => this.handleAction({
btn,
item: dataList[btn.dataset.idx],
previewBox,
statusNode,
overlay,
details,
getTargetCid
}));
});
}
static renderRow(item, index, highlightRegex) {
const lang = item.languages?.[0] || "未知";
const name = item.name || item.extra_name || "未知字幕";
const provider = item.provider || "迅雷";
const providerClass = provider === "SubtitleCat" ? "pdb-sub-provider--subtitlecat" : "pdb-sub-provider--xunlei";
const displayName = highlightRegex ? this.escapeHtml(name).replace(highlightRegex, '<span style="color:#e74c3c; font-weight:bold;">$1</span>') : this.escapeHtml(name);
return `
<tr class="pdb-sub-tr">
<td class="pdb-sub-td-provider"><span class="pdb-sub-provider ${providerClass}">${this.escapeHtml(provider)}</span></td>
<td class="pdb-sub-td">${displayName}</td>
<td class="pdb-sub-td-lang">${this.escapeHtml(lang)}</td>
<td class="pdb-sub-td-ext">${this.escapeHtml(item.ext || "srt")}</td>
<td class="pdb-sub-td-actions">
<button class="sub-action-btn pdb-sub-action-btn pdb-sub-btn-preview" data-action="preview" data-idx="${index}">预览</button>
<button class="sub-action-btn pdb-sub-action-btn pdb-sub-btn-download" data-action="download" data-idx="${index}">下载</button>
<button class="sub-action-btn pdb-sub-action-btn pdb-sub-btn-upload" data-action="upload" data-idx="${index}">115直传</button>
</td>
</tr>
`;
}
static async handleAction({btn, item, previewBox, statusNode, overlay, details, getTargetCid}) {
const action = btn.dataset.action;
const oldText = btn.textContent;
const url = item.url;
if (!url) return alert("无效的字幕下载直链");
const filename = this.buildSubtitleFilename(details, item);
btn.textContent = "获取中...";
btn.style.opacity = "0.6";
try {
const buffer = await this.fetchBinaryCached(url);
const text = this.decodeSubtitle(buffer);
if (text.includes("<?xml") && text.includes("<Code>NoSuchKey</Code>")) throw new Error("该字幕在迅雷云端已失效丢失 (NoSuchKey)");
if (action === "preview") {
previewBox.value = text;
statusNode.textContent = filename;
this.setActivePreviewRow(overlay, btn);
} else if (action === "download") {
this.downloadBuffer(buffer, filename);
} else if (action === "upload") {
const jdbCid = getTargetCid?.() || this.getTargetCid();
let cids = jdbCid ? [jdbCid] : [...this.current115Cids];
const req115 = this.getReq115();
if (!cids.length || !req115) throw new Error("未在 115 找到匹配目录,请确认作品已下载到 115");
btn.textContent = "直传中...";
for (const cid of cids) {
await this.uploadSubtitle({req115, cid, filename, buffer});
}
show.ok(`字幕已上传到 115(${cids.length}个目录)`);
const detailBtn = document.getElementById(this.BTN_ID);
if (detailBtn) {
detailBtn.classList.add("is-success");
detailBtn.textContent = "已有字幕";
}
window.dispatchEvent(new CustomEvent("JavDB_SubtitleUploaded", {
detail: {
code: details.code || "",
cid: String(cids[0]),
subtitle: {n: filename, s: buffer.byteLength}
},
}));
overlay.remove();
this.clearPreviewCache();
}
} catch (err) {
alert(`执行中止: ${err.message}`);
} finally {
btn.textContent = oldText;
btn.style.opacity = "1";
}
}
static setActivePreviewRow(overlay, btn) {
overlay.querySelectorAll(".pdb-sub-tr.is-previewing").forEach((row) => {
row.classList.remove("is-previewing");
row.removeAttribute("aria-current");
});
const row = btn.closest(".pdb-sub-tr");
if (!row) return;
row.classList.add("is-previewing");
row.setAttribute("aria-current", "true");
}
static decodeSubtitle(buffer) {
let text = new TextDecoder("utf-8").decode(buffer);
if ((text.match(/\uFFFD/g) || []).length > 3) text = new TextDecoder("gbk").decode(buffer);
return text;
}
static fetchBinaryCached(url) {
if (this.previewCache.has(url)) return Promise.resolve(this.previewCache.get(url));
return this.fetchBinary(url).then((buffer) => {
this.previewCache.set(url, buffer);
return buffer;
});
}
static fetchBinary(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url,
responseType: "arraybuffer",
onload: (res) => {
if (res.status === 200 && res.response) resolve(res.response);
else reject(new Error(`字幕流获取失败,HTTP_CODE: ${res.status}`));
},
onerror: () => reject(new Error("跨域网络请求被阻断")),
});
});
}
static downloadBuffer(buffer, filename) {
const blob = new Blob([buffer], {type: "application/octet-stream"});
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}
static uploadSubtitle(args) {
return args.req115.queueMutation("上传字幕", () => this.uploadSubtitleNow(args));
}
static async uploadSubtitleNow({req115, cid, filename, buffer}) {
const blob = new Blob([buffer], {type: "application/octet-stream"});
const file = new File([blob], filename, {type: "application/octet-stream"});
const initRes = await req115.sampleInitUpload({filename, filesize: file.size, cid});
if (!initRes || (!initRes.host && initRes.status !== 2 && initRes.statuscode !== 0)) {
throw new Error(initRes?.error_msg || "获取115上传安全凭证被拦截");
}
if (!initRes.host) return initRes;
let uploadRes = null;
for (let retry = 0; retry < 3; retry++) {
uploadRes = await req115.upload({...initRes, filename, file});
if (uploadRes && uploadRes.state !== false) return uploadRes;
await new Promise((resolve) => setTimeout(resolve, 1500));
}
throw new Error(uploadRes?.error_msg || uploadRes?.error || "115 服务器拒绝接收回调");
}
};
window.JavPackSubtitle = JavPackSubtitle;
+1 -1
View File
@@ -38,7 +38,7 @@ export default defineConfig({
], ],
require: [ require: [
'https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js', 'https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js',
'https://cdn.jsdelivr.net/npm/tabulator-tables@6.3.1/dist/js/tabulator.min.js', 'https://cdn.jsdelivr.net/npm/tabulator-tables@6.5.2/dist/js/tabulator.min.js',
'https://cdn.jsdelivr.net/npm/layui-layer@1.0.9/dist/layer.min.js', 'https://cdn.jsdelivr.net/npm/layui-layer@1.0.9/dist/layer.min.js',
'https://cdn.jsdelivr.net/npm/blueimp-md5@2.19.0/js/md5.min.js', 'https://cdn.jsdelivr.net/npm/blueimp-md5@2.19.0/js/md5.min.js',
'https://cdn.jsdelivr.net/npm/toastify-js@1.12.0/src/toastify.min.js', 'https://cdn.jsdelivr.net/npm/toastify-js@1.12.0/src/toastify.min.js',