26 Commits

Author SHA1 Message Date
lzyme.dev@gmail.com e61739bd17 修复删除目录潜在的bug 2026-08-30 17:16:46 +08:00
lzyme.dev@gmail.com c7310ae830 更新文档 2026-08-30 16:00:12 +08:00
lzyme.dev@gmail.com fb5120551c 修复预览视频播放 更新文档 2026-08-30 15:57:27 +08:00
lzyme.dev@gmail.com 42bc3f624a 修复磁力排序刷新失效 2026-08-30 14:30:43 +08:00
lzyme.dev@gmail.com 13abbd03fb 修复javdb 视频预览 2026-08-30 14:11:29 +08:00
lzyme.dev@gmail.com 6dfebb50a3 修复javbus搜索 2026-08-30 13:57:46 +08:00
lzyme.dev@gmail.com 7637d33dc6 更新文档 2026-08-30 13:45:02 +08:00
lzyme.dev@gmail.com 3287afe7ea 更新文档 2026-08-30 13:36:02 +08:00
lzyme.dev@gmail.com 06f7059f1b 新增一键删除风控策略 方式115触发风控 2026-08-30 13:27:06 +08:00
lzyme.dev@gmail.com c4d77e728a 新增一键清除提示
新增捐助地址
2026-08-30 13:14:30 +08:00
lzyme.dev@gmail.com a8fc1cde8a 新增磁力过滤与排序 2026-08-30 12:57:54 +08:00
lzyme.dev@gmail.com c8bcac5f98 修复javbus反复开启标题翻译失效 2026-08-30 11:27:07 +08:00
lzyme.dev@gmail.com 2643a3d636 修复删除目录 2026-08-30 04:50:28 +08:00
lzyme.dev@gmail.com 34d34b5200 add missing lib 2026-08-30 04:17:27 +08:00
lzyme.dev@gmail.com cc8ff8082e 修复字幕选择作品文件夹无法展示问题 2026-08-30 04:15:23 +08:00
lzyme.dev@gmail.com 5636ee4f74 修复字幕选择作品文件夹无法展示问题 2026-08-30 04:05:40 +08:00
lzyme.dev@gmail.com b0fe131a03 修复视频长预览图 2026-08-30 03:49:06 +08:00
lzyme.dev@gmail.com 06e9f4e8b4 新增字幕直传115作品目录,支持多目录选择 2026-08-30 03:08:17 +08:00
lzyme.dev@gmail.com 8da3ef2ed5 修复瀑布流下滑删除按钮没有出现 2026-08-30 00:38:30 +08:00
lzyme.dev@gmail.com c645eb6629 瀑布流页面可以就行排序 2026-08-30 00:06:07 +08:00
lzyme.dev@gmail.com 0885b5e0fe 新增标记删除 2026-08-29 22:02:02 +08:00
lzyme.dev@gmail.com 268883ac02 强化目录删除的安全边界 保证只删除出现番号匹配的目录 2026-08-29 19:37:55 +08:00
lzyme.dev@gmail.com b1772160a1 详情页也添加删除按钮 2026-08-29 18:59:43 +08:00
lzyme.dev@gmail.com 39f252fa0b 多目录可选删除,暂时无法删除下载记录 2026-08-29 18:44:16 +08:00
lzyme.dev@gmail.com d99af94e74 新增作品列表下删除作品 2026-08-29 18:10:41 +08:00
lzyme.dev@gmail.com 2383406ede 新增作品列表下删除作品 2026-08-29 18:04:57 +08:00
29 changed files with 8998 additions and 675 deletions
+1 -4
View File
@@ -23,7 +23,6 @@
// @exclude https://*javsee*/*actresses
// @exclude https://*seejav*/forum/*
// @exclude https://*seejav*/*actresses
// @require https://raw.githubusercontent.com/Tampermonkey/utils/refs/heads/main/requires/gh_2215_make_GM_xhr_more_parallel_again.js
// @require https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js
// @require https://cdn.jsdelivr.net/npm/tabulator-tables@6.3.1/dist/js/tabulator.min.js
// @require https://cdn.jsdelivr.net/npm/layui-layer@1.0.9/dist/layer.min.js
@@ -10957,6 +10956,4 @@ pluginManager.processCss().then();
duration: -1
});
pluginManager.processPlugins().then();
}();
}();
+132 -67
View File
@@ -2,7 +2,7 @@
> Jav-鉴黄师 油猴脚本 — 工程化重构版本
基于 `vite-plugin-monkey` 脚手架,将原 10,000+ 行单体脚本拆分为 49 个可维护的 ES Module 模块,支持本地开发热更新、一键构建。
基于 `vite-plugin-monkey` 脚手架,将原 10,000+ 行单体脚本拆分为 56 个可维护的 ES Module 模块,支持本地开发热更新、一键构建。
---
@@ -23,28 +23,58 @@
---
## 自动部署
本项目使用 GitHub Actions 在推送代码时自动构建并发布到 [GitHub Releases](https://github.com/fireinrain/JHS-enhance/releases)。
```
git push → GitHub Actions 自动构建 → 发布 GitHub Release
↓
https://github.com/fireinrain/JHS-enhance/releases/latest/download/jhs-enhance.user.js
↓
用户点击安装链接 → Tampermonkey 弹出安装提示
```
---
## 功能概览
- **列表页增强**:作品状态标签(屏蔽/收藏/已下载/已观看)、一键打开待鉴定/已收藏作品、新作品检测、演员黑名单过滤
- **详情页增强**:磁力链接高亮、预览视频(DMM 多画质)、标题翻译、字幕搜索(SubTitleCat / 迅雷)、演员信息、相关清单
- **数据管理**:IndexedDB 本地存储、云盘备份/恢复(阿里云盘 / 115 网盘 / WebDAV)、跨 Tab 数据同步
- **自动化任务**:自动翻页、新作品检测、收藏演员同步、黑名单更新
- **UI 优化**:分类折叠、热榜 Top250、免 VIP 查看热播、以图识图、截图功能
- **多站点支持**:JavDB / JavBus / JavSee / SeeJav / FC2 / JavTrailers / SubTitleCat
### 列表页增强
- 作品状态标签(屏蔽/收藏/已下载/已观看)
- 一键筛选已收藏/新作品/待鉴定
- 演员黑名单过滤、标题关键词过滤
- 封面快捷按钮(长缩略图/预览视频/鉴定按钮/第三方跳转/复制番号)
- 分类折叠、热榜 Top250、免 VIP 查看热播
- 自动翻页
### 详情页增强
- **磁力排序与过滤(JavDB)**:按发布日期/文件大小/文件数量拖拽排序,勾选启用;高清/4K/字幕/无码多条件并集过滤,4K 磁力行高亮显示
- 磁力链接高亮匹配已收藏/已下载状态
- DMM 多画质预览视频
- **多源视频预览**:Javxy 聚合 API 独立按钮(番号/识别码右侧),多源 fallback(JavTrailers / JavDB 等),HLS 支持、播放进度记忆、画质切换
- **标题翻译**:Google 翻译,支持开关多次切换不失效
- **字幕搜索**:合并迅雷 + SubTitleCat 为单一入口,支持 115 网盘直传、详情页预匹配目录、多目录选择上传、长目录名截断展示、字幕预览绿色字体
- **多源预览图**:javfree / projectjav / javstore 三源切换,来源按钮绿色高亮,sessionStorage 缓存,错误提示弹窗内显示
- 演员信息展示、相关清单、评论查看
- 以图识图、第三方站点跳转
### 115 网盘集成
- 作品目录匹配与详情页预加载
- 多目录选择上传字幕
- 字幕一键直传至匹配目录
- **作品删除增强**:删除整个文件夹(非仅视频),支持标记删除批量操作,删除后字幕目录列表联动;批量删除含风控保护(1~3s
随机延迟 + 触发风控自动重试退避)
### 数据管理
- IndexedDB 本地存储
- 云盘备份/恢复(阿里云盘 / WebDAV)
- 跨 Tab 数据同步(BroadcastChannel)
### 自动化任务
- 新作品检测
- 收藏演员同步
- 黑名单自动检测与更新
- 后台定时任务调度
### 其他
- FC2 站点支持(含 123AV 补全)
- MagnetHub 磁力搜索
- JavTrailers 站点处理
- 设置面板:快捷键配置、域名配置、缓存清理、标记删除列表、打赏作者(二维码/钱包地址可自定义)
- 浏览历史、想看/已看列表
- 多站点支持:JavDB / JavBus / JavSee / SeeJav / FC2 / JavTrailers / SubTitleCat
---
@@ -56,61 +86,69 @@ git push → GitHub Actions 自动构建 → 发布 GitHub Release
```
JHS-enhance/
├── src/
│ ├── main.js # 入口文件:全局初始化、插件注册、页面启动
│ ├── core/ # 基础设施层
│ │ ├── constants.js # 全局常量、CSS 样式、页面类型判断
│ │ ├── base-plugin.js # 插件基类(含 SVG 图标、页面信息解析)
│ │ ├── plugin-manager.js # 插件管理器(注册、CSS/插件生命周期)
│ │ ├── globals.js # 全局依赖挂载(jQuery、localforage 等)
│ │ ├── storage.js # IndexedDB 存储管理器(localforage 封装)
│ │ ├── utils.js # 工具函数集(DOM 操作、弹窗、快捷键等)
│ │ └── gmHttp.js # GM_xmlhttpRequest 封装(带重试/并发控制)
│ ├── api/ # API 层
│ │ ├── javdb.js # JavDB API 封装(签名、搜索、评论等)
│ │ └── dmm.js # DMM 预览视频获取(多画质)
│ └── plugins/ # 插件层(38 个功能插件)
│ ├── list-page.js # 列表页过滤与状态标签
│ ├── list-page-button.js # 列表页功能按钮(待鉴定/已收藏/新作品/黑名单)
│ ├── detail-page.js # 详情页数据加载
│ ├── detail-page-button.js# 详情页操作按钮
│ ├── setting.js # 设置面板(Tabulator 表格)
│ ├── translate.js # 标题翻译(Google 翻译 API)
│ ├── preview-video.js # DMM 预览视频
│ ├── highlight-magnet.js # 磁力链接高亮匹配
│ ├── auto-page.js # 自动翻页 / 云盘备份
│ ├── blacklist.js # 黑名单管理(演员/番号)
│ ├── favorite-actresses.js# 收藏演员同步
│ ├── new-video.js # 新作品检测
│ ├── task.js # 后台任务调度
│ ├── history.js # 浏览历史
│ ├── wangpan-115.js # 115 网盘匹配
│ ├── wangpan-115-task.js # 115 网盘任务处理
│ ├── aliyun-pan.js # 阿里云盘
│ ├── main.js # 入口文件:全局初始化、插件注册、页面启动、loading/Toast/Viewer 全局函数
│ ├── core/ # 基础设施层(7 个模块)
│ │ ├── constants.js # 全局常量、CSS 样式、页面类型判断、枚举值
│ │ ├── base-plugin.js # 插件基类(含 SVG 图标、页面信息解析、getBean 跨插件调用)
│ │ ├── plugin-manager.js # 插件管理器(注册、CSS 注入、插件生命周期、按站点分组)
│ │ ├── globals.js # 全局依赖挂载(jQuery、localforage、layer 等)
│ │ ├── storage.js # IndexedDB 存储管理器(localforage 封装,含缓存)
│ │ ├── utils.js # 工具函数集(DOM 操作、弹窗确认、快捷键、重试、日期格式化)
│ │ └── gmHttp.js # GM_xmlhttpRequest 封装(GET/POST/Form/File、超时重试、分块下载)
│ ├── api/ # API 层(4 个模块)
│ │ ├── javdb.js # JavDB API 封装(签名算法、搜索、评论、磁力列表)
│ │ ├── dmm.js # DMM 预览视频获取(多画质、默认画质选择)
│ │ ├── dmm-graphql.js # DMM GraphQL API(备选视频源回退)
│ │ └── javxy.js # Javxy 聚合视频 API(多源解析、签名生成)
│ ├── lib/ # 第三方补丁库
│ │ ├── gm-xhr-parallel.js # Tampermonkey MV3 并行请求修复(redirect: manual)
│ │ └── hls-runtime.js # HLS 视频播放运行时(hls.js 封装、进度记忆、画质切换)
│ └── plugins/ # 插件层(42 个功能插件)
│ ├── list-page.js # 列表页过滤与状态标签渲染
│ ├── list-page-button.js # 列表页功能按钮(待鉴定/已收藏/新作品/黑名单管理)
│ ├── detail-page.js # 详情页数据加载与基础增强
│ ├── detail-page-button.js# 详情页操作按钮(115 目录预加载 + 磁力排序过滤面板)
│ ├── setting.js # 设置面板(Tab 页:基础/任务/域名/快捷键/屏蔽/缓存/标记删除/打赏作者)
│ ├── translate.js # 标题翻译(Google 翻译 API,原始标题缓存)
│ ├── preview-video.js # DMM 预览视频播放器
│ ├── highlight-magnet.js # 磁力链接高亮 + 排序 + 过滤(多键排序、并集过滤、4K 高亮)
│ ├── auto-page.js # 自动翻页
│ ├── blacklist.js # 黑名单管理(屏蔽演员/番号/关键词)
│ ├── favorite-actresses.js# 收藏演员同步与高亮
│ ├── new-video.js # 新作品检测与通知
│ ├── task.js # 后台任务调度(黑名单/收藏演员/新作品定时执行)
│ ├── history.js # 浏览历史表格
│ ├── wangpan-115.js # 115 网盘匹配/标签/删除(含风控保护的批量删除)
│ ├── wangpan-115-task.js # 115 网盘任务处理(文件搜索 API 封装)
│ ├── aliyun-pan.js # 阿里云盘备份/恢复
│ ├── fc2.js # FC2 页面处理
│ ├── fc2-by123av.js # FC2 通过 123AV 搜索
│ ├── actress-info.js # 演员信息展示
│ ├── related.js # 相关清单
│ ├── review.js # 评论展示
│ ├── cover-button.js # 封面按钮
│ ├── fold-category.js # 分类折叠
│ ├── hit-show.js # 热播榜单
│ ├── fc2-by123av.js # FC2 通过 123AV 补全番号信息
│ ├── actress-info.js # 演员信息展示(含头像、生日、三围等)
│ ├── related.js # 相关作品清单
│ ├── review.js # 评论区展示
│ ├── cover-button.js # 封面快捷按钮(SVG 图标 + 5 种封面操作)
│ ├── fold-category.js # 分类折叠展开
│ ├── hit-show.js # 热播榜单 + 免 VIP 查看
│ ├── top250.js # Top250 排行榜
│ ├── nav-bar.js # JavDB 导航栏增强
│ ├── bus-nav-bar.js # JavBus 导航栏增强
│ ├── bus-detail-page.js # JavBus 详情页
│ ├── bus-detail-page.js # JavBus 详情页专属处理
│ ├── bus-img.js # JavBus 图片处理
│ ├── bus-preview-video.js # JavBus 预览视频
│ ├── image-recognition.js # 以图识图
│ ├── screenshot.js # 截图功能
│ ├── image-recognition.js # 以图识图(第三方识图接口)
│ ├── screenshot.js # 多源预览图(javfree/projectjav/javstore + 来源切换弹窗)
│ ├── subtitles.js # 字幕搜索(整合迅雷+SubTitleCat)+ 115 多目录直传
│ ├── req115.js # 115 网盘 API 封装(Req 基类 + Drive115 + Req115)
│ ├── magnet-hub.js # MagnetHub 磁力搜索
│ ├── other-site.js # 第三方站点链接
│ ├── jav-trailers.js # JavTrailers 站点
│ ├── subtitle-cat.js # SubTitleCat 字幕搜索
│ ├── other-site.js # 第三方站点跳转链接
│ ├── jav-trailers.js # JavTrailers 站点处理
│ ├── javxy-preview-video.js# Javxy 多源视频预览(独立按钮、聚合 API、HLS 支持)
│ ├── subtitle-cat.js # SubTitleCat 字幕搜索(已整合,保留原文件)
│ ├── filter-title-keyword.js # 标题关键词过滤
│ └── want-watched.js # 想看/已看视频列表
├── vite.config.mjs # Vite + vite-plugin-monkey 配置
├── vite.config.mjs # Vite + vite-plugin-monkey 配置(元数据/CDN 依赖/域名匹配)
├── package.json
└── JHS-origin.js # 原始单体脚本(参考)
└── JHS-origin.js # 原始单体脚本(参考归档)
```
---
@@ -240,6 +278,12 @@ window.show.ok('操作成功'); // Toast 通知
---
## 赞助
如果当前项目有帮到您的话,赞助我喝瓶水/Star项目也是极好的,感谢支持 :)
![赞助](https://i.imgur.com/ynGK6he.jpeg)
## 构建配置
`vite-plugin-monkey` 在 `vite.config.mjs` 中配置了:
@@ -253,7 +297,28 @@ window.show.ok('操作成功'); // Toast 通知
## 版本历史
- **v3.3.6** — 工程化重构版本,模块化拆分,支持 HMR 开发
- **v3.3.8**
- 新增:Javxy 多源视频预览独立按钮(番号/识别码右侧),基于 Javxy 聚合 API,支持 JavTrailers / JavDB 等多源回退
- 新增:HLS 视频播放运行时(hls.js 封装),支持 .m3u8 格式、播放进度记忆、画质切换
- 新增:DMM GraphQL API 模块,作为备选视频源回退方案
- 优化:`bus-preview-video.js` 和 `preview-video.js` 清理移植代码,保持原有播放逻辑不变
- 优化:`dmm.js` 回退至仅 DMM Affiliate API + GraphQL API 双通道,移除 Javxy 耦合
- **v3.3.7**
- 新增:JavDB 磁力排序与过滤(拖拽排序、多条件并集过滤、4K 高亮)
- 新增:多源预览图(javfree/projectjav/javstore + 来源切换弹窗 + 绿色高亮)
- 新增:字幕搜索合并为单一入口(迅雷+SubTitleCat),详情页预匹配 115 目录,多目录选择上传
- 新增:标记删除批量 115 删除(含风控保护:1~3s 随机延迟 + 风控自动退避重试)
- 优化:作品删除改为删除整个文件夹(非仅视频),删除后字幕目录列表联动更新
- 修复:标题翻译开关多次切换后失效(原始标题 data-original-title 缓存)
- 修复:115 目录名过长截断展示(最多 26 字符)
- 修复:字幕预览区域字体改为绿色
- 修复:JavBus/JavDB 磁力列表 DOM 精确提取(data-rank/data-size/data-files/data-date 属性)
- 修复:无码过滤条件新增 -U/-UC/-u/-uc 匹配
- 优化:删除作品 115 风控提示(标记删除面板)
- 优化:设置面板打赏作者二维码/钱包地址可自定义
- **v3.3.6** — 工程化重构版本,模块化拆分(41 个插件),支持 Vite HMR 开发热更新
---
+4455 -408
View File
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
import {GM_xmlhttpRequest} from 'vite-plugin-monkey/dist/client';
const DMM_GRAPHQL_URL = 'https://api.video.dmm.co.jp/graphql';
const dmmGraphQLKeyword = (code) => {
const match = String(code || '').trim().toUpperCase().match(/^([A-Z0-9]{2,10})-(\d{1,6})$/);
if (!match) return '';
return `${match[1].toLowerCase()}${match[2].padStart(5, '0')}`;
};
const dmmGraphQLRequest = (query) => {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: DMM_GRAPHQL_URL,
data: JSON.stringify({query}),
timeout: 12000,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Referer: 'https://video.dmm.co.jp/',
'Fanza-Device': 'BROWSER',
'Cache-Control': 'no-cache',
},
onload: (response) => {
if (response.status < 200 || response.status >= 400) {
reject(new Error(`DMM GraphQL HTTP ${response.status || 0}`));
return;
}
try {
const json = JSON.parse(response.responseText || '{}');
if (Array.isArray(json.errors) && json.errors.length) {
reject(new Error(json.errors[0].message || 'DMM GraphQL error'));
return;
}
resolve(json.data || {});
} catch (e) {
reject(new Error(`DMM GraphQL JSON parse error: ${e.message}`));
}
},
onerror: () => reject(new Error('DMM GraphQL network error')),
ontimeout: () => reject(new Error('DMM GraphQL timeout')),
});
});
};
const loadDmmCover = async (code) => {
const keyword = dmmGraphQLKeyword(code);
if (!keyword) throw new Error('DMM cover keyword missing');
const searchKeywords = [...new Set([keyword, `${keyword}#`])];
for (const searchKeyword of searchKeywords) {
const query = `{ legacySearchPPV(limit: 5, offset: 0, sort: SALES_RANK_SCORE, floor: AV, queryWord: ${JSON.stringify(searchKeyword)}) { result { contents { id } } } }`;
const data = await dmmGraphQLRequest(query);
const ids = Array.isArray(data && data.legacySearchPPV && data.legacySearchPPV.result && data.legacySearchPPV.result.contents)
? data.legacySearchPPV.result.contents.map((item) => String(item && item.id || '').trim()).filter(Boolean)
: [];
const compactKeyword = keyword.replace(/[^a-z0-9]/gi, '').toLowerCase();
const contentID =
ids.find((id) => id.replace(/[^a-z0-9]/gi, '').toLowerCase().includes(compactKeyword)) ||
(ids.length === 1 ? ids[0] : '');
if (contentID) {
const encoded = encodeURIComponent(contentID);
return `https://pics.dmm.co.jp/digital/video/${encoded}/${encoded}pl.jpg`;
}
}
throw new Error('DMM cover missing');
};
const searchDmmContentIdsByGraphQL = async (code) => {
const keyword = dmmGraphQLKeyword(code);
if (!keyword) return [];
const carNumNoHyphen = String(code || '').replace(/-/g, '').toLowerCase();
const results = [];
const searchKeywords = [...new Set([keyword, `${keyword}#`])];
for (const searchKeyword of searchKeywords) {
const query = `{ legacySearchPPV(limit: 10, offset: 0, sort: SALES_RANK_SCORE, floor: AV, queryWord: ${JSON.stringify(searchKeyword)}) { result { contents { contentId serviceCode floorCode } } } }`;
const data = await dmmGraphQLRequest(query);
const contents = Array.isArray(data && data.legacySearchPPV && data.legacySearchPPV.result && data.legacySearchPPV.result.contents)
? data.legacySearchPPV.result.contents
: [];
for (const item of contents) {
const contentId = String(item.contentId || '');
if (contentId.replace(/[^a-z0-9]/gi, '').toLowerCase().includes(carNumNoHyphen)) {
results.push({
serviceCode: item.serviceCode || 'digital',
floorCode: item.floorCode || 'AV',
contentId: contentId,
});
}
}
if (results.length > 0) break;
}
return results;
};
const getDmmGraphQLVideoUrls = async (code) => {
const contentItems = await searchDmmContentIdsByGraphQL(code);
if (!contentItems.length) return null;
const results = await Promise.allSettled(
contentItems.map((item) => extractTrailerLinks(item))
);
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
return result.value;
}
}
return null;
};
const extractTrailerLinks = (item) => {
const {contentId, serviceCode, floorCode} = item;
return new Promise((resolve, reject) => {
const trailerPageUrl = `https://www.dmm.co.jp/service/digitalapi/-/html5_player/=/cid=${contentId}/mtype=AhRVShI_/service=${serviceCode}/floor=${floorCode}/mode=/`;
GM_xmlhttpRequest({
method: 'GET',
url: trailerPageUrl,
timeout: 15000,
headers: {
'accept-language': 'ja-JP,ja;q=0.9',
Cookie: 'age_check_done=1',
},
onload: (response) => {
const htmlContent = response.responseText || '';
if (htmlContent.includes('このサービスはお住まいの地域からは')) {
reject(new Error('节点不可用,请将DMM域名分流到日本ip'));
return;
}
const match = htmlContent.match(/const\s+args\s+=\s+(.*);/);
if (!match) {
reject(new Error('未在脚本中找到 const args = ... 变量'));
return;
}
let bitrates;
try {
bitrates = JSON.parse(match[1]).bitrates;
} catch (e) {
reject(new Error(`解析播放器脚本 JSON 失败: ${e.message}`));
return;
}
if (!Array.isArray(bitrates)) {
reject(new Error('解析画质链接失败: bitrates 字段不是一个数组或不存在'));
return;
}
const qualityKeys = [
'4ks', '4k', 'hhbs', 'hhb', 'hmb', 'mhb', 'mmb',
'dmb_w', 'dmb_s', 'dm_s', 'sm_s', 'mhb_w',
];
const qualityNameRegex = new RegExp(`(${qualityKeys.join('|')})\\.mp4$`);
const finalQualityMap = {};
for (const bitrate of bitrates) {
const url = bitrate && bitrate.src;
if (!url || typeof url !== 'string' || !url.endsWith('.mp4')) continue;
const qualityMatch = url.match(qualityNameRegex);
let qualityKey = '';
if (qualityMatch && qualityMatch[1]) qualityKey = qualityMatch[1];
if (qualityKey && !finalQualityMap[qualityKey]) {
finalQualityMap[qualityKey] = url;
}
}
if (Object.keys(finalQualityMap).length === 0) {
reject(new Error('未找到匹配要求的预览画质视频'));
return;
}
resolve(finalQualityMap);
},
onerror: () => reject(new Error('DMM 播放页请求网络错误')),
ontimeout: () => reject(new Error('DMM 播放页请求超时')),
});
});
};
export {
DMM_GRAPHQL_URL,
dmmGraphQLKeyword,
dmmGraphQLRequest,
loadDmmCover,
searchDmmContentIdsByGraphQL,
getDmmGraphQLVideoUrls,
extractTrailerLinks,
};
+31 -3
View File
@@ -1,4 +1,6 @@
import { qualityOptions } from '../core/constants.js';
import {getDmmGraphQLVideoUrls} from './dmm-graphql.js';
const selectDefaultQuality = (dmmVideoQualityList, intendedDefault) => {
if (!dmmVideoQualityList || 0 === dmmVideoQualityList.length) return null;
const availableSet = new Set(dmmVideoQualityList);
@@ -138,6 +140,25 @@ class DmmVideoFetcher {
if (0 === Object.keys(finalQualityMap).length) throw new Error("未找到匹配要求的预览画质视频");
return finalQualityMap;
}
async _searchViaGraphQL() {
clog.debug("尝试通过 GraphQL API 搜索视频...");
const videoMap = await getDmmGraphQLVideoUrls(this.carNum);
if (videoMap && Object.keys(videoMap).length > 0) {
this._updateCache(videoMap);
clog.debug("GraphQL 成功解析出预览视频:", videoMap);
const dmmCacheKey = "jhs_other_site_dmm";
const dmmCacheData = localStorage.getItem(dmmCacheKey) ? JSON.parse(localStorage.getItem(dmmCacheKey)) : {};
dmmCacheData[this.carNum] = {
type: "graphql",
url: `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`,
};
localStorage.setItem(dmmCacheKey, JSON.stringify(dmmCacheData));
return videoMap;
}
return null;
}
async fetchVideo() {
const cachedResult = this._checkCache();
if (cachedResult) return cachedResult;
@@ -149,22 +170,29 @@ class DmmVideoFetcher {
contentItems = await this._searchContentIds();
} catch (e) {
clog.error("DMM API 搜索失败:", e);
}
if (!contentItems || 0 === contentItems.length) {
clog.warn("Affiliate API 未找到视频,尝试 GraphQL API...");
const graphQLResult = await this._searchViaGraphQL();
if (graphQLResult) return graphQLResult;
const $btn = $("#fanzaBtn");
$btn.attr("href", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`);
$btn.attr("title", "未查询到, 点击前往搜索页");
$btn.css("backgroundColor", "#de3333");
return null;
}
if (!contentItems || 0 === contentItems.length) return null;
try {
const finalVideoMap = await Promise.any(contentItems.map((item => this._extractTrailerLinks(item))));
this._updateCache(finalVideoMap);
return finalVideoMap;
} catch (error) {
const errors = error.errors || [ error ];
clog.error(`Affiliate 解析失败: ${errors[0] && errors[0].message || errors[0]}`, errors);
clog.warn("尝试 GraphQL API 作为 fallback...");
const graphQLResult = await this._searchViaGraphQL();
if (graphQLResult) return graphQLResult;
if (errors.some((err => err.message.includes("节点不可用")))) this.showErrorMessages && show.error("节点不可用,请将DMM域名分流到日本ip"); else {
const displayError = errors[0].message || errors[0];
clog.error(`解析失败: ${displayError}`, errors);
this.showErrorMessages && show.error(`解析失败: ${displayError}`);
}
const $btn = $("#fanzaBtn");
@@ -178,4 +206,4 @@ class DmmVideoFetcher {
const getDmmVideo = async (carNum2, showErrorMessages = !0) => new DmmVideoFetcher(carNum2, showErrorMessages).fetchVideo();
export { DmmVideoFetcher, getDmmVideo, selectDefaultQuality };
export { DmmVideoFetcher, getDmmVideo, selectDefaultQuality };
+223
View File
@@ -0,0 +1,223 @@
import {qualityOptions} from '../core/constants.js';
const JAVXY_TOKEN = [118, 119, 112, 71, 97, 110, 28, 84, 124, 65, 76, 102, 65, 16, 77, 109, 64, 82, 85, 83, 67, 92, 125, 108, 83, 65, 124, 107, 84, 104, 71, 84, 17, 124, 118, 125, 104, 8, 125, 96, 112, 103, 29, 18, 82, 83, 87, 84]
.map(v => String.fromCharCode(v ^ 0x25)).join('');
const JAVXY_ENDPOINTS = [
{host: String.fromCharCode(106, 97, 118, 120, 121, 46, 99, 99, 46, 99, 100), label: 'Javxy'},
{
host: String.fromCharCode(119, 111, 114, 107, 101, 114, 46, 106, 97, 118, 120, 121, 46, 99, 99, 46, 99, 100),
label: 'Javxy Worker'
},
];
const JAVXY_SOURCE_LABELS = {
'Tokyo-Hot': 'Javxy | Tokyo-Hot',
FC2: 'Javxy | FC2',
Direct: 'Javxy | Direct',
DMM: 'Javxy | dmm',
MGStage: 'Javxy | MGStage',
DUGA: 'Javxy | DUGA',
MYWIFE: 'Javxy | MyWife',
JavTrailers: 'Javxy | JavTrailers',
JavDB: 'Javxy | Javdb',
AVWikiDB: 'Javxy | AVWikiDB',
JAVDatabase: 'Javxy | JAVDatabase',
HEYZO: 'Javxy | Heyzo',
HeyDouga: 'Javxy | HeyDouga',
PACO: 'Javxy | Paco',
'10MU': 'Javxy | 10mu',
Caribbean: 'Javxy | 加勒比',
'1Pondo': 'Javxy | 一本道',
};
const JAVXY_QUALITY_OPTIONS = [
{quality: '4k', text: '4K'},
{quality: 'hhb', text: '1080p'},
{quality: '1080p', text: '1080p'},
{quality: 'hmb', text: '720p'},
{quality: '720p', text: '720p'},
{quality: 'mhb', text: '576p'},
{quality: '540p', text: '540p'},
{quality: 'mmb', text: '432p'},
{quality: '480p', text: '480p'},
{quality: '396p', text: '396p'},
{quality: '360p', text: '360p'},
{quality: '240p', text: '240p'},
];
const jpSourceFailedKey = (source = 'DMM') => `javxy_jp_source_failed_until_${String(source || '').toUpperCase()}`;
const isJpSourceTemporarilyFailed = (source = 'DMM') => {
const until = Number(sessionStorage.getItem(jpSourceFailedKey(source)) || 0);
if (!Number.isFinite(until) || until <= Date.now()) {
sessionStorage.removeItem(jpSourceFailedKey(source));
return false;
}
return true;
};
const markJpSourceTemporarilyFailed = (source = 'DMM') => {
sessionStorage.setItem(jpSourceFailedKey(source), String(Date.now() + 30 * 60 * 1000));
};
const selectHighestQuality = (qualityMap) => {
const rank = new Map(JAVXY_QUALITY_OPTIONS.map((item, index) => [item.quality, index]));
return Object.keys(qualityMap || {})
.filter(key => qualityMap[key])
.sort((a, b) => (rank.get(a) ?? -1) - (rank.get(b) ?? -1))[0] || null;
};
const sortQualityKeys = (qualityMap) => {
const rank = new Map(JAVXY_QUALITY_OPTIONS.map((item, index) => [item.quality, index]));
return Object.keys(qualityMap || {})
.filter(key => qualityMap[key])
.sort((a, b) => (rank.get(a) ?? -1) - (rank.get(b) ?? -1));
};
const normalizeJavxySource = (value) => {
const raw = String(value || '').trim().toLowerCase();
if (raw.includes('fc2')) return 'FC2';
if (raw.includes('mgstage')) return 'MGStage';
if (raw.includes('heydouga')) return 'Direct';
if (raw.includes('mywife')) return 'MYWIFE';
if (raw.includes('duga')) return 'DUGA';
if (raw.includes('javtrailers')) return 'JavTrailers';
if (raw.includes('javdb')) return 'JavDB';
if (raw.includes('avwikidb')) return 'AVWikiDB';
if (raw.includes('javdatabase')) return 'JAVDatabase';
if (raw.includes('dmm')) return 'DMM';
if (raw === 'direct' || raw.includes('heyzo') || raw.includes('heydouga') || raw.includes('paco') || raw.includes('10musume') || raw.includes('10mu') || raw.includes('1pondo') || raw.includes('caribbean') || raw.includes('tokyo-hot') || raw.includes('tokyohot')) return 'Direct';
return String(value || '').trim();
};
const javxyQualityMapToDmmFormat = (javxyQualityMap) => {
const result = {};
const qualityKeys = qualityOptions.map(o => o.quality);
for (const [key, url] of Object.entries(javxyQualityMap || {})) {
if (!url) continue;
const lowerKey = key.toLowerCase();
if (qualityKeys.includes(key)) {
result[key] = url;
} else if (qualityKeys.includes(lowerKey)) {
result[lowerKey] = url;
}
}
return result;
};
const fromJavxyCcCd = async (id, rawCode = '', options = {}) => {
const query = String(id || rawCode || '').trim();
if (!query) {
clog.debug('Javxy 跳过:查询词为空');
return null;
}
for (const endpoint of JAVXY_ENDPOINTS) {
const params = new URLSearchParams({client: 'laosiji-new'});
if (Array.isArray(options.skip) && options.skip.length) params.set('skip', options.skip.join(','));
if (Array.isArray(options.prefer) && options.prefer.length) params.set('prefer', options.prefer.join(','));
if (Array.isArray(options.source) && options.source.length) params.set('source', options.source.join(','));
if (options.playbackFallback) params.set('purpose', 'playback-fallback');
const apiUrl = `https://${endpoint.host}/trailers/${encodeURIComponent(query)}?${params}`;
clog.debug('Javxy 请求 API', {query, apiUrl, endpoint: endpoint.label});
let r;
try {
r = await gmHttp.get(apiUrl, null, {
'Accept': 'application/json,text/plain,*/*',
'X-Javxy-Token': JAVXY_TOKEN,
}, {timeout: 8000});
} catch (e) {
clog.debug('Javxy API 网络失败,尝试下一个节点', {endpoint: endpoint.label, error: e.message});
continue;
}
if (!r) {
clog.debug('Javxy API 无响应,尝试下一个节点', {endpoint: endpoint.label});
continue;
}
const trailerUrl = String(r?.trailer || '').trim();
if (!trailerUrl) {
clog.debug('Javxy 无 trailer 字段', {endpoint: endpoint.label, keys: Object.keys(r || {})});
return null;
}
const qualityMap = r?.qualities && typeof r.qualities === 'object' ? r.qualities : {};
const quality = r?.quality && qualityMap[r.quality] ? r.quality : selectHighestQuality(qualityMap);
const sourceBase = JAVXY_SOURCE_LABELS[r?.source] || `Javxy | ${r?.source || 'dmm'}`;
const directUrl = qualityMap[quality] || trailerUrl;
clog.debug('Javxy 返回结果', {
endpoint: endpoint.label,
source: r?.source,
quality,
qualities: Object.keys(qualityMap),
url: directUrl,
});
const dmmQualityMap = javxyQualityMapToDmmFormat(qualityMap);
return {
url: directUrl,
source: sourceBase,
type: String(r?.type || '').trim() || 'video',
qualities: qualityMap,
quality,
directUrl,
code: id,
rawCode,
javxySource: String(r?.source || '').trim(),
requiresJP: Boolean(r?.requiresJP),
urls: Array.isArray(r?.urls) && r.urls.length ? r.urls : sortQualityKeys(qualityMap).map(key => qualityMap[key]),
dmmQualityMap,
};
}
return null;
};
const fallbackJavxyResult = async (code, rawCode = '', failedSources = [], options = {}) => {
const skip = [...new Set((failedSources || []).map(source => normalizeJavxySource(source)).filter(Boolean))];
const source = [...new Set((options.source || []).map(s => String(s || '').trim()).filter(Boolean))];
if (!skip.length && !source.length) return null;
clog.debug('Javxy 播放失败回落查询', {code, skip, source});
return fromJavxyCcCd(code, rawCode, {skip, source, playbackFallback: true});
};
const getJavxyVideoUrls = async (code, failedSources = []) => {
const result = await fallbackJavxyResult(code, code, failedSources, {
source: ['JavTrailers', 'JavDB'],
});
if (result?.dmmQualityMap && Object.keys(result.dmmQualityMap).length > 0) {
return result.dmmQualityMap;
}
if (result?.url) {
const qualityMap = {};
qualityMap['720p'] = result.url;
return qualityMap;
}
return null;
};
export {
fromJavxyCcCd,
fallbackJavxyResult,
getJavxyVideoUrls,
normalizeJavxySource,
isJpSourceTemporarilyFailed,
markJpSourceTemporarilyFailed,
JAVXY_TOKEN,
JAVXY_ENDPOINTS,
};
+1
View File
@@ -21,6 +21,7 @@ class BasePlugin {
__publicField(this, "videoSvg", '<svg t="1749003664455" class="jhs-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1952" width="200" height="200"><path d="M825.6 153.6H198.4C124.5 153.6 64 214.1 64 288v448c0 73.9 60.5 134.4 134.4 134.4h627.2c73.9 0 134.4-60.5 134.4-134.4V288c0-73.9-60.5-134.4-134.4-134.4z m-138.2 44.8l112 112H706l-112-112h93.4z m-156.8 0l112 112H526.7l-112-112h115.9z m-179.2 0l112 112H347.5l-112-112h115.9zM108.8 288c0-41.4 28.4-76.1 66.7-86.3l108.7 108.7H108.8V288z m806.4 448c0 49.4-40.2 89.6-89.6 89.6H198.4c-49.4 0-89.6-40.2-89.6-89.6V355.2h806.4V736z m0-425.6h-52.5l-112-112h74.9c49.4 0 89.6 40.2 89.6 89.6v22.4z" p-id="1953"></path><path d="M454 687.2l149.3-77.6c27.5-13.8 27.5-53 0-66.8L468 472.2c-31.2-15.6-68 7.1-68 42v139.6c0 27.8 29.2 45.8 54 33.4zM444.8 512l134.4 67.2-134.4 67.2V512z" p-id="1954"></path></svg>');
__publicField(this, "screenSvg", '<svg t="1750691468062" class="jhs-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2693" width="200" height="200"><path d="M288 160a64 64 0 0 0-64 64v576a64 64 0 0 0 64 64h448a64 64 0 0 0 64-64v-576a64 64 0 0 0-64-64h-448m0-64h448a128 128 0 0 1 128 128v576a128 128 0 0 1-128 128h-448a128 128 0 0 1-128-128v-576a128 128 0 0 1 128-128z" fill="#4078FD" p-id="2694"></path><path d="M416 352m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0Z" fill="#FE9C23" p-id="2695"></path><path d="M352 732.448a32 32 0 0 1-32-32v-160a32 32 0 0 1 44.224-29.568l130.112 53.632 153.952-169.984a32 32 0 0 1 55.712 21.472v284.448a32 32 0 0 1-32 32z m0-32h320z" fill="#4078FD" opacity=".2" p-id="2696"></path><path d="M672 416l-169.088 186.656-150.912-62.208v160h320V416m0-32a32 32 0 0 1 32 32v284.448a32 32 0 0 1-32 32h-320a32 32 0 0 1-32-32v-160a32 32 0 0 1 44.192-29.6l130.112 53.632 153.984-169.984a32 32 0 0 1 23.712-10.496z" fill="#4078FD" p-id="2697"></path></svg>');
__publicField(this, "recoveryVideoSvg", '<svg t="1749003779161" class="jhs-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8204" width="200" height="200"><path d="M938.666667 553.92V768c0 64.8-52.533333 117.333333-117.333334 117.333333H202.666667c-64.8 0-117.333333-52.533333-117.333334-117.333333V256c0-64.8 52.533333-117.333333 117.333334-117.333333h618.666666c64.8 0 117.333333 52.533333 117.333334 117.333333v297.92z m-64-74.624V256a53.333333 53.333333 0 0 0-53.333334-53.333333H202.666667a53.333333 53.333333 0 0 0-53.333334 53.333333v344.48A290.090667 290.090667 0 0 1 192 597.333333a286.88 286.88 0 0 1 183.296 65.845334C427.029333 528.384 556.906667 437.333333 704 437.333333c65.706667 0 126.997333 16.778667 170.666667 41.962667z m0 82.24c-5.333333-8.32-21.130667-21.653333-43.648-32.917333C796.768 511.488 753.045333 501.333333 704 501.333333c-121.770667 0-229.130667 76.266667-270.432 188.693334-2.730667 7.445333-7.402667 20.32-13.994667 38.581333-7.68 21.301333-34.453333 28.106667-51.370666 13.056-16.437333-14.634667-28.554667-25.066667-36.138667-31.146667A222.890667 222.890667 0 0 0 192 661.333333c-14.464 0-28.725333 1.365333-42.666667 4.053334V768a53.333333 53.333333 0 0 0 53.333334 53.333333h618.666666a53.333333 53.333333 0 0 0 53.333334-53.333333V561.525333zM320 480a96 96 0 1 1 0-192 96 96 0 0 1 0 192z m0-64a32 32 0 1 0 0-64 32 32 0 0 0 0 64z" fill="#000000" p-id="8205"></path></svg>');
__publicField(this, "markDeleteSvg", '<svg class="jhs-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="200" height="200"><path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64z m0 832c-212.1 0-384-171.9-384-384s171.9-384 384-384 384 171.9 384 384-171.9 384-384 384z"></path><path d="M717.3 338.7L685.3 306.7 512 480 338.7 306.7 306.7 338.7 480 512 306.7 685.3 338.7 717.3 512 544 685.3 717.3 717.3 685.3 544 512z"></path></svg>');
}
getName() {
throw new Error(`${this.constructor.name} 未显示getName()`);
+572
View File
@@ -451,6 +451,577 @@ const mainCss = `
background: rgba(0, 0, 0, 0.3);
z-index: 999998;
}
.jhs-preview-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
z-index: 2147483647;
display: flex;
overflow: auto;
cursor: zoom-out;
backdrop-filter: blur(5px);
}
.jhs-preview-img {
border-radius: 4px;
margin: auto;
cursor: zoom-in;
max-width: 95vw;
max-height: 95vh;
object-fit: contain;
display: block;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
}
.jhs-preview-img.zoomed {
max-width: none;
max-height: none;
cursor: zoom-out;
}
.jhs-preview-toolbar {
position: fixed;
top: 20px;
right: 20px;
display: flex;
gap: 8px;
z-index: 2147483648;
background: #fff;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid #e5e7eb;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
.jhs-preview-btn {
padding: 7px 16px;
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.jhs-preview-btn:hover {
background: #e5e7eb;
border-color: #9ca3af;
}
.jhs-preview-btn.active {
color: #fff;
background: #2ecc71;
border-color: #2ecc71;
}
.jhs-loading-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 2147483649;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
}
.jhs-loading-spinner {
text-align: center;
}
.jhs-loading-spinner .spinner {
width: 40px;
height: 40px;
margin: 0 auto 12px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: jhs-spin 0.6s linear infinite;
}
@keyframes jhs-spin {
to { transform: rotate(360deg); }
}
.jhs-preview-toast {
position: fixed;
top: 80px;
left: 50%;
transform: translate(-50%, -20px);
z-index: 2147483650;
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
color: #fff;
opacity: 0;
transition: opacity 0.3s ease, transform 0.3s ease;
pointer-events: none;
}
.jhs-preview-toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
.jhs-preview-toast-error {
background: rgba(220, 38, 38, 0.9);
box-shadow: 0 4px 16px rgba(220, 38, 38, 0.4);
}
.jhs-preview-toast-info {
background: rgba(59, 130, 246, 0.9);
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.4);
}
.magnet-4k-highlight {
background-color: #fff5f5 !important;
}
.magnet-4k-highlight .name {
color: #f40 !important;
}
.msf-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 999997;
}
.msf-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #fff;
border-radius: 10px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
z-index: 999998;
width: 380px;
display: flex;
flex-direction: column;
}
.msf-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
border-radius: 10px 10px 0 0;
}
.msf-title {
font-size: 15px;
font-weight: 600;
color: #1e293b;
}
.msf-close {
font-size: 22px;
color: #9ca3af;
cursor: pointer;
padding: 0 6px;
line-height: 1;
transition: color 0.2s;
}
.msf-close:hover {
color: #ef4444;
}
.msf-body {
padding: 18px;
display: flex;
flex-direction: column;
gap: 16px;
}
.msf-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.msf-section-title {
font-size: 13px;
font-weight: 600;
color: #6b7280;
}
.msf-sort-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.msf-sort-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
cursor: default;
transition: all 0.2s;
user-select: none;
}
.msf-sort-item:hover {
border-color: #cbd5e1;
background: #f1f5f9;
}
.msf-sort-item.msf-dragging {
opacity: 0.5;
border-color: #3b82f6;
background: #eff6ff;
}
.msf-sort-item.msf-drag-over {
border-color: #3b82f6;
background: #eff6ff;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.msf-drag-handle {
color: #cbd5e1;
font-size: 16px;
cursor: grab;
line-height: 1;
letter-spacing: -2px;
transition: color 0.2s;
}
.msf-drag-handle:active {
cursor: grabbing;
}
.msf-sort-item:hover .msf-drag-handle {
color: #94a3b8;
}
.msf-sort-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #374151;
cursor: pointer;
flex: 1;
}
.msf-sort-checkbox {
margin: 0;
cursor: pointer;
width: 16px;
height: 16px;
}
.msf-sort-hint {
font-size: 12px;
color: #9ca3af;
margin-left: 4px;
}
.msf-filter-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.msf-filter-label {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 14px;
color: #374151;
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.msf-filter-label:hover {
border-color: #cbd5e1;
background: #f1f5f9;
}
.msf-filter-checkbox {
margin: 0;
cursor: pointer;
width: 16px;
height: 16px;
}
.msf-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 18px;
border-top: 1px solid #e5e7eb;
}
.msf-btn {
padding: 8px 22px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.msf-btn-cancel {
background: #f3f4f6;
color: #374151;
}
.msf-btn-cancel:hover {
background: #e5e7eb;
}
.msf-btn-confirm {
background: #3b82f6;
color: #fff;
}
.msf-btn-confirm:hover {
background: #2563eb;
}
</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: 1;
min-width: 0;
max-width: 360px;
}
.pdb-sub-checkbox-group {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
min-width: 0;
}
.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: #2ecc71;
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>
`;
@@ -470,6 +1041,7 @@ insertStyle(
);
insertStyle(mainCss);
insertStyle(subtitleModalCss);
export {
__defProp,
+5 -2
View File
@@ -2,16 +2,19 @@ import $ from 'jquery';
import localforage from 'localforage';
import Toastify from 'toastify-js';
import md5 from 'blueimp-md5';
import { TabulatorFull as Tabulator } from 'tabulator-tables';
import Viewer from 'viewerjs';
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';
window.$ = window.jQuery = $;
if (typeof $.trim !== 'function') {
$.trim = function (text) {
return text == null ? '' : (text + '').trim();
};
}
window.localforage = localforage;
window.Toastify = Toastify;
window.md5 = md5;
window.Tabulator = Tabulator;
window.Viewer = Viewer;
window.QRCode = QRCode;
+175
View File
@@ -0,0 +1,175 @@
/*
* https://github.com/Tampermonkey/tampermonkey/issues/2215
*
* This script provides a workaround for a Chrome MV3 issue (https://github.com/w3c/webextensions/issues/694)
* where extensions can't set/delete headers that are preserved over redirects.
*
* By setting `redirect: 'manual'` and following redirects manually, this script ensures request redirects work
* as intended and requests to different URLs are made in parallel (again).
*
* Userscript authors should include this as a `@require` when they need to make parallel requests with GM_xmlhttpRequest,
* especially if requests might take a long time to complete.
*
* Including this script will modify the behavior of GM_xmlhttpRequest and GM.xmlHttpRequest in Tampermonkey only.
*
* Usage:
*
* Add this to the metadata block of your userscript:
*
* // @grant GM_xmlhttpRequest
* // @require https://raw.githubusercontent.com/Tampermonkey/utils/refs/heads/main/requires/gh_2215_make_GM_xhr_more_parallel_again.js
*
**/
/* global GM_info, GM_xmlhttpRequest, GM */
const HAS_GM = typeof GM !== 'undefined';
const NEW_GM = ((scope, GM) => {
// Check if running in Tampermonkey and if version supports redirect control
if (typeof GM_info === 'undefined' || GM_info.scriptHandler !== "Tampermonkey" || compareVersions(GM_info.version, "5.3.2") < 0) return;
// Backup original functions
const GM_xmlhttpRequestOrig = GM_xmlhttpRequest;
const GM_xmlHttpRequestOrig = GM.xmlHttpRequest;
function compareVersions(v1, v2) {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
const length = Math.max(parts1.length, parts2.length);
for (let i = 0; i < length; i++) {
const num1 = parts1[i] || 0;
const num2 = parts2[i] || 0;
if (num1 > num2) return 1;
if (num1 < num2) return -1;
}
return 0;
}
// Wrapper for GM_xmlhttpRequest
function GM_xmlhttpRequestWrapper(odetails) {
// If redirect is manually set, simply pass odetails to the original function
if (odetails.redirect !== undefined) {
return GM_xmlhttpRequestOrig(odetails);
}
// Warn if onprogress is used with settings incompatible with fetch mode used in background
if (odetails.onprogress || odetails.fetch === false) {
console.warn("Fetch mode does not support onprogress in the background.");
}
const {
onload,
onloadend,
onerror,
onabort,
ontimeout,
...details
} = odetails;
// Set redirect to manual and handle redirects
const handleRedirects = (initialDetails) => {
const request = GM_xmlhttpRequestOrig({
...initialDetails,
redirect: 'manual',
onload: function (response) {
if (response.status >= 300 && response.status < 400) {
const m = response.responseHeaders.match(/Location:\s*(\S+)/i);
// Follow redirect manually
const redirectUrl = m && m[1];
if (redirectUrl) {
const absoluteUrl = new URL(redirectUrl, initialDetails.url).href;
handleRedirects({...initialDetails, url: absoluteUrl});
return;
}
}
if (onload) onload.call(this, response);
if (onloadend) onloadend.call(this, response);
},
onerror: function (response) {
if (onerror) onerror.call(this, response);
if (onloadend) onloadend.call(this, response);
},
onabort: function (response) {
if (onabort) onabort.call(this, response);
if (onloadend) onloadend.call(this, response);
},
ontimeout: function (response) {
if (ontimeout) ontimeout.call(this, response);
if (onloadend) onloadend.call(this, response);
}
});
return request;
};
return handleRedirects(details);
}
// Wrapper for GM.xmlHttpRequest
function GM_xmlHttpRequestWrapper(odetails) {
let abort;
const p = new Promise((resolve, reject) => {
const {onload, ontimeout, onerror, ...send} = odetails;
send.onerror = function (r) {
if (onerror) {
resolve(r);
onerror.call(this, r);
} else {
reject(r);
}
};
send.ontimeout = function (r) {
if (ontimeout) {
// See comment above
resolve(r);
ontimeout.call(this, r);
} else {
reject(r);
}
};
send.onload = function (r) {
resolve(r);
if (onload) onload.call(this, r);
};
const a = GM_xmlhttpRequestWrapper(send).abort;
if (abort === true) {
a();
} else {
abort = a;
}
});
p.abort = () => {
if (typeof abort === 'function') {
abort();
} else {
abort = true;
}
};
return p;
}
// Export wrappers
GM_xmlhttpRequest = GM_xmlhttpRequestWrapper;
scope.GM_xmlhttpRequestOrig = GM_xmlhttpRequestOrig;
const gopd = Object.getOwnPropertyDescriptor(GM, 'xmlHttpRequest');
if (gopd && gopd.configurable === false) {
return {
__proto__: GM,
xmlHttpRequest: GM_xmlHttpRequestWrapper,
xmlHttpRequestOrig: GM_xmlHttpRequestOrig
};
} else {
GM.xmlHttpRequest = GM_xmlHttpRequestWrapper;
GM.xmlHttpRequestOrig = GM_xmlHttpRequestOrig;
}
})(globalThis, HAS_GM ? GM : {});
if (HAS_GM && NEW_GM) GM = NEW_GM;
+266
View File
@@ -0,0 +1,266 @@
import {GM_xmlhttpRequest} from 'vite-plugin-monkey/dist/client';
const HLS_SCRIPT_URL = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js';
let hlsLoadPromise = null;
const getHlsClass = () => {
const HlsClass = window.Hls || globalThis.Hls || (typeof Hls !== 'undefined' ? Hls : null);
return HlsClass && HlsClass.isSupported && HlsClass.isSupported() ? HlsClass : null;
};
const binaryTextToArrayBuffer = (value) => {
const text = String(value || '');
const bytes = new Uint8Array(text.length);
for (let index = 0; index < text.length; index += 1) {
bytes[index] = text.charCodeAt(index) & 0xff;
}
return bytes.buffer;
};
const loadHlsLibrary = () => {
const readyHls = getHlsClass();
if (readyHls) return Promise.resolve(readyHls);
if (hlsLoadPromise) return hlsLoadPromise;
const loadByGm = () => new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: HLS_SCRIPT_URL,
timeout: 15000,
onload: (r) => {
if (r.status >= 200 && r.status < 300 && r.responseText) {
try {
Function(`${r.responseText}\n//# sourceURL=${HLS_SCRIPT_URL}`).call(globalThis);
} catch (err) {
clog.error('HLS: hls.js 执行失败', err);
}
}
resolve(getHlsClass());
},
onerror: () => resolve(getHlsClass()),
ontimeout: () => resolve(getHlsClass()),
});
});
hlsLoadPromise = new Promise((resolve) => {
const existing = document.querySelector('script[data-jhs-hls="1"]');
if (existing) {
existing.addEventListener('load', () => resolve(getHlsClass()), {once: true});
existing.addEventListener('error', () => loadByGm().then(resolve), {once: true});
setTimeout(() => {
if (!getHlsClass()) loadByGm().then(resolve);
}, 4000);
return;
}
const hlsScript = document.createElement('script');
hlsScript.src = HLS_SCRIPT_URL;
hlsScript.async = true;
hlsScript.dataset.jhsHls = '1';
hlsScript.onload = () => resolve(getHlsClass());
hlsScript.onerror = () => loadByGm().then(resolve);
document.head.appendChild(hlsScript);
}).then((HlsClass) => {
if (!HlsClass) hlsLoadPromise = null;
return HlsClass;
});
return hlsLoadPromise;
};
const createHlsLoader = () => {
class GMHlsLoader {
constructor(config) {
this.config = config;
this.context = null;
this.callbacks = null;
this.loader = null;
this.stats = this.createStats();
}
createStats() {
return {
aborted: false,
loaded: 0,
retry: 0,
total: 0,
chunkCount: 0,
bwEstimate: 0,
trequest: 0,
tfirst: 0,
tload: 0,
loading: {start: 0, first: 0, end: 0},
parsing: {start: 0, end: 0},
buffering: {start: 0, first: 0, end: 0},
};
}
destroy() {
this.abort();
}
abort() {
if (this.stats) this.stats.aborted = true;
if (this.loader) {
try {
this.loader.abort();
} catch (e) { /* ignore */
}
}
this.loader = null;
}
load(context, config, callbacks) {
this.context = context;
this.callbacks = callbacks;
const requestUrl = context.url;
const wantsArrayBuffer =
context.responseType === 'arraybuffer' ||
/\.(?:ts|m4s|mp4|key)(?:[?#]|$)/i.test(requestUrl);
const startedAt = performance.now();
const stats = (this.stats = this.createStats());
stats.trequest = startedAt;
stats.tfirst = startedAt;
stats.tload = startedAt;
stats.loading.start = startedAt;
this.loader = GM_xmlhttpRequest({
method: 'GET',
url: requestUrl,
responseType: 'text',
overrideMimeType: wantsArrayBuffer ? 'text/plain; charset=x-user-defined' : undefined,
timeout: config && config.timeout ? config.timeout : 20000,
headers: {
Accept: wantsArrayBuffer
? '*/*'
: 'application/vnd.apple.mpegurl, application/x-mpegURL, */*',
},
onprogress: (event) => {
stats.loaded = Number(event && event.loaded ? event.loaded : stats.loaded || 0);
stats.total = Number(event && event.total ? event.total : stats.total || stats.loaded || 0);
if (!stats.loading.first && stats.loaded > 0) {
stats.loading.first = performance.now();
}
},
onload: (r) => {
const status = Number(r.status || 0);
const response = {
code: status,
text: r.statusText || '',
url: r.finalUrl || requestUrl,
};
stats.tfirst = stats.tfirst || performance.now();
stats.tload = performance.now();
stats.loading.first = stats.loading.first || stats.tload;
stats.loading.end = stats.tload;
if (status < 200 || status >= 300) {
callbacks.onError && callbacks.onError(response, context, null, stats);
return;
}
const responseText = r.responseText || r.response || '';
const data = wantsArrayBuffer
? binaryTextToArrayBuffer(responseText)
: responseText;
stats.loaded = data && data.byteLength ? data.byteLength : (data && data.length ? data.length : stats.loaded || 0);
stats.total = stats.total || stats.loaded;
stats.bwEstimate =
stats.loading.end > stats.loading.first
? Math.round((stats.total * 8000) / (stats.loading.end - stats.loading.first))
: 0;
callbacks.onSuccess && callbacks.onSuccess({data, url: response.url}, stats, context, response);
},
onerror: () => {
callbacks.onError &&
callbacks.onError({code: 0, text: 'network error', url: requestUrl}, context, null, stats);
},
ontimeout: () => {
stats.tload = performance.now();
stats.loading.end = stats.tload;
callbacks.onTimeout && callbacks.onTimeout(stats, context, null);
},
});
}
}
return GMHlsLoader;
};
const isM3U8Url = (url) => /\.m3u8(?:[?#].*)?$/i.test(String(url || ''));
const attachHlsToVideo = (videoEl, src) => {
return new Promise((resolve, reject) => {
if (!videoEl || !src) {
reject(new Error('video element or source missing'));
return;
}
const HlsClass = getHlsClass();
if (!HlsClass) {
videoEl.src = src;
videoEl.load && videoEl.load();
resolve(false);
return;
}
const hls = new HlsClass({
enableWorker: false,
lowLatencyMode: true,
loader: createHlsLoader(),
autoStartLoad: true,
startPosition: 0,
capLevelToPlayerSize: true,
testBandwidth: false,
preferManagedMediaSource: false,
maxBufferLength: 6,
maxMaxBufferLength: 12,
backBufferLength: 30,
maxBufferHole: 0.5,
nudgeOffset: 0.1,
manifestLoadingMaxRetry: 2,
levelLoadingMaxRetry: 2,
fragLoadingMaxRetry: 2,
manifestLoadingTimeOut: 12000,
levelLoadingTimeOut: 12000,
fragLoadingTimeOut: 12000,
abrEwmaFastLive: 3,
abrEwmaSlowLive: 9,
});
hls.on(HlsClass.Events.MANIFEST_PARSED, () => {
hls.startLoad(0);
videoEl.play().catch(() => {
});
resolve(true);
});
hls.on(HlsClass.Events.ERROR, (_, data) => {
if (!data || !data.fatal) return;
clog.error('HLS: 播放失败', data);
if (data.type === HlsClass.ErrorTypes.MEDIA_ERROR) {
if (videoEl.readyState >= 2) {
try {
hls.recoverMediaError && hls.recoverMediaError();
} catch (e) { /* ignore */
}
return;
}
}
reject(new Error(data.details || 'HLS fatal error'));
});
hls.loadSource(src);
hls.attachMedia(videoEl);
videoEl._hls = hls;
});
};
const destroyHls = (videoEl) => {
if (videoEl && videoEl._hls) {
try {
videoEl._hls.destroy();
} catch (e) { /* ignore */
}
videoEl._hls = null;
}
};
export {loadHlsLibrary, getHlsClass, createHlsLoader, isM3U8Url, attachHlsToVideo, destroyHls, HLS_SCRIPT_URL};
+7 -1
View File
@@ -1,4 +1,5 @@
import './core/globals.js';
import './lib/gm-xhr-parallel.js';
import { __defProp, __typeError, __publicField, __privateMethod, currentHref, isJavDb, isJavBus, isSearchPage, Status_RUNNING, Status_SUCCESS, Status_FAIL, Status_LOADING, Status_FILTER, Status_FAVORITE, Status_HAS_DOWN, Status_HAS_WATCH, NO, YES, qualityOptions, detailPageCss$1, javBusStyle, detailPageCss, javdbStyle, mainCss, insertStyle } from './core/constants.js';
import { StorageManager } from './core/storage.js';
import { Utils } from './core/utils.js';
@@ -16,6 +17,8 @@ import { DetailPagePlugin } from './plugins/detail-page.js';
import { PreviewVideoPlugin } from './plugins/preview-video.js';
import { JavTrailersPlugin } from './plugins/jav-trailers.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 { HighlightMagnetPlugin } from './plugins/highlight-magnet.js';
import { FoldCategoryPlugin } from './plugins/fold-category.js';
@@ -26,6 +29,7 @@ import { TOP250Plugin } from './plugins/top250.js';
import { NavBarPlugin } from './plugins/nav-bar.js';
import { BusDetailPagePlugin } from './plugins/bus-detail-page.js';
import { BusPreviewVideoPlugin } from './plugins/bus-preview-video.js';
import {JavxyPreviewVideoPlugin} from './plugins/javxy-preview-video.js';
import { ImageRecognitionPlugin } from './plugins/image-recognition.js';
import { BusNavBarPlugin } from './plugins/bus-nav-bar.js';
import { RelatedPlugin } from './plugins/related.js';
@@ -846,7 +850,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/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 pluginManager2 = new PluginManager;
@@ -873,6 +877,7 @@ const pluginManager = function() {
pluginManager2.register(DetailPageButtonPlugin);
pluginManager2.register(HighlightMagnetPlugin);
pluginManager2.register(PreviewVideoPlugin);
pluginManager2.register(JavxyPreviewVideoPlugin);
pluginManager2.register(FilterTitleKeywordPlugin);
pluginManager2.register(ActressInfoPlugin);
pluginManager2.register(OtherSitePlugin);
@@ -903,6 +908,7 @@ const pluginManager = function() {
pluginManager2.register(FilterTitleKeywordPlugin);
pluginManager2.register(HighlightMagnetPlugin);
pluginManager2.register(BusPreviewVideoPlugin);
pluginManager2.register(JavxyPreviewVideoPlugin);
pluginManager2.register(MagnetHubPlugin);
pluginManager2.register(ScreenShotPlugin);
pluginManager2.register(OtherSitePlugin);
+17 -7
View File
@@ -1,10 +1,12 @@
import { BasePlugin } from '../core/base-plugin.js';
import { __publicField, qualityOptions } from '../core/constants.js';
import {getDmmVideo, selectDefaultQuality} from '../api/dmm.js';
class BusPreviewVideoPlugin extends BasePlugin {
getName() {
return "BusPreviewVideoPlugin";
}
async initCss() {
return "\n /* 弹窗/Modal 样式 */\n .bus-preview-modal {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: rgba(0, 0, 0, 0.95); \n /* 关键修改:更新 z-index */\n z-index: 12345699; \n display: flex;\n justify-content: center;\n align-items: center;\n opacity: 0; \n visibility: hidden; \n transition: opacity 0.2s ease;\n }\n .bus-preview-modal.is-open {\n opacity: 1;\n visibility: visible;\n }\n /* 垂直排列视频和按钮,并居中 */\n .bus-preview-modal-content {\n position: relative;\n max-width: 95%; \n max-height: 95%;\n display: flex; \n flex-direction: column; \n align-items: center; \n gap: 15px; \n }\n \n /* 移除 .bus-preview-close-btn 的样式 */\n\n /* 视频播放器容器 */\n .video-player-wrapper {\n /* 关键修改:更新 width 和 max-height */\n width: 80vw; \n max-height: 85vh; \n aspect-ratio: 16 / 9; \n position: relative; \n background-color: black; \n max-width: 100%; \n }\n /* 视频元素 */\n .video-player-wrapper #preview-video {\n position: absolute; \n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: block;\n }\n\n /* 画质控制盒 (底部按钮) */\n .video-control-box {\n display: flex;\n flex-direction: row; \n justify-content: center; \n flex-wrap: wrap; \n gap: 10px;\n padding: 10px 0; \n }\n\n /* 按钮样式 (保留) */\n .video-control-btn {\n min-width:80px;\n padding: 6px 12px;\n background: rgba(255,255,255,0.2);\n color: white;\n border: 1px solid rgba(255,255,255,0.5);\n border-radius: 4px;\n cursor: pointer;\n text-align: center;\n font-size: 14px;\n transition: background-color 0.2s, border-color 0.2s;\n }\n .video-control-btn:hover {\n background: rgba(255,255,255,0.4);\n }\n .video-control-btn.active {\n background-color: #1890ff; \n color: white;\n font-weight: bold;\n border: 1px solid #096dd9;\n }\n ";
}
@@ -22,7 +24,10 @@ class BusPreviewVideoPlugin extends BasePlugin {
}
closeVideoModal() {
const $previewVideo = $("#preview-video");
$previewVideo.length > 0 && $previewVideo[0].pause();
if ($previewVideo.length > 0) {
const videoEl = $previewVideo[0];
videoEl.pause();
}
$("#bus-preview-modal").removeClass("is-open");
}
async handle() {
@@ -51,7 +56,8 @@ class BusPreviewVideoPlugin extends BasePlugin {
let $previewVideo = $("#preview-video");
if ($previewVideo.length > 0) {
$modal.addClass("is-open");
$previewVideo[0].play().catch((e => console.warn("尝试播放失败 (可能被浏览器阻止):", e)));
const videoEl = $previewVideo[0];
videoEl.play().catch((e => console.warn("尝试播放失败 (可能被浏览器阻止):", e)));
return;
}
let carNum2 = this.getPageInfo().carNum;
@@ -70,13 +76,15 @@ class BusPreviewVideoPlugin extends BasePlugin {
defaultVideoQuality = selectDefaultQuality(Object.keys(dmmVideoMap), defaultVideoQuality);
let defaultVideoUrl = dmmVideoMap[defaultVideoQuality];
$container.html(`\n <div class="video-player-wrapper">\n <video id="preview-video" controls playsinline>\n <source src="${defaultVideoUrl}" />\n </video>\n </div>\n <div class="video-control-box">\n </div>\n `);
const $videoEl = $("#preview-video"), $previewSource = $videoEl.find("source"), $qualityControlsBox = $container.find(".video-control-box");
if (!$videoEl.length || !$previewSource.length) return;
const videoEl = $videoEl[0], jhs_videoMuted = localStorage.getItem("jhs_videoMuted");
const $videoEl = $("#preview-video"), $qualityControlsBox = $container.find(".video-control-box");
if (!$videoEl.length) return;
const videoEl = $videoEl[0];
const jhs_videoMuted = localStorage.getItem("jhs_videoMuted");
videoEl.muted = !jhs_videoMuted || "yes" === jhs_videoMuted;
videoEl.addEventListener("volumechange", (function() {
localStorage.setItem("jhs_videoMuted", videoEl.muted ? "yes" : "no");
}));
let buttonsHtml = "";
qualityOptions.forEach((option => {
let dmmVideoUrl = dmmVideoMap[option.quality];
@@ -92,10 +100,12 @@ class BusPreviewVideoPlugin extends BasePlugin {
const $button = $(e.currentTarget);
if ($button.hasClass("active")) return;
let videoSrc = $button.attr("data-video-src");
$previewSource.attr("src", videoSrc);
const currentTime = videoEl.currentTime;
videoEl.src = videoSrc;
videoEl.load();
videoEl.currentTime = currentTime;
if (currentTime > 0 && Number.isFinite(currentTime)) {
videoEl.currentTime = currentTime;
}
await videoEl.play();
$buttons.removeClass("active");
$button.addClass("active");
File diff suppressed because one or more lines are too long
+236 -13
View File
@@ -22,7 +22,8 @@ class DetailPageButtonPlugin extends BasePlugin {
window.isDetailPage && this.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);
isJavBus && $("#mag-submit-show").before(buttonsHtml);
$("#favoriteBtn").on("click", (() => this.favoriteOne()));
@@ -42,25 +43,244 @@ class DetailPageButtonPlugin extends BasePlugin {
}
});
}));
const highlightMagnetPlugin = this.getBean("HighlightMagnetPlugin"), enableMagnetsFilter = await storageManager.getSetting("enableMagnetsFilter", YES);
$("#magnets-span").text(enableMagnetsFilter === YES ? "关闭磁力过滤" : "开启磁力过滤");
enableMagnetsFilter === YES && highlightMagnetPlugin.doFilterMagnet();
const highlightMagnetPlugin = this.getBean("HighlightMagnetPlugin"),
enableMagnetsFilter = await storageManager.getSetting("enableMagnetsFilter", NO);
if (enableMagnetsFilter === YES) {
const savedSettings = await storageManager.getSetting("magnetSortAndFilter", "{}");
let settings;
try {
settings = JSON.parse(savedSettings);
} catch (e) {
settings = {};
}
const sortEnabled = settings.sortEnabled || {};
const defaultFullSortOrder = isJavBus ? ["date", "size"] : ["date", "size", "files"];
const fullSortOrder = (settings.sortOrder && settings.sortOrder.length === defaultFullSortOrder.length) ? settings.sortOrder : defaultFullSortOrder;
const effectiveSortOrder = fullSortOrder.filter(key => sortEnabled[key]);
const applySettings = {
filterHD: settings.filterHD || !1,
filter4K: settings.filter4K || !1,
filterSubtitle: settings.filterSubtitle || !1,
filterUncensored: settings.filterUncensored || !1,
sortOrder: effectiveSortOrder,
};
const rowReadyCondition = isJavBus
? (() => $("#magnet-table tr").length > 1)
: (() => $("#magnets-content .item[data-rank]").length > 0);
utils.loopDetector(rowReadyCondition, () => {
highlightMagnetPlugin.applySortAndFilter(applySettings);
});
$("#magnets-span").text("取消磁力过滤");
} else {
$("#magnets-span").text("排序与过滤");
}
$("#enable-magnets-filter").on("click", (event => {
let $span = $("#magnets-span");
if ("关闭磁力过滤" === $span.text()) {
const $span = $("#magnets-span");
if ("取消磁力过滤" === $span.text()) {
highlightMagnetPlugin.showAll();
$span.text("开启磁力过滤");
$span.text("排序与过滤");
storageManager.saveSettingItem("enableMagnetsFilter", NO);
} else {
highlightMagnetPlugin.doFilterMagnet();
$span.text("关闭磁力过滤");
storageManager.saveSettingItem("enableMagnetsFilter", YES);
this.openMagnetSortFilterPanel(highlightMagnetPlugin);
}
}));
$("#search-subtitle-btn").on("click", (event => utils.openPage(`https://subtitlecat.com/index.php?search=${carNum2}`, carNum2, !1, event)));
$("#xunLeiSubtitleBtn").on("click", (() => this.searchXunLeiSubtitle(carNum2)));
$("#subtitleSearchBtn").on("click", (() => {
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();
}
async openMagnetSortFilterPanel(highlightMagnetPlugin) {
const existingPanel = document.getElementById("magnet-sort-filter-panel");
if (existingPanel) {
existingPanel.remove();
return;
}
const savedSettings = await storageManager.getSetting("magnetSortAndFilter", "{}");
let settings;
try {
settings = JSON.parse(savedSettings);
} catch (e) {
settings = {};
}
const sortEnabled = settings.sortEnabled || (isJavBus ? {date: !1, size: !1} : {date: !1, size: !1, files: !1});
const defaultSortOrder = isJavBus ? ["date", "size"] : ["date", "size", "files"];
const sortOrder = (settings.sortOrder && settings.sortOrder.length === defaultSortOrder.length) ? settings.sortOrder : defaultSortOrder;
const filterHD = settings.filterHD || !1;
const filter4K = settings.filter4K || !1;
const filterSubtitle = settings.filterSubtitle || !1;
const filterUncensored = settings.filterUncensored || !1;
const SORT_LABELS = isJavBus ? {date: "发布日期", size: "文件大小"} : {
date: "发布日期",
size: "文件大小",
files: "文件数量"
};
const SORT_HINTS = isJavBus ? {date: "从新到旧", size: "从大到小"} : {
date: "从新到旧",
size: "从大到小",
files: "从少到多"
};
const FILTER_ITEMS = [
{key: "HD", label: "高清", checked: filterHD},
{key: "4K", label: "4K", checked: filter4K},
{key: "Subtitle", label: "字幕", checked: filterSubtitle},
{key: "Uncensored", label: "无码", checked: filterUncensored},
];
const currentSortOrder = [...sortOrder];
const currentSortEnabled = {...sortEnabled};
const currentFilters = {
HD: filterHD,
"4K": filter4K,
Subtitle: filterSubtitle,
Uncensored: filterUncensored,
};
const buildSortItemsHTML = () => {
return currentSortOrder.map(key => {
const checked = currentSortEnabled[key] ? "checked" : "";
return `<div class="msf-sort-item" draggable="true" data-sort-key="${key}">
<span class="msf-drag-handle">⋮⋮</span>
<label class="msf-sort-label">
<input type="checkbox" class="msf-sort-checkbox" data-sort-key="${key}" ${checked}>
${SORT_LABELS[key]} <span class="msf-sort-hint">${SORT_HINTS[key]}</span>
</label>
</div>`;
}).join("");
};
const buildFilterItemsHTML = () => {
return FILTER_ITEMS.map(item => {
const checked = currentFilters[item.key] ? "checked" : "";
return `<label class="msf-filter-label">
<input type="checkbox" class="msf-filter-checkbox" data-filter-key="${item.key}" ${checked}>
${item.label}
</label>`;
}).join("");
};
const panelHTML = `
<div class="msf-overlay" id="msf-overlay"></div>
<div class="msf-panel" id="magnet-sort-filter-panel">
<div class="msf-header">
<span class="msf-title">磁力排序与过滤</span>
<span class="msf-close" id="msf-close-btn">&times;</span>
</div>
<div class="msf-body">
<div class="msf-section">
<div class="msf-section-title">排序优先级(拖动调整顺序,勾选启用)</div>
<div class="msf-sort-list" id="msf-sort-list">
${buildSortItemsHTML()}
</div>
</div>
<div class="msf-section">
<div class="msf-section-title">过滤条件(可多选,并集关系)</div>
<div class="msf-filter-list">
${buildFilterItemsHTML()}
</div>
</div>
</div>
<div class="msf-footer">
<button class="msf-btn msf-btn-cancel" id="msf-cancel-btn">取消</button>
<button class="msf-btn msf-btn-confirm" id="msf-confirm-btn">确认</button>
</div>
</div>
`;
const wrapper = document.createElement("div");
wrapper.innerHTML = panelHTML;
document.body.appendChild(wrapper);
const panel = document.getElementById("magnet-sort-filter-panel");
const overlay = document.getElementById("msf-overlay");
const closePanel = () => {
panel.remove();
overlay.remove();
};
overlay.addEventListener("click", closePanel);
document.getElementById("msf-close-btn").addEventListener("click", closePanel);
document.getElementById("msf-cancel-btn").addEventListener("click", closePanel);
const rebuildSortList = () => {
const sortList = document.getElementById("msf-sort-list");
sortList.innerHTML = buildSortItemsHTML();
bindSortEvents();
};
const bindSortEvents = () => {
const sortItems = document.querySelectorAll("#msf-sort-list .msf-sort-item");
sortItems.forEach(item => {
item.addEventListener("dragstart", (e) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", item.dataset.sortKey);
item.classList.add("msf-dragging");
});
item.addEventListener("dragend", () => {
item.classList.remove("msf-dragging");
document.querySelectorAll("#msf-sort-list .msf-sort-item").forEach(el => el.classList.remove("msf-drag-over"));
});
item.addEventListener("dragover", (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
item.classList.add("msf-drag-over");
});
item.addEventListener("dragleave", () => {
item.classList.remove("msf-drag-over");
});
item.addEventListener("drop", (e) => {
e.preventDefault();
item.classList.remove("msf-drag-over");
const fromKey = e.dataTransfer.getData("text/plain");
const toKey = item.dataset.sortKey;
if (fromKey === toKey) return;
const fromIdx = currentSortOrder.indexOf(fromKey);
const toIdx = currentSortOrder.indexOf(toKey);
currentSortOrder.splice(fromIdx, 1);
currentSortOrder.splice(toIdx, 0, fromKey);
rebuildSortList();
});
const checkbox = item.querySelector(".msf-sort-checkbox");
checkbox.addEventListener("change", () => {
currentSortEnabled[item.dataset.sortKey] = checkbox.checked;
});
});
};
bindSortEvents();
document.querySelectorAll(".msf-filter-checkbox").forEach(cb => {
cb.addEventListener("change", () => {
currentFilters[cb.dataset.filterKey] = cb.checked;
});
});
document.getElementById("msf-confirm-btn").addEventListener("click", async () => {
try {
const enabledSortOrder = currentSortOrder.filter(key => currentSortEnabled[key]);
const newSettings = {
sortOrder: currentSortOrder,
sortEnabled: currentSortEnabled,
filterHD: currentFilters.HD,
filter4K: currentFilters["4K"],
filterSubtitle: currentFilters.Subtitle,
filterUncensored: currentFilters.Uncensored,
};
await storageManager.saveSettingItem("magnetSortAndFilter", JSON.stringify(newSettings));
const hasAnyFilter = currentFilters.HD || currentFilters["4K"] || currentFilters.Subtitle || currentFilters.Uncensored;
const hasAnySort = enabledSortOrder.length > 0;
if (hasAnyFilter || hasAnySort) {
await highlightMagnetPlugin.applySortAndFilter({
filterHD: currentFilters.HD,
filter4K: currentFilters["4K"],
filterSubtitle: currentFilters.Subtitle,
filterUncensored: currentFilters.Uncensored,
sortOrder: enabledSortOrder,
});
$("#magnets-span").text("取消磁力过滤");
await storageManager.saveSettingItem("enableMagnetsFilter", YES);
} else {
highlightMagnetPlugin.showAll();
$("#magnets-span").text("排序与过滤");
await storageManager.saveSettingItem("enableMagnetsFilter", NO);
}
} catch (e) {
console.error("[磁力排序] 应用失败:", e);
}
closePanel();
});
}
async showStatus(carNum2) {
const $filterBtn = $("#filterBtn span"), $favoriteBtn = $("#favoriteBtn span"), $hasDownBtn = $("#hasDownBtn span"), $hasWatchBtn = $("#hasWatchBtn span"), hotKeyDisplay = hotKey => hotKey ? `(${hotKey})` : "";
$filterBtn.text(`🚫 屏蔽 ${hotKeyDisplay(this.filterHotKey)}`);
@@ -124,6 +344,8 @@ class DetailPageButtonPlugin extends BasePlugin {
window.refresh();
utils.closePage();
}
/*
searchXunLeiSubtitle(carNum2) {
let loadObj = loading();
gmHttp.get(`https://api-shoulei-ssl.xunlei.com/oracle/subtitle?gcid=&cid=&name=${carNum2}`).then((res => {
@@ -205,6 +427,7 @@ class DetailPageButtonPlugin extends BasePlugin {
loadObj.close();
}));
}
*/
async filterOne(event, noAlert) {
event && event.preventDefault();
let pageInfo = this.getPageInfo();
@@ -333,4 +556,4 @@ class DetailPageButtonPlugin extends BasePlugin {
}
export { DetailPageButtonPlugin };
export { DetailPageButtonPlugin };
+9 -5
View File
@@ -171,7 +171,7 @@ class Fc2By123AvPlugin extends BasePlugin {
async open123AvFc2Dialog(carNum2, href) {
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>');
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({
type: 1,
title: carNum2,
@@ -235,8 +235,12 @@ class Fc2By123AvPlugin extends BasePlugin {
window.refresh();
layer.closeAll();
}));
$("#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)));
$("#subtitleSearchBtn").on("click", (() => {
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-", "");
this.getBean("OtherSitePlugin").loadOtherSite(tempCarNum, carNum2).then();
}
@@ -265,7 +269,7 @@ class Fc2By123AvPlugin extends BasePlugin {
$(".screen-container").on("click", (event => {
event.stopPropagation();
event.preventDefault();
showImageViewer(event.currentTarget);
showPreviewOverlay(imgUrl, carNum2);
}));
}
}));
@@ -351,4 +355,4 @@ class Fc2By123AvPlugin extends BasePlugin {
}
export { Fc2By123AvPlugin };
export { Fc2By123AvPlugin };
+8 -4
View File
@@ -33,7 +33,7 @@ class Fc2Plugin extends BasePlugin {
layer.open({
type: 1,
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%" ]),
skin: "movie-detail-layer",
scrollbar: !1,
@@ -90,8 +90,12 @@ class Fc2Plugin extends BasePlugin {
window.refresh();
layer.closeAll();
}));
$("#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)));
$("#subtitleSearchBtn").on("click", (() => {
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", (() => {
let magnetHub = this.getBean("MagnetHubPlugin").createMagnetHub(carNum2);
layer.open({
@@ -147,7 +151,7 @@ class Fc2Plugin extends BasePlugin {
utils.loopDetector((() => $(".movie-gallery .image-list").length > 0), (async () => {
$(".movie-gallery .image-list").prepend(' <a class="tile-item screen-container" style="overflow:hidden;max-height: 150px;max-width:150px; text-align:center;"><div style="margin-top: 50px;color: #000;cursor: auto">正在加载缩略图</div></a> ');
const screenShotPlugin = this.getBean("ScreenShotPlugin"), imgUrl = await screenShotPlugin.getScreenshot(carNum2);
imgUrl && await screenShotPlugin.addImg("缩略图", imgUrl);
imgUrl && await screenShotPlugin.addImg("缩略图", imgUrl, carNum2);
}));
}
handleMagnets(movieId) {
+202 -23
View File
@@ -5,26 +5,211 @@ class HighlightMagnetPlugin extends BasePlugin {
getName() {
return "HighlightMagnetPlugin";
}
doFilterMagnet() {
this.handleDb();
this.handleBus();
parseDateFromAttr(dateStr) {
if (!dateStr) return 0;
if (dateStr.length === 8) {
const y = parseInt(dateStr.substring(0, 4), 10);
const m = parseInt(dateStr.substring(4, 6), 10) - 1;
const d = parseInt(dateStr.substring(6, 8), 10);
const date = new Date(y, m, d);
return isNaN(date.getTime()) ? 0 : date.getTime();
}
const d = new Date(dateStr);
return isNaN(d.getTime()) ? 0 : d.getTime();
}
handleDb() {
parseSizeToBytes(sizeText) {
if (!sizeText) return 0;
const s = sizeText.trim().toUpperCase();
const num = parseFloat(s);
if (isNaN(num)) return 0;
if (s.includes('TB')) return num * 1024 * 1024 * 1024 * 1024;
if (s.includes('GB')) return num * 1024 * 1024 * 1024;
if (s.includes('MB')) return num * 1024 * 1024;
if (s.includes('KB')) return num * 1024;
return num;
}
getRowInfo(row) {
const $row = $(row);
const nameText = $row.find('.name').text().toLowerCase();
const tags = [];
$row.find('.tags .tag').each((_, tag) => tags.push($(tag).text().toLowerCase()));
const sizeMB = parseInt($row.attr('data-size'), 10) || 0;
const files = parseInt($row.attr('data-files'), 10) || 0;
const dateStr = $row.attr('data-date') || '';
return {
row: row,
$row: $row,
nameText: nameText,
date: this.parseDateFromAttr(dateStr),
dateStr: dateStr,
size: sizeMB * 1024 * 1024,
files: files,
tags: tags,
is4K: nameText.includes('4k') || nameText.includes('[4k]') || tags.includes('4k'),
isHD: nameText.includes('高清') || tags.includes('高清'),
isSubtitle: nameText.includes('字幕') || nameText.includes('-c') || nameText.includes('.chs') || nameText.includes('.cht') || nameText.includes('.chi') || tags.includes('字幕'),
isUncensored: nameText.includes('无码') || nameText.includes('無碼') || nameText.includes('-u') || nameText.includes('-uc') || tags.includes('无码') || tags.includes('無碼'),
};
}
getRowInfoForBus(row) {
const $row = $(row);
const $nameCell = $row.find('td:first-child');
const nameText = ($nameCell.find('a:first-child').text() || '').toLowerCase().trim();
const sizeText = ($row.find('td:nth-child(2)').text() || '').trim();
const dateText = ($row.find('td:nth-child(3)').text() || '').trim();
const hasHDButton = $nameCell.find('a.btn-primary').text().includes('高清');
return {
row: row,
$row: $row,
nameText: nameText,
date: this.parseDateFromAttr(dateText),
dateStr: dateText,
size: this.parseSizeToBytes(sizeText),
files: 0,
tags: [],
is4K: nameText.includes('4k') || nameText.includes('[4k]'),
isHD: hasHDButton || nameText.includes('高清'),
isSubtitle: nameText.includes('字幕') || nameText.includes('-c') || nameText.includes('.chs') || nameText.includes('.cht') || nameText.includes('.chi'),
isUncensored: nameText.includes('无码') || nameText.includes('無碼') || nameText.includes('-u') || nameText.includes('-uc'),
};
}
async applySortAndFilter(settings) {
if (isJavBus) {
return this.applySortAndFilterForBus(settings);
}
if (!isJavDb) return;
let magnetNameList = $("#magnets-content .name");
if (0 === magnetNameList.length) return;
const HIGH_QUALITY_KEYWORDS = [ "4k", "-c", "-u", "-uc" ];
let hasHighQuality = !1;
magnetNameList.each(((_, el) => {
const $el = $(el), text = $el.text().toLowerCase(), isHighQuality = HIGH_QUALITY_KEYWORDS.some((keyword => text.includes(keyword)));
$el.parent().parent().parent().addClass("magnet-row");
text.includes("4k") && $el.css("color", "#f40");
if (isHighQuality) {
hasHighQuality = !0;
$el.parent().parent().parent().addClass("high-quality");
const $container = $("#magnets-content");
const $rows = $container.find(".item[data-rank]");
if (0 === $rows.length) return;
const rowInfos = $rows.toArray().map(row => this.getRowInfo(row));
const {filterHD, filter4K, filterSubtitle, filterUncensored, sortOrder} = settings;
const anyFilterEnabled = filterHD || filter4K || filterSubtitle || filterUncensored;
let visibleInfos = rowInfos;
if (anyFilterEnabled) {
visibleInfos = rowInfos.filter(info => {
if (filterHD && info.isHD) return !0;
if (filter4K && info.is4K) return !0;
if (filterSubtitle && info.isSubtitle) return !0;
if (filterUncensored && info.isUncensored) return !0;
return !1;
});
}
if (sortOrder && sortOrder.length > 0) {
visibleInfos.sort((a, b) => {
for (const key of sortOrder) {
let cmp = 0;
switch (key) {
case 'date':
cmp = b.date - a.date;
break;
case 'size':
cmp = b.size - a.size;
break;
case 'files':
cmp = a.files - b.files;
break;
}
if (0 !== cmp) return cmp;
}
return 0;
});
}
const container = document.getElementById("magnets-content");
if (!container) return;
const hiddenInfos = rowInfos.filter(info => !visibleInfos.includes(info));
const reviewsContainer = document.getElementById("reviewsContainer");
const insertRef = reviewsContainer && reviewsContainer.parentNode === container
? (reviewsContainer.previousElementSibling || reviewsContainer)
: null;
const fragment = document.createDocumentFragment();
visibleInfos.forEach(info => fragment.appendChild(info.row));
hiddenInfos.forEach(info => fragment.appendChild(info.row));
if (insertRef) {
container.insertBefore(fragment, insertRef);
} else {
container.appendChild(fragment);
}
visibleInfos.forEach(info => {
if (info.is4K) {
info.$row.addClass('magnet-4k-highlight');
}
}));
hasHighQuality ? $("#magnets-content .magnet-row").not(".high-quality").hide() : $("#enable-magnets-filter").addClass("do-hide");
});
hiddenInfos.forEach(info => $(info.row).hide());
}
applySortAndFilterForBus(settings) {
const $table = $("#magnet-table");
if (!$table.length) return;
const allRows = $table.find("tr").toArray();
const headerRow = allRows.find(r => $(r).find('td').length === 0 || $(r).css('font-weight') === 'bold' || $(r).css('fontWeight') === 'bold' || $(r).css('font-weight') === '700');
const dataRows = headerRow ? allRows.filter(r => r !== headerRow) : allRows.slice(1);
if (0 === dataRows.length) return;
const rowInfos = dataRows.map(row => this.getRowInfoForBus(row));
const {filterHD, filter4K, filterSubtitle, filterUncensored, sortOrder} = settings;
const anyFilterEnabled = filterHD || filter4K || filterSubtitle || filterUncensored;
let visibleInfos = rowInfos;
if (anyFilterEnabled) {
visibleInfos = rowInfos.filter(info => {
if (filterHD && info.isHD) return !0;
if (filter4K && info.is4K) return !0;
if (filterSubtitle && info.isSubtitle) return !0;
if (filterUncensored && info.isUncensored) return !0;
return !1;
});
}
if (sortOrder && sortOrder.length > 0) {
visibleInfos.sort((a, b) => {
for (const key of sortOrder) {
let cmp = 0;
switch (key) {
case 'date':
cmp = b.date - a.date;
break;
case 'size':
cmp = b.size - a.size;
break;
}
if (0 !== cmp) return cmp;
}
return 0;
});
}
const hiddenInfos = rowInfos.filter(info => !visibleInfos.includes(info));
const fragment = document.createDocumentFragment();
visibleInfos.forEach(info => fragment.appendChild(info.row));
hiddenInfos.forEach(info => fragment.appendChild(info.row));
const insertAnchor = headerRow || $table.find("tr").first()[0];
if (insertAnchor && insertAnchor.nextSibling) {
$(insertAnchor).after(fragment);
} else {
$table.append(fragment);
}
visibleInfos.forEach(info => {
if (info.is4K) {
info.$row.addClass('magnet-4k-highlight');
}
});
hiddenInfos.forEach(info => $(info.row).hide());
}
showAll() {
if (isJavDb) {
$("#magnets-content .item[data-rank]").toArray().forEach((el => {
$(el).show();
$(el).removeClass('magnet-4k-highlight');
}));
}
if (isJavBus) {
$("#magnet-table tr").toArray().forEach((el => {
$(el).show();
$(el).removeClass('magnet-4k-highlight');
}));
}
}
handleBus() {
isJavBus && window.isDetailPage && utils.loopDetector((() => $("#magnet-table td a").length > 0), (() => {
@@ -44,12 +229,6 @@ class HighlightMagnetPlugin extends BasePlugin {
})) : $("#enable-magnets-filter").addClass("do-hide");
}));
}
showAll() {
if (isJavDb) {
$("#magnets-content .item").toArray().forEach((el => $(el).show()));
}
isJavBus && $("#magnet-table tr").toArray().forEach((el => $(el).show()));
}
}
+239
View File
@@ -0,0 +1,239 @@
import {BasePlugin} from '../core/base-plugin.js';
import {__publicField, isJavBus, isJavDb, qualityOptions} from '../core/constants.js';
import {isM3U8Url, attachHlsToVideo, destroyHls, loadHlsLibrary, getHlsClass} from '../lib/hls-runtime.js';
import {getJavxyVideoUrls} from '../api/javxy.js';
class JavxyPreviewVideoPlugin extends BasePlugin {
getName() {
return "JavxyPreviewVideoPlugin";
}
_attachVideoSrc(videoEl, src) {
if (!videoEl || !src) return;
destroyHls(videoEl);
if (isM3U8Url(src)) {
const HlsClass = getHlsClass();
if (HlsClass) {
attachHlsToVideo(videoEl, src).catch((err) => {
clog.error('视频预览 HLS 加载失败:', err);
videoEl.src = src;
videoEl.load && videoEl.load();
});
} else {
loadHlsLibrary().then((HlsClass) => {
if (videoEl && videoEl.isConnected && HlsClass) {
attachHlsToVideo(videoEl, src).catch((err) => {
clog.error('视频预览 HLS 延迟加载失败:', err);
videoEl.src = src;
videoEl.load && videoEl.load();
});
} else if (videoEl && videoEl.isConnected) {
videoEl.src = src;
videoEl.load && videoEl.load();
}
});
}
} else {
videoEl.src = src;
videoEl.load && videoEl.load();
}
}
_selectDefaultQuality(qualityList, intendedDefault) {
if (!qualityList || qualityList.length === 0) return null;
const availableSet = new Set(qualityList);
if (availableSet.has(intendedDefault)) return intendedDefault;
const priorityOrder = qualityOptions.map(o => o.quality).reverse();
for (const q of priorityOrder) if (availableSet.has(q)) return q;
return qualityList[0];
}
async initCss() {
return `
.jhs-javxy-video-modal {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.95); z-index: 12345700;
display: flex; justify-content: center; align-items: center;
opacity: 0; visibility: hidden; transition: opacity 0.2s;
}
.jhs-javxy-video-modal.is-open { opacity: 1; visibility: visible; }
.jhs-javxy-video-modal-inner {
display: flex; flex-direction: column; align-items: center;
gap: 12px; max-width: 90vw; max-height: 90vh;
}
.jhs-javxy-video-wrapper {
width: 80vw; max-height: 80vh; aspect-ratio: 16/9;
background: #000; position: relative;
}
.jhs-javxy-video-wrapper video {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
}
.jhs-javxy-quality-bar {
display: flex; gap: 8px; flex-wrap: wrap; justify-content: center;
}
.jhs-javxy-quality-btn {
min-width: 60px; padding: 5px 10px; font-size: 13px;
background: rgba(255,255,255,0.2); color: #fff;
border: 1px solid rgba(255,255,255,0.5); border-radius: 4px;
cursor: pointer; transition: background 0.2s;
}
.jhs-javxy-quality-btn:hover { background: rgba(255,255,255,0.4); }
.jhs-javxy-quality-btn.active {
background: #1890ff; border-color: #096dd9; font-weight: bold;
}
.jhs-javxy-loading {
color: #fff; font-size: 16px; text-align: center;
}
`;
}
async handle() {
if (!window.isDetailPage) return;
const carNum = this.getPageInfo().carNum;
if (!carNum) return;
this._addJavxyButton(carNum);
}
_addJavxyButton(carNum) {
let $target = null;
if (isJavDb) {
$target = $('a[title="複製番號"]');
} else if (isJavBus) {
const headerSpans = document.querySelectorAll("span.header");
for (const span of headerSpans) {
if (span.textContent.trim() === "識別碼:") {
const nextSpan = span.nextElementSibling;
if (nextSpan && nextSpan.tagName === "SPAN") {
const copyBtn = nextSpan.nextElementSibling;
if (copyBtn && copyBtn.tagName === "BUTTON" && copyBtn.textContent.trim() === "复制") {
$target = $(copyBtn);
} else {
$target = $(nextSpan);
}
}
break;
}
}
}
if (!$target || !$target.length) return;
const $btn = $(`<a class="site-btn" style="min-width: auto; padding: 0 5px; margin-left: 6px; margin-bottom: 0; background-color: #1890ff; vertical-align: middle; cursor: pointer; font-size: 11px;"><span>视频预览</span></a>`);
$btn.on("click", async (e) => {
e.preventDefault();
e.stopPropagation();
$btn.css("pointer-events", "none").find("span").text("...");
try {
await this._openJavxyPlayer(carNum);
} finally {
$btn.css("pointer-events", "").find("span").text("视频预览");
}
});
$target.after($btn);
}
async _openJavxyPlayer(carNum) {
if ($("#jhs-javxy-video-modal").length === 0) {
$("body").append(`
<div id="jhs-javxy-video-modal" class="jhs-javxy-video-modal">
<div class="jhs-javxy-video-modal-inner">
<div class="jhs-javxy-video-wrapper">
<video id="jhs-javxy-video" controls playsinline></video>
</div>
<div class="jhs-javxy-quality-bar"></div>
</div>
</div>
`);
const $modal = $("#jhs-javxy-video-modal");
$modal.on("click", (e) => {
if (e.target.id === "jhs-javxy-video-modal") {
$modal.removeClass("is-open");
const videoEl = document.getElementById("jhs-javxy-video");
if (videoEl) {
destroyHls(videoEl);
videoEl.pause();
}
}
});
$(document).on("keydown", (e) => {
if (e.key === "Escape" && $modal.hasClass("is-open")) {
$modal.removeClass("is-open");
const videoEl = document.getElementById("jhs-javxy-video");
if (videoEl) {
destroyHls(videoEl);
videoEl.pause();
}
}
});
}
const $modal = $("#jhs-javxy-video-modal");
const $wrapper = $modal.find(".jhs-javxy-video-wrapper");
const $qualityBar = $modal.find(".jhs-javxy-quality-bar");
$wrapper.html('<div class="jhs-javxy-loading">视频预览加载中...</div>');
$qualityBar.empty();
$modal.addClass("is-open");
let videoMap = null;
try {
videoMap = await getJavxyVideoUrls(carNum);
} catch (err) {
$wrapper.html(`<div class="jhs-javxy-loading">视频预览请求失败: ${err.message}</div>`);
return;
}
if (!videoMap || Object.keys(videoMap).length === 0) {
$wrapper.html('<div class="jhs-javxy-loading">未找到可用的视频源</div>');
return;
}
const qualityList = Object.keys(videoMap);
const defaultQuality = this._selectDefaultQuality(qualityList, '720p');
const defaultUrl = videoMap[defaultQuality];
$wrapper.html(`<video id="jhs-javxy-video" controls playsinline>
<source src="${defaultUrl}" />
</video>`);
const videoEl = document.getElementById("jhs-javxy-video");
if (!videoEl) return;
if (isM3U8Url(defaultUrl)) {
this._attachVideoSrc(videoEl, defaultUrl);
}
let buttonsHtml = "";
qualityOptions.forEach((option) => {
const url = videoMap[option.quality];
if (url) {
buttonsHtml += `<button class="jhs-javxy-quality-btn${option.quality === defaultQuality ? ' active' : ''}"
data-quality="${option.quality}" data-video-src="${url}">${option.text}</button>`;
}
});
$qualityBar.html(buttonsHtml);
$qualityBar.off("click").on("click", ".jhs-javxy-quality-btn", (e) => {
const $btn = $(e.currentTarget);
if ($btn.hasClass("active")) return;
const src = $btn.attr("data-video-src");
const currentTime = videoEl.currentTime;
destroyHls(videoEl);
this._attachVideoSrc(videoEl, src);
videoEl.load();
if (currentTime > 0 && Number.isFinite(currentTime)) {
videoEl.currentTime = currentTime;
}
videoEl.play().catch(() => {
});
$qualityBar.find(".jhs-javxy-quality-btn").removeClass("active");
$btn.addClass("active");
});
videoEl.play().catch((e) => console.warn("视频预览播放失败:", e));
}
}
export {JavxyPreviewVideoPlugin};
+78 -1
View File
@@ -10,6 +10,7 @@ class ListPageButtonPlugin extends BasePlugin {
await this.createMenuBtn();
this.bindEvent();
await storageManager.getSetting("autoPage") === YES ? $("#sort-toggle-btn").hide() : this.sortItems().then();
this.initOneTimeSortBtn();
}
async createMenuBtn() {
const showWaitCheckBtn = await storageManager.getSetting("showWaitCheckBtn", YES), showWaitDownBtn = await storageManager.getSetting("showWaitDownBtn", YES);
@@ -38,7 +39,7 @@ class ListPageButtonPlugin extends BasePlugin {
const isFc2Page2 = currentHref.includes("advanced_search");
isFc2Page2 ? $el = $("h2.section-title") : otherCss = "flex-grow:1;";
const jhs_sortMethod = localStorage.getItem("jhs_sortMethod"), sortText = "当前排序方式: " + ("rateCount" === jhs_sortMethod ? "评价人数" : "date" === jhs_sortMethod ? "时间" : "默认");
$el.append(`\n <div style="display: flex;align-items: center; ${otherCss} ">\n <a id="waitCheckBtn" class="menu-btn main-tab-btn" data-tip="打开未鉴定的列表项, 并自动播放视频" style="background-color:#56c938 !important; ${showWaitCheckBtn === NO ? "display: none" : ""}"><span>打开待鉴定</span></a>\n <a id="waitDownBtn" class="menu-btn main-tab-btn" style="background-color:#2caac0 !important; ${showWaitDownBtn === NO ? "display: none" : ""}"><span>打开已收藏</span></a>\n ${isStarPage ? `\n <a id="addBlacklistBtn" class="menu-btn main-tab-btn" style="background-color:${addBlacklistBtnColor} !important;" data-tip="将演员加入黑名单, 后续有作品更新也会纳入屏蔽中"><span>${addBlacklistBtnText}</span></a>\n <a id="filterAllVideo" class="menu-btn main-tab-btn" style="background-color:#e8ab39 !important;margin-right: 30px!important;" data-tip="一键屏蔽已选分类的视频列表至鉴定记录中"><span>一键屏蔽所有作品</span></a>\n ` : ""}\n ${currentHref.includes("/tags") ? `\n <a id="addBlacklistBtn" class="menu-btn main-tab-btn" style="background-color:${addBlacklistBtnColor} !important;" data-tip="将演员加入黑名单, 后续有作品更新也会纳入屏蔽中"><span>${addBlacklistBtnText}</span></a>\n ` : ""}\n </div>\n <div style="display: flex;align-items: center;">\n <a id="newVideoBtn" class="menu-btn main-tab-btn" style="background-color:#2c6cc0 !important;"><span>新作品检测 (<span id="newVideoCount">0</span>)</span></a>\n <a id="blacklistBtn" class="menu-btn main-tab-btn" style="background-color:#34393f !important;"><span>演员黑名单</span></a>\n ${isSearchPage || isFc2Page2 ? "" : `<a id="sort-toggle-btn" class="menu-btn main-tab-btn" style="background-color:#8783ab !important;"> ${sortText} </a>`}\n </div>\n `);
$el.append(`\n <div style="display: flex;align-items: center; ${otherCss} ">\n <a id="waitCheckBtn" class="menu-btn main-tab-btn" data-tip="打开未鉴定的列表项, 并自动播放视频" style="background-color:#56c938 !important; ${showWaitCheckBtn === NO ? "display: none" : ""}"><span>打开待鉴定</span></a>\n <a id="waitDownBtn" class="menu-btn main-tab-btn" style="background-color:#2caac0 !important; ${showWaitDownBtn === NO ? "display: none" : ""}"><span>打开已收藏</span></a>\n ${isStarPage ? `\n <a id="addBlacklistBtn" class="menu-btn main-tab-btn" style="background-color:${addBlacklistBtnColor} !important;" data-tip="将演员加入黑名单, 后续有作品更新也会纳入屏蔽中"><span>${addBlacklistBtnText}</span></a>\n <a id="filterAllVideo" class="menu-btn main-tab-btn" style="background-color:#e8ab39 !important;margin-right: 30px!important;" data-tip="一键屏蔽已选分类的视频列表至鉴定记录中"><span>一键屏蔽所有作品</span></a>\n ` : ""}\n ${currentHref.includes("/tags") ? `\n <a id="addBlacklistBtn" class="menu-btn main-tab-btn" style="background-color:${addBlacklistBtnColor} !important;" data-tip="将演员加入黑名单, 后续有作品更新也会纳入屏蔽中"><span>${addBlacklistBtnText}</span></a>\n ` : ""}\n </div>\n <div style="display: flex;align-items: center;">\n <a id="one-time-sort-btn" class="menu-btn main-tab-btn" style="background-color:#8783ab !important;">一次性排序: 默认</a>\n <a id="newVideoBtn" class="menu-btn main-tab-btn" style="background-color:#2c6cc0 !important;"><span>新作品检测 (<span id="newVideoCount">0</span>)</span></a>\n <a id="blacklistBtn" class="menu-btn main-tab-btn" style="background-color:#34393f !important;"><span>演员黑名单</span></a>\n ${isSearchPage || isFc2Page2 ? "" : `<a id="sort-toggle-btn" class="menu-btn main-tab-btn" style="background-color:#8783ab !important;"> ${sortText} </a>`}\n </div>\n `);
}
if (isJavBus) {
const isStarPage = currentHref.includes("/star/");
@@ -90,6 +91,22 @@ class ListPageButtonPlugin extends BasePlugin {
localStorage.setItem("jhs_sortMethod", newMethod);
this.sortItems().then();
}));
$("#one-time-sort-btn").on("click", (event => {
const currentMethod = localStorage.getItem("jhs_oneTimeSortMethod") || "default";
const sortCycle = ["default", "rateCount", "totalScore", "date"];
let nextIndex = sortCycle.indexOf(currentMethod) + 1;
if (nextIndex >= sortCycle.length || nextIndex < 0) nextIndex = 0;
const newMethod = sortCycle[nextIndex];
const methodText = {
default: "默认",
rateCount: "评价人数",
totalScore: "总分",
date: "时间"
}[newMethod];
$("#one-time-sort-btn").text(`一次性排序: ${methodText}`);
localStorage.setItem("jhs_oneTimeSortMethod", newMethod);
this.oneTimeSortItems().then();
}));
const blacklistPlugin = this.getBean("BlacklistPlugin");
$("#addBlacklistBtn").on("click", (async event => {
await blacklistPlugin.addBlacklist(event);
@@ -174,6 +191,66 @@ class ListPageButtonPlugin extends BasePlugin {
$container.empty().append(items);
}
}
async initOneTimeSortBtn() {
const autoPage = await storageManager.getSetting("autoPage");
if (autoPage !== YES) {
$("#one-time-sort-btn").hide();
return;
}
const oneTimeMethod = localStorage.getItem("jhs_oneTimeSortMethod") || "default";
const methodText = {
default: "默认",
rateCount: "评价人数",
totalScore: "总分",
date: "时间"
}[oneTimeMethod];
$("#one-time-sort-btn").text(`一次性排序: ${methodText}`).show();
}
async oneTimeSortItems() {
if (currentHref.includes("handle") || currentHref.includes("advanced_search")) return;
if (isSearchPage) return;
const method = localStorage.getItem("jhs_oneTimeSortMethod");
if (!method) return;
$(".movie-list .item").each((function (index) {
$(this).attr("data-original-index") || $(this).attr("data-original-index", index);
}));
const $container = $(".movie-list"), $items = $(".item", $container);
if ("default" === method) {
$items.sort((function (a, b) {
return $(a).data("original-index") - $(b).data("original-index");
})).appendTo($container);
} else {
const items = $items.get();
items.sort((function (a, b) {
if ("rateCount" === method) {
const getScore = el => {
const match = $(el).find(".score .value").text().match(/由(\d+)人/);
return match ? parseFloat(match[1]) : 0;
};
return getScore(b) - getScore(a);
} else if ("totalScore" === method) {
const getTotalScore = el => {
const text = $(el).find(".score .value").text();
const scoreMatch = text.match(/([\d.]+)分/);
const countMatch = text.match(/由(\d+)人/);
const score = scoreMatch ? parseFloat(scoreMatch[1]) : 0;
const count = countMatch ? parseFloat(countMatch[1]) : 0;
return score * count;
};
return getTotalScore(b) - getTotalScore(a);
} else {
const getDate = el => {
const dateStr = $(el).find(".meta").text().trim();
return new Date(dateStr);
};
return getDate(b) - getDate(a);
}
}));
$container.empty().append(items);
}
}
async openWaitCheck(event) {
const maxCount = await storageManager.getSetting("waitCheckCount", 5);
let count = 0;
+4 -2
View File
@@ -56,9 +56,9 @@ class ListPagePlugin extends BasePlugin {
this.replaceHdImg();
this.addJumpPageControl();
this.fixBusTitleBox();
this.getBean("CoverButtonPlugin").addSvgBtn().then();
await this.doFilter();
await this.getBean("ListPageButtonPlugin").sortItems();
this.getBean("CoverButtonPlugin").addSvgBtn().then();
$(this.getSelector().itemSelector + " a").attr("target", "_blank");
this.getBean("AutoPagePlugin").checkLoad();
} finally {
@@ -402,11 +402,13 @@ class ListPagePlugin extends BasePlugin {
}
if (this.cache[carNum2]) {
let _this = this;
$title.attr("data-original-title", content);
$title.contents().each((function() {
3 === this.nodeType && "" !== this.textContent.trim() && (this.textContent = " " + _this.cache[carNum2] + " ");
}));
$title.attr("title", _this.cache[carNum2]);
} else translateText(content).then((result => {
$title.attr("data-original-title", content);
if (isJavDb) {
$title.contents().each((function() {
3 !== this.nodeType || "" === this.textContent.trim() || this.textContent.includes(carNum2) || (this.textContent = " " + result + " ");
@@ -424,7 +426,7 @@ class ListPagePlugin extends BasePlugin {
async revertTranslation() {
$(this.getSelector().itemSelector).toArray().forEach((ele => {
let $box2 = $(ele);
const originalContent = $box2.find(".box").attr("title") || $box2.find(".video-title").attr("title") || $box2.find("img").attr("data-title");
const originalContent = $box2.find(".video-title").attr("data-original-title") || $box2.find(".box").attr("title") || $box2.find(".video-title").attr("title") || $box2.find("img").attr("data-title");
let carNum2;
isJavDb && (carNum2 = $box2.find(".video-title strong").text().trim());
const $title = $box2.find(".video-title");
+41 -5
View File
@@ -1,5 +1,6 @@
import { BasePlugin } from '../core/base-plugin.js';
import { __publicField, currentHref, NO, YES, qualityOptions } from '../core/constants.js';
import {getDmmVideo, selectDefaultQuality} from '../api/dmm.js';
class PreviewVideoPlugin extends BasePlugin {
getName() {
@@ -49,7 +50,7 @@ class PreviewVideoPlugin extends BasePlugin {
} else {
clog.debug("JavDB没有视频播放元素, 开始创建...");
const videoCoverSrc = $(".column-video-cover img").attr("src");
$(".preview-images").prepend(`\n <a class="preview-video-container" data-fancybox="gallery" href="#preview-video">\n <span>預告片</span>\n <img src="${videoCoverSrc}" class="video-cover" style="width: 150px; height: auto;" alt="">\n </a>\n `);
$(".preview-images").prepend(`\n <a class="preview-video-container" data-fancybox="gallery" href="#preview-video">\n <span>预告片</span>\n <img src="${videoCoverSrc}" class="video-cover" style="width: 150px; height: auto;" alt="">\n </a>\n `);
$(".preview-video-container").on("click", (event => {
utils.loopDetector((() => $(".fancybox-content #preview-video").length > 0), (async () => {
await this.handleVideo();
@@ -60,19 +61,52 @@ class PreviewVideoPlugin extends BasePlugin {
clog.error("预加载dmm失败:", error);
}
}
async handleVideo() {
if (await storageManager.getSetting("enableLoadPreviewVideo", YES) === NO) return;
const $videoEl = $("#preview-video");
if (!$videoEl.length) return;
const $videoContainer = $videoEl.parent();
$videoContainer.css("position", "relative");
const videoEl = $videoEl[0], jhs_videoMuted = localStorage.getItem("jhs_videoMuted");
jhs_videoMuted && (videoEl.muted = "yes" === jhs_videoMuted);
const videoEl = $videoEl[0];
let carNum2 = this.getPageInfo().carNum;
const jhs_videoMuted = localStorage.getItem("jhs_videoMuted");
if (jhs_videoMuted) videoEl.muted = "yes" === jhs_videoMuted;
videoEl.addEventListener("volumechange", (function() {
localStorage.setItem("jhs_videoMuted", videoEl.muted ? "yes" : "no");
}));
const CACHE_KEY = "jhs_dmm_video";
let dmmErrorRetryCount = 0;
const attachVideoErrorHandler = () => {
videoEl.addEventListener("error", async function onVideoError(e) {
if (dmmErrorRetryCount >= 2) return;
dmmErrorRetryCount++;
clog.warn(`预览视频加载失败(第${dmmErrorRetryCount}次),清除缓存后重试...`);
const cachedData = localStorage.getItem(CACHE_KEY) ? JSON.parse(localStorage.getItem(CACHE_KEY)) : {};
delete cachedData[carNum2];
localStorage.setItem(CACHE_KEY, JSON.stringify(cachedData));
const freshMap = await getDmmVideo(carNum2);
if (!freshMap) {
show.error("预览视频获取失败,请稍后重试");
return;
}
const storedQuality = await storageManager.getSetting("videoQuality");
const quality = selectDefaultQuality(Object.keys(freshMap), storedQuality);
const freshUrl = freshMap[quality];
const savedTime = videoEl.currentTime;
$videoEl.attr("src", freshUrl);
videoEl.load();
if (savedTime > 0 && Number.isFinite(savedTime)) {
videoEl.currentTime = savedTime;
}
videoEl.play();
}, {once: false});
};
attachVideoErrorHandler();
videoEl.play();
let carNum2 = this.getPageInfo().carNum;
const dmmVideoMap = await getDmmVideo(carNum2);
let $bottomToolbar = $("<div></div>").attr("id", "video-bottom-toolbar").css({
display: "flex",
@@ -122,7 +156,9 @@ class PreviewVideoPlugin extends BasePlugin {
const currentTime = videoEl.currentTime;
$videoEl.attr("src", videoSrc);
videoEl.load();
videoEl.currentTime = currentTime;
if (currentTime > 0 && Number.isFinite(currentTime)) {
videoEl.currentTime = currentTime;
}
await videoEl.play();
$bottomToolbar.find(".video-control-btn").removeClass("active").css({
"background-color": "#fff",
+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;
+454 -115
View File
@@ -1,153 +1,492 @@
import { BasePlugin } from '../core/base-plugin.js';
import { __publicField, isJavBus, isJavDb } from '../core/constants.js';
import { searchFiles } from './wangpan-115-task.js';
const Thumbnail = {
sources: ['javfree', 'projectjav', 'javstore'],
cacheKey(code) {
return `thumb_cache_v3_${code}`;
},
lookupCode(code) {
const text = String(code || '').trim();
const fc2 = text.match(/^(?:FC2[-_\s]?(?:PPV[-_\s]?)?)?(\d{6,9})$/i);
return fc2 ? fc2[1] : text;
},
sourceOrder() {
const savedOrder = ['javfree', 'projectjav', 'javstore'];
const seen = new Set();
return [...savedOrder, ...this.sources].filter(src => {
if (seen.has(src) || typeof this[src] !== 'function') return false;
seen.add(src);
return true;
});
},
async fetchFromSource(source, code) {
try {
return await this[source](this.lookupCode(code));
} catch (e) {
clog.debug(`Thumbnail[${source}] 异常:`, e.message);
return null;
}
},
normalizeForCompare(text) {
return String(text || '').toLowerCase().replace(/[^a-z0-9]/g, '');
},
isCodeMatched(text, code) {
const normalizedText = this.normalizeForCompare(text);
const normalizedCode = this.normalizeForCompare(code);
return !!normalizedCode && normalizedText.includes(normalizedCode);
},
isDetailMatched(doc, url, code) {
const title = doc?.querySelector('title')?.textContent || '';
const headings = [...(doc?.querySelectorAll('h1,h2,h3,.entry-title,.movie-title,.post-title') || [])]
.map(el => el.textContent || '').join(' ');
const bodyText = (doc?.body?.textContent || '').slice(0, 5000);
return this.isCodeMatched([url, title, headings, bodyText].join(' '), code);
},
normalizePreviewUrl(url, baseUrl = '') {
if (!url) return '';
const absolute = /^https?:\/\//i.test(url)
? url : (baseUrl ? new URL(url, baseUrl).href : url);
return absolute.replace(/^http:/, 'https:');
},
isJavfreePreviewImage(url, code) {
const cleanUrl = String(url || '').split('?')[0];
const lookupCode = this.lookupCode(code);
const isFc2Numeric = /^\d{6,9}$/.test(lookupCode);
const fc2ShotPattern = isFc2Numeric
? new RegExp(`${lookupCode}_\\d+\\.(?:jpe?g|png|webp)$`, 'i')
: null;
return this.isCodeMatched(cleanUrl, code) && (
/-(?:1080p|demosaic)\.(?:jpe?g|png|webp)$/i.test(cleanUrl) ||
(isFc2Numeric && fc2ShotPattern.test(cleanUrl))
);
},
selectJavfreePreviewUrl(doc, detailUrl, code) {
const urls = [...doc.querySelectorAll('p > img[src]')]
.map(img => this.normalizePreviewUrl(img.getAttribute('src') || img.src || '', detailUrl))
.filter(url => this.isJavfreePreviewImage(url, code));
return urls.find(url => /-1080p\./i.test(url)) ||
urls.find(url => /-demosaic\./i.test(url)) ||
urls.find(url => /_1\.(?:jpe?g|png|webp)$/i.test(url)) || '';
},
async javfree(code) {
code = this.lookupCode(code);
const cacheKey = this.cacheKey(code);
const cached = sessionStorage.getItem(cacheKey);
if (cached) return cached;
try {
const html = await gmHttp.get(`https://javfree.me/search/${code}`);
const doc = new DOMParser().parseFromString(html, 'text/html');
const link = [...doc.querySelectorAll('.entry-title>a')]
.find(a => this.isCodeMatched([a.href, a.textContent].join(' '), code))?.href;
if (!link) return null;
const dHtml = await gmHttp.get(link);
const dDoc = new DOMParser().parseFromString(dHtml, 'text/html');
if (!this.isDetailMatched(dDoc, link, code)) return null;
const url = this.selectJavfreePreviewUrl(dDoc, link, code);
if (url) {
sessionStorage.setItem(cacheKey, url);
return url;
}
return null;
} catch {
return null;
}
},
async javstore(code) {
code = this.lookupCode(code);
try {
const normalizedCode = code.replace(/^fc2-?/i, '').replace(/-/g, '').toLowerCase();
const searchUrl = `https://javstore.net/search?q=${encodeURIComponent(code)}`;
const searchHtml = await gmHttp.get(searchUrl);
const searchDoc = new DOMParser().parseFromString(searchHtml, 'text/html');
const candidateLinks = searchDoc.querySelectorAll('a[href*="/"]');
const detailUrls = [];
for (const link of candidateLinks) {
const href = link.getAttribute('href');
if (!href) continue;
if (href.startsWith('http') && !href.includes('javstore.net')) continue;
const urlObj = new URL(href, searchUrl);
if (!/javstore\.net$/i.test(urlObj.hostname)) continue;
if (/^\/search(?:[/?#]|$)/i.test(urlObj.pathname)) continue;
const fullUrl = urlObj.href;
const pathLastPart = decodeURIComponent(urlObj.pathname.split('/').pop() || '');
const normalizedPath = pathLastPart.toLowerCase().replace(/-/g, '');
const looksLikeDetail = /\.html$/i.test(urlObj.pathname) || /^\/\d+[-/]/.test(urlObj.pathname);
if (looksLikeDetail && normalizedPath.includes(normalizedCode) && !detailUrls.includes(fullUrl)) {
detailUrls.push(fullUrl);
}
}
if (detailUrls.length === 0) return null;
for (const detailUrl of detailUrls) {
const imgUrl = await this._extractImgFromDetail(detailUrl, code);
if (imgUrl) return imgUrl;
}
return null;
} catch (e) {
clog.debug('javstore 获取失败', e);
return null;
}
},
async _extractImgFromDetail(detailUrl, code) {
try {
const detailHtml = await gmHttp.get(detailUrl);
const detailDoc = new DOMParser().parseFromString(detailHtml, 'text/html');
if (!this.isDetailMatched(detailDoc, detailUrl, code)) return null;
for (const link of detailDoc.querySelectorAll('a')) {
if (link.textContent.includes('CLICK HERE')) {
const imgUrl = link.href || link.getAttribute('href') || '';
if (imgUrl) return this.normalizePreviewUrl(imgUrl, detailUrl);
}
}
const img = detailDoc.querySelector('img[src*="_s.jpg"]');
if (img) {
let src = img.getAttribute('src') || '';
if (!src.startsWith('http')) src = new URL(src, detailUrl).href;
return this.normalizePreviewUrl(src.replace(/_s\.jpg$/, '_l.jpg'), detailUrl);
}
return null;
} catch (e) {
clog.debug('javstore: 详情页请求失败', detailUrl, e.message);
return null;
}
},
async projectjav(code) {
code = this.lookupCode(code);
try {
const request = (url) => gmHttp.get(url, {}, {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'
});
const searchUrl = `https://projectjav.com/?searchTerm=${encodeURIComponent(code)}`;
const searchHtml = await request(searchUrl);
const searchDoc = new DOMParser().parseFromString(searchHtml, 'text/html');
let detailUrl = '';
const allMovieLinks = [...searchDoc.querySelectorAll('a[href*="/movie/"]')];
const firstLink = allMovieLinks[0]?.getAttribute('href') || '';
if (firstLink) {
detailUrl = firstLink.startsWith('http') ? firstLink : `https://projectjav.com${firstLink}`;
}
if (!detailUrl) return null;
const detailHtml = await request(detailUrl);
const detailDoc = new DOMParser().parseFromString(detailHtml, 'text/html');
const screenshotLink = [...detailDoc.querySelectorAll('.col-md-12.thumbnail a[data-featherlight="image"], .thumbnail a[data-featherlight="image"]')]
.find(a => this.isCodeMatched([a.outerHTML, a.closest('.thumbnail')?.outerHTML, detailUrl].join(' '), code));
if (screenshotLink) {
const thumbImg = screenshotLink.querySelector('img');
const href = screenshotLink.getAttribute('href') || '';
if (href) return this.normalizePreviewUrl(href, detailUrl);
if (thumbImg) {
const src = (thumbImg.getAttribute('src') || '').replace(/\?.*$/, '');
if (src) return this.normalizePreviewUrl(src, detailUrl);
}
}
return null;
} catch (e) {
clog.debug('[projectjav] 异常:', e.message);
return null;
}
},
async get(code) {
const cacheKey = this.cacheKey(code);
const cached = sessionStorage.getItem(cacheKey);
if (cached) return {url: cached, source: null};
let url = null, source = null;
for (const src of this.sourceOrder()) {
url = await this.fetchFromSource(src, code);
if (url) {
source = src;
break;
}
}
if (url) sessionStorage.setItem(cacheKey, url);
return {url, source};
}
};
let previewOverlay = null;
function showPreviewOverlay(imgUrl, code, source = null) {
if (previewOverlay) {
previewOverlay.close();
}
let currentBlobUrl = null;
const originalHtmlOverflow = document.documentElement.style.overflow;
const originalBodyOverflow = document.body.style.overflow;
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
const container = document.createElement('div');
container.className = 'jhs-preview-overlay';
const img = document.createElement('img');
img.src = imgUrl;
img.className = 'jhs-preview-img';
img.onclick = (e) => {
e.stopPropagation();
img.classList.toggle('zoomed');
};
img.onerror = () => {
img.src = imgUrl;
};
const toolbar = document.createElement('div');
toolbar.className = 'jhs-preview-toolbar';
const createButton = (text, className, onClick) => {
const btn = document.createElement('button');
btn.className = `jhs-preview-btn ${className}`;
btn.textContent = text;
btn.onclick = onClick;
return btn;
};
const setActiveSource = (activeSource) => {
toolbar.querySelectorAll('.jhs-preview-btn').forEach(b => b.classList.remove('active'));
const activeBtn = toolbar.querySelector(`.jhs-preview-btn-${activeSource}`);
if (activeBtn) activeBtn.classList.add('active');
};
const changeSource = async (src, srcName) => {
const loadingOverlay = document.createElement('div');
loadingOverlay.className = 'jhs-loading-overlay';
loadingOverlay.innerHTML = '<div class="jhs-loading-spinner"><div class="spinner"></div><div>正在加载预览图...</div></div>';
document.body.appendChild(loadingOverlay);
try {
const newUrl = src === 'javfree' ? await Thumbnail.javfree(code)
: src === 'projectjav' ? await Thumbnail.projectjav(code)
: await Thumbnail.javstore(code);
if (newUrl) {
if (currentBlobUrl) {
URL.revokeObjectURL(currentBlobUrl);
currentBlobUrl = null;
}
img.src = newUrl;
setActiveSource(src);
} else {
showToast(`${srcName} 未找到预览图`, 'error');
}
} catch (err) {
showToast(`${srcName} 加载失败: ${err.message}`, 'error');
} finally {
loadingOverlay.remove();
}
};
let toastTimer = null;
const showToast = (msg, type = 'info') => {
const existing = container.querySelector('.jhs-preview-toast');
if (existing) existing.remove();
if (toastTimer) clearTimeout(toastTimer);
const toast = document.createElement('div');
toast.className = `jhs-preview-toast jhs-preview-toast-${type}`;
toast.textContent = msg;
container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('show'));
toastTimer = setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
};
const javfreeBtn = createButton('javfree', 'jhs-preview-btn-javfree javfree', (e) => {
e.stopPropagation();
changeSource('javfree', 'javfree');
});
const projectjavBtn = createButton('projectjav', 'jhs-preview-btn-projectjav projectjav', (e) => {
e.stopPropagation();
changeSource('projectjav', 'projectjav');
});
const javstoreBtn = createButton('javstore', 'jhs-preview-btn-javstore javstore', (e) => {
e.stopPropagation();
changeSource('javstore', 'javstore');
});
const newWindowBtn = createButton('新窗口打开', 'jhs-preview-btn-action action', (e) => {
e.stopPropagation();
window.open(img.src);
});
const downloadBtn = createButton('下载', 'jhs-preview-btn-action action', (e) => {
e.stopPropagation();
const a = document.createElement('a');
a.href = img.src;
a.download = `${code}.jpg`;
a.click();
});
toolbar.appendChild(javfreeBtn);
toolbar.appendChild(projectjavBtn);
toolbar.appendChild(javstoreBtn);
toolbar.appendChild(newWindowBtn);
toolbar.appendChild(downloadBtn);
if (source === 'javfree') setActiveSource('javfree');
else if (source === 'projectjav') setActiveSource('projectjav');
else if (source === 'javstore') setActiveSource('javstore');
else setActiveSource('javfree');
container.appendChild(img);
const closeOverlay = () => {
if (container.parentNode) {
container.remove();
toolbar.remove();
document.documentElement.style.overflow = originalHtmlOverflow;
document.body.style.overflow = originalBodyOverflow;
if (currentBlobUrl) {
URL.revokeObjectURL(currentBlobUrl);
currentBlobUrl = null;
}
}
};
container.onclick = closeOverlay;
const escHandler = (e) => {
if (e.key === 'Escape') {
closeOverlay();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
document.body.appendChild(container);
document.body.appendChild(toolbar);
previewOverlay = {container, toolbar, close: closeOverlay};
}
class ScreenShotPlugin extends BasePlugin {
getName() {
return "ScreenShotPlugin";
}
async handle() {
this.loadScreenShot().then();
}
async loadScreenShot() {
if (!window.isDetailPage) return;
if ("yes" !== await storageManager.getSetting("enableLoadScreenShot", "yes")) return;
let carNum2 = this.getPageInfo().carNum;
isJavDb && $(".preview-images .tile-item").first().before(' <a class="tile-item screen-container" style="overflow:hidden;max-height: 215px;text-align:center;"><div style="margin-top: 50px;color: #000;cursor: auto">正在加载缩略图</div></a> ');
isJavBus && $("#sample-waterfall .sample-box:first").after(' <a class="sample-box screen-container" style="overflow:hidden; height: 110px; text-align:center;"><div style="margin-top: 30px;color: #000;cursor: auto">正在加载缩略图</div></a> ');
isJavDb && $(".preview-images .tile-item").first().before(
'<a class="tile-item screen-container" style="overflow:hidden;max-height:215px;text-align:center;">' +
'<div style="margin-top:50px;color:#000;cursor:auto">正在加载缩略图</div></a>'
);
isJavBus && $("#sample-waterfall .sample-box:first").after(
'<a class="sample-box screen-container" style="overflow:hidden;height:110px;text-align:center;">' +
'<div style="margin-top:30px;color:#000;cursor:auto">正在加载缩略图</div></a>'
);
try {
const imgUrl = await this.getScreenshot(carNum2);
this.addImg("缩略图", imgUrl);
clog.log("加载缩略图:", imgUrl);
const result = await Thumbnail.get(carNum2);
if (result.url) {
this.addImg("缩略图", result.url, carNum2, result.source);
clog.log("加载缩略图:", result.url, result.source);
} else {
this.showErrorFallback(carNum2, new Error("所有来源均未找到预览图"));
}
} catch (e) {
this.showErrorFallback(carNum2, e);
}
}
async getScreenshot(carNum2) {
const cacheData = localStorage.getItem("jhs_screenShot") ? JSON.parse(localStorage.getItem("jhs_screenShot")) : {};
if (cacheData[carNum2]) {
clog.debug("缓存中存在缩略图:", carNum2, cacheData[carNum2]);
return cacheData[carNum2];
}
let imgUrl;
try {
imgUrl = await Promise.any([ this.getJavStoreScreenShot(carNum2) ]);
} catch (e) {
clog.error("获取缩略图资源失败:", imgUrl, e);
throw e;
}
if (!imgUrl) {
this.showErrorFallback(carNum2, null);
return null;
}
const httpsIndex = imgUrl.indexOf("https://");
-1 !== httpsIndex && (imgUrl = imgUrl.substring(httpsIndex));
cacheData[carNum2] = imgUrl;
clog.log("缩略图获取成功:", imgUrl);
localStorage.setItem("jhs_screenShot", JSON.stringify(cacheData));
return imgUrl;
const result = await Thumbnail.get(carNum2);
if (result.url) return result.url;
throw new Error("未找到预览图");
}
async getJavStoreScreenShot(carNum2) {
let url = `https://javstore.net/search/${carNum2}.html`;
clog.log("正在解析缩略图:", url);
let html = await gmHttp.get(url);
const $dom = utils.htmlTo$dom(html);
let detailPageUrl = null;
$dom.find("#content_news h3 span a").each((function() {
if ($(this).attr("title").toLowerCase().includes(carNum2.toLowerCase())) {
detailPageUrl = $(this).attr("href");
return !1;
}
}));
if (!detailPageUrl) {
clog.error("JavStore, 查询番号失败:", url);
return null;
}
let detailPageHtml = await gmHttp.get(detailPageUrl);
const $detailPageDom = utils.htmlTo$dom(detailPageHtml);
let imgUrl = $detailPageDom.find("a:contains('CLICK HERE')").attr("href") || $detailPageDom.find("img[src*='_s.jpg']").attr("src");
if (!imgUrl) {
clog.error("JavStore, 解析预览图失败:", url);
return null;
}
return imgUrl.replace(".th", "");
}
async getJavBestScreenShot(carNum2) {
let url = `https://javbest.net/?s=${carNum2}`;
clog.log("正在解析缩略图:", url);
let html = await gmHttp.get(url);
const $dom = utils.htmlTo$dom(html), href = $dom.find(".app_loop_thumb a").first().attr("href");
if (!href) {
clog.error("解析JavBest搜索页失败:", url);
throw new Error("解析JavBest搜索页失败");
}
const title = $dom.find(".app_loop_thumb a").first().attr("title");
if (!title.toLowerCase().includes(carNum2.toLowerCase())) {
clog.error("解析JavBest搜索页失败:", title);
throw new Error("解析JavBest搜索页失败");
}
const detailPageHtml = await gmHttp.get(href);
let imgUrl = $(detailPageHtml).find('#content a img[src*="_t.jpg"]').attr("src");
if (!imgUrl) {
clog.error("解析JavBest缩略图失败:", url);
throw new Error("解析JavBest缩略图失败");
}
imgUrl = imgUrl.replace("_t", "").replace("http:", "https:");
return imgUrl;
return await Thumbnail.javstore(carNum2);
}
async getJavFreeScreenShot(carNum2) {
let url = `https://javfree.me/search/${carNum2}/`, html = await gmHttp.get(url);
const $itemList = utils.htmlTo$dom(html).find("article h2.entry-title a");
if (!$itemList || 0 === $itemList.length) {
clog.error("解析JavFree搜索页失败:", url);
throw new Error("解析JavFree搜索页失败");
}
let detailPageUrl = $($itemList[0]).attr("href"), detailPageHtml = await gmHttp.get(detailPageUrl);
const $imgList = utils.htmlTo$dom(detailPageHtml).find("#main > article > .entry-content > p img");
if (!$imgList || 0 === $imgList.length) {
clog.error("解析JavFree详情页失败:", detailPageUrl);
throw new Error("解析JavFree详情页失败");
}
const srcList = $imgList.filter((function() {
const src = $(this).attr("src");
return src && src.toLowerCase().endsWith(".jpeg");
})).map((function() {
return $(this).attr("src");
})).get();
console.log(srcList);
return srcList.at(-1);
return await Thumbnail.javfree(carNum2);
}
addImg(title, imgUrl) {
if (imgUrl) {
isJavDb && $(".screen-container").html(`<img src="${imgUrl}" alt="${title}" loading="lazy" style="width: 100%;">`);
isJavBus && $(".screen-container").html(`<div class="photo-frame"><img src="${imgUrl}" style="height: inherit;width: 100%;" title="${title}" alt="${title}"></div>`);
$(".screen-container").on("click", (event => {
event.stopPropagation();
event.preventDefault();
showImageViewer(event.currentTarget);
}));
}
addImg(title, imgUrl, code, source) {
if (!imgUrl) return;
isJavDb && $(".screen-container").html(
`<img src="${imgUrl}" alt="${title}" loading="lazy" style="width:100%;">`
);
isJavBus && $(".screen-container").html(
`<div class="photo-frame"><img src="${imgUrl}" style="height:inherit;width:100%;" title="${title}" alt="${title}"></div>`
);
$(".screen-container").on("click", (event) => {
event.stopPropagation();
event.preventDefault();
showPreviewOverlay(imgUrl, code || title, source);
});
}
showErrorFallback(carNum2, error) {
var _a2;
console.error("获取缩略图失败:", null == (_a2 = null == error ? void 0 : error.message) ? void 0 : _a2.substring(0, 100));
let differentCss = isJavBus ? "margin-top: 30px" : "margin-top: 50px";
$(".screen-container").html(`<div style="${differentCss}; cursor:auto;color:#000;">获取缩略图失败</div><br/><a href='#' class='retry-link'>点击重试</a> 或 <a class="check-link" href='https://javstore.net/search/${carNum2}.html' target='_blank'>前往确认</a>`).off("click", ".retry-link").off("click", ".check-link").on("click", ".retry-link", (async e => {
console.error("获取缩略图失败:", error?.message?.substring?.(0, 100) || error);
let differentCss = isJavBus ? "margin-top:30px" : "margin-top:50px";
$(".screen-container").html(
`<div style="${differentCss};cursor:auto;color:#000;">获取缩略图失败</div>` +
`<br/><a href='#' class='retry-link'>点击重试</a> 或 ` +
`<a class="check-link" href='https://javstore.net/search?q=${encodeURIComponent(carNum2)}' target='_blank'>前往确认</a>`
).off("click", ".retry-link").off("click", ".check-link")
.on("click", ".retry-link", async (e) => {
e.stopPropagation();
e.preventDefault();
$(".screen-container").html(`<div style="${differentCss};cursor:auto;color:#000;">正在重新加载...</div>`);
$(".screen-container").html(
`<div style="${differentCss};cursor:auto;color:#000;">正在重新加载...</div>`
);
try {
const imgUrl = await this.getScreenshot(carNum2);
this.addImg("缩略图", imgUrl);
const result = await Thumbnail.get(carNum2);
if (result.url) {
this.addImg("缩略图", result.url, carNum2, result.source);
} else {
this.showErrorFallback(carNum2, new Error("所有来源均未找到预览图"));
}
} catch (err) {
this.showErrorFallback(carNum2, err);
}
})).on("click", ".check-link", (async e => {
}).on("click", ".check-link", (e) => {
e.stopPropagation();
e.preventDefault();
window.open(`https://javstore.net/search/${carNum2}.html`, "_blank");
}));
window.open(`https://javstore.net/search?q=${encodeURIComponent(carNum2)}`, "_blank");
});
}
}
export { ScreenShotPlugin };
export {ScreenShotPlugin, Thumbnail, showPreviewOverlay};
window.showPreviewOverlay = showPreviewOverlay;
+129 -4
View File
File diff suppressed because one or more lines are too long
+605
View File
@@ -0,0 +1,605 @@
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 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" : "";
const displayName = m.name.length > 26 ? m.name.slice(0, 26) + '...' : m.name;
return `<label class="pdb-sub-checkbox-label"><input type="checkbox" class="pdb-sub-checkbox" value="${m.cid}"${checked}><span title="${this.escapeHtml(m.name)}">${this.escapeHtml(displayName)}</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;
+43
View File
@@ -145,6 +145,49 @@ class WangPan115TaskPlugin extends BasePlugin {
}
return fileId;
}
//TODO: 暂时没有删除离线任务
// async deleteOfflineTasksByKeyword(keyword) {
// try {
// const singInfo = await (async () => {
// const res = await gmHttp.get("https://115.com/?ct=offline&ac=space&_=" + (new Date).getTime());
// return "object" == typeof res ? res : null;
// })();
// if (!singInfo) {
// console.error("删除离线任务: 未登录115网盘");
// return;
// }
// const {sign: sign, time: time} = singInfo, uid = singInfo.uid || (() => {
// const match = document.cookie.match(/UID=(\d+)_/);
// return match ? match[1] : null;
// })() || await this.getUserId();
// const taskListRes = await gmHttp.postForm("https://115.com/web/lixian/?ct=lixian&ac=task_lists", {
// page: 1,
// uid: uid,
// sign: sign,
// time: time
// });
// const tasks = (null == taskListRes ? void 0 : taskListRes.tasks) || [];
// const keyword2 = keyword.toLowerCase().replace("fc2-", "");
// let deletedCount = 0;
// for (const task of tasks) {
// if (task.name && task.name.toLowerCase().includes(keyword2)) {
// console.log(`删除离线任务: ${task.name} (hash: ${task.info_hash})`);
// await gmHttp.postForm("https://115.com/web/lixian/?ct=lixian&ac=task_del", {
// "hash[0]": task.info_hash,
// flag: 1,
// uid: uid,
// sign: sign,
// time: time
// });
// deletedCount++;
// }
// }
// deletedCount > 0 && console.log(`共删除 ${deletedCount} 个离线任务`);
// } catch (error) {
// console.error("删除离线任务失败:", error);
// }
// }
}
+281 -1
View File
@@ -1,6 +1,7 @@
import { BasePlugin } from '../core/base-plugin.js';
import { __publicField, isJavBus, isJavDb, currentHref, NO } from '../core/constants.js';
import { searchFiles } from './wangpan-115-task.js';
import {JavPackSubtitle} from './subtitles.js';
class WangPan115Plugin extends BasePlugin {
constructor() {
@@ -240,6 +241,19 @@ const _WangPan115MatchPlugin = class _WangPan115MatchPlugin extends BasePlugin {
event.stopPropagation();
await this.retryMatch(event.currentTarget);
}));
$(document).on("click", ".jhs-detail-delete-btn", (async event => {
event.preventDefault();
event.stopPropagation();
const $btn = $(event.currentTarget);
const matchData = $btn.attr("data-match");
if (!matchData) return;
const matchList = JSON.parse(matchData);
const carNum2 = this.getPageInfo().carNum;
this.showDeleteDialog(carNum2, matchList, (async newMatchList => {
$("#115-match-table").remove();
await this.matchDetailPage(newMatchList);
}));
}));
await this.matchDetailPage();
$(document).on("click", ".jhs-match-detail-error-btn", (async event => {
event.preventDefault();
@@ -266,6 +280,8 @@ const _WangPan115MatchPlugin = class _WangPan115MatchPlugin extends BasePlugin {
if (this.loginStatus === _WangPan115MatchPlugin.LoginStatus.LOGGED_OUT) $tbody.append(`<tr><td colspan="4">\n <a class='jhs-match-no-login-btn a-info'\n data-keyword="${carNum2}"\n title="未登录115网盘">未登录</a>\n </td></tr>`); else if (matchList.length > 0) {
const rowsHtml = matchList.map((match => `\n <tr>\n <td>${match.name}</td>\n <td>${this.formatSize(match.size)}</td>\n <td>${match.createTime}</td>\n <td>\n <a href="https://115vod.com/?pickcode=${match.videoId}&share_id=0"\n target="_blank"\n class="a-success"\n title="播放">播放</a>\n </td>\n </tr>\n `)).join("");
$tbody.append(rowsHtml);
$detail.css("position", "relative");
$detail.append(`<span class="jhs-detail-delete-btn" \n data-match='${JSON.stringify(matchList)}'\n style="position: absolute; top: 8px; right: 12px; cursor: pointer; color: #e74c3c; font-size: 16px; font-weight: bold; z-index: 1;" \n title="删除115作品">🗑️ 删除</span>`);
} else $tbody.append(`<tr><td colspan="4">\n <a class='jhs-match-detail-error-btn a-info'\n data-keyword="${carNum2}"\n title="未匹配,点击重试">未匹配</a>\n </td></tr>`);
} catch (error) {
$tbody.append(`<tr><td colspan="4">\n <a class="a-danger jhs-match-detail-error-btn" title="${error.message || "加载失败"}">加载失败,请重试</a>\n </td></tr>`);
@@ -341,7 +357,260 @@ const _WangPan115MatchPlugin = class _WangPan115MatchPlugin extends BasePlugin {
}
}
updateMatchStatus($box2, carNum2, matchList) {
matchList.length > 0 ? $box2.find(".jhs-match-btn").replaceWith(`<a class='jhs-match-btn a-success' \n data-keyword="${carNum2}"\n data-match='${JSON.stringify(matchList)}'\n title="点击查看匹配详情">匹配${matchList.length}个</a>`) : $box2.find(".jhs-match-btn").replaceWith(`<a class='jhs-match-error-btn a-info' data-keyword="${carNum2}" \n title="点击重新尝试匹配">未匹配</a>`);
if (matchList.length > 0) {
$box2.find(".jhs-match-btn").replaceWith(`<a class='jhs-match-btn a-success' \n data-keyword="${carNum2}"\n data-match='${JSON.stringify(matchList)}'\n title="点击查看匹配详情">匹配${matchList.length}个</a>`);
const $deleteBtn = $box2.find(".delete115Svg");
const $markBtn = $box2.find(".markDeleteSvg");
if ($deleteBtn.length > 0 && matchList.some(m => m.dirId != null)) {
$deleteBtn.attr("data-match", JSON.stringify(matchList));
$deleteBtn.show();
}
if ($markBtn.length > 0) {
$markBtn.show();
this.updateMarkBtnStyle($markBtn, carNum2);
}
} else {
$box2.find(".jhs-match-btn").replaceWith(`<a class='jhs-match-error-btn a-info' data-keyword="${carNum2}" \n title="点击重新尝试匹配">未匹配</a>`);
const $deleteBtn = $box2.find(".delete115Svg");
const $markBtn = $box2.find(".markDeleteSvg");
if ($deleteBtn.length > 0) {
$deleteBtn.removeAttr("data-match");
$deleteBtn.hide();
}
if ($markBtn.length > 0) {
$markBtn.hide();
}
}
}
async updateMarkBtnStyle($markBtn, carNum2) {
const markedList = await storageManager.forage.getItem("markedDeleteList") || [];
const isMarked = markedList.some(item => item.carNum === carNum2);
isMarked ? $markBtn.addClass("marked") : $markBtn.removeClass("marked");
$markBtn.attr("title", isMarked ? "取消标记删除" : "标记删除");
}
async batchDeleteMarked(selectedItems, onProgress) {
let deletedCount = 0;
let failedCount = 0;
const randomDelay = () => new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 2000));
for (let i = 0; i < selectedItems.length; i++) {
const item = selectedItems[i];
try {
if (i > 0) {
await randomDelay();
}
const matchList = await this.searchFiles(item.carNum);
if (matchList.length === 0) {
failedCount++;
onProgress && onProgress(i + 1, selectedItems.length, item, "未匹配到115文件");
continue;
}
const upperCarNum = item.carNum.toUpperCase();
const safeMatchList = matchList.filter(m => m.name.toUpperCase().includes(upperCarNum));
if (safeMatchList.length === 0) {
failedCount++;
onProgress && onProgress(i + 1, selectedItems.length, item, "无安全可删除目录");
continue;
}
let itemDeleted = 0;
const groupedByDir = {};
for (const m of safeMatchList) {
if (!groupedByDir[m.dirId]) groupedByDir[m.dirId] = [];
groupedByDir[m.dirId].push(m);
}
const entries = Object.entries(groupedByDir);
for (let g = 0; g < entries.length; g++) {
if (g > 0) {
await randomDelay();
}
const [dirId, items] = entries[g];
if (items.length === 1) {
const result = await this._deleteOne(dirId, false, i, selectedItems, item, onProgress);
if (result) {
itemDeleted++;
deletedCount++;
}
} else {
for (let f = 0; f < items.length; f++) {
if (f > 0) await randomDelay();
const fileDeleted = await this._deleteOne(items[f].folderId, true, i, selectedItems, item, onProgress);
if (fileDeleted) {
itemDeleted++;
deletedCount++;
}
}
}
}
onProgress && onProgress(i + 1, selectedItems.length, item, itemDeleted > 0 ? `已删除${itemDeleted}个` : "删除失败");
} catch (error) {
failedCount++;
onProgress && onProgress(i + 1, selectedItems.length, item, error.message || "出错");
console.error(`批量删除出错 [${item.carNum}]:`, error);
}
}
return {deletedCount, failedCount};
}
isRateLimited(response) {
if (!response) return false;
if (response.state === false) {
const msg = (response.error || response.msg || "").toLowerCase();
if (msg.includes("频繁") || msg.includes("操作太频繁") || msg.includes("稍后再试")
|| msg.includes("rate") || msg.includes("limit") || msg.includes("too many")) {
return true;
}
}
if (response.status === 403 || response.status === 429) {
return true;
}
return false;
}
async _deleteOne(deleteId, isFile, batchIndex, selectedItems, item, onProgress) {
let retryCount = 0;
const maxRetries = 3;
const label = isFile ? "文件" : "目录";
while (retryCount <= maxRetries) {
try {
const result = await gmHttp.postForm("https://webapi.115.com/rb/delete", {
"fid[0]": deleteId,
ignore_warn: "1"
});
if (result && result.state) {
return 1;
} else {
const errMsg = (result && result.error) || "未知错误";
if (retryCount < maxRetries && this.isRateLimited(result)) {
retryCount++;
const backoff = (retryCount + 1) * 2000 + Math.random() * 1000;
onProgress && onProgress(batchIndex + 1, selectedItems.length, item, `风控触发,${backoff / 1000}秒后重试(${retryCount}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, backoff));
continue;
}
console.error(`删除${label}失败: ${deleteId}`, errMsg);
return 0;
}
} catch (e) {
if (retryCount < maxRetries && this.isRateLimited(e)) {
retryCount++;
const backoff = (retryCount + 1) * 2000 + Math.random() * 1000;
onProgress && onProgress(batchIndex + 1, selectedItems.length, item, `风控触发,${backoff / 1000}秒后重试(${retryCount}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, backoff));
continue;
}
console.error(`删除${label}失败: ${deleteId}`, e);
return 0;
}
}
return 0;
}
showDeleteDialog(carNum2, matchList, afterDelete) {
const upperCarNum = carNum2.toUpperCase();
const safeMatchList = matchList.filter(m => m.name.toUpperCase().includes(upperCarNum));
const unsafeCount = matchList.length - safeMatchList.length;
if (safeMatchList.length === 0) {
show.error(`作品 "${carNum2}" 匹配到的 ${matchList.length} 个项目均不含番号,无法安全删除。请手动在115中删除。`);
return;
}
const formatSize = bytes => {
if (!bytes) return "-";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = parseFloat(bytes), unit = 0;
for (; size >= 1024 && unit < units.length - 1;) size /= 1024, unit++;
return size.toFixed(2) + " " + units[unit];
};
let html = '<div style="padding: 15px; max-height: 400px; overflow-y: auto;">';
html += `<div style="margin-bottom: 12px; font-size: 14px;">作品 <b>${carNum2}</b> 匹配到 <b>${matchList.length}</b> 个文件夹(可删除 <b>${safeMatchList.length}</b> 个):</div>`;
if (unsafeCount > 0) {
html += `<div style="margin-bottom: 10px; padding: 6px 10px; background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; font-size: 12px; color: #856404;">⚠ 已自动排除 ${unsafeCount} 个目录名不含番号的项目,防止误删</div>`;
}
html += '<div style="margin-bottom: 10px;"><label style="cursor: pointer;"><input type="checkbox" class="jhs-delete-select-all" checked> <b>全选</b></label></div>';
for (let i = 0; i < safeMatchList.length; i++) {
const m = safeMatchList[i];
html += `<div style="margin-bottom: 6px; padding: 8px 10px; border: 1px solid #e0e0e0; border-radius: 4px; background: #fafafa;">
<label style="display: flex; align-items: flex-start; cursor: pointer; width: 100%;">
<input type="checkbox" class="jhs-delete-folder-cb" data-index="${i}" checked style="margin-top: 3px; flex-shrink: 0;">
<span style="margin-left: 8px; flex: 1;">
<div style="word-break: break-all;">${m.name}</div>
<div style="font-size: 12px; color: #888; margin-top: 2px;">${formatSize(m.size)} | ${m.createTime}</div>
</span>
</label>
</div>`;
}
html += "</div>";
const layerIndex = layer.open({
type: 1,
title: "删除115作品文件夹",
content: html,
area: ["520px", "auto"],
btn: ["确定删除", "取消"],
shadeClose: !1,
yes: async index => {
const $layer = $(`#layui-layer${index}`);
const checked = $layer.find(".jhs-delete-folder-cb:checked");
if (0 === checked.length) {
show.error("请至少选择一个文件夹");
return;
}
const selected = checked.map((function () {
return parseInt($(this).data("index"));
})).get();
layer.close(index);
let loadObj = loading();
let deletedCount = 0;
try {
const selectedItems = selected.map((i) => safeMatchList[i]);
const totalByDir = {};
for (const m of safeMatchList) {
totalByDir[m.dirId] = (totalByDir[m.dirId] || 0) + 1;
}
const groupedByDir = {};
for (const item of selectedItems) {
if (!groupedByDir[item.dirId]) groupedByDir[item.dirId] = [];
groupedByDir[item.dirId].push(item);
}
for (const [dirId, items] of Object.entries(groupedByDir)) {
if (items.length === totalByDir[dirId]) {
const result = await gmHttp.postForm("https://webapi.115.com/rb/delete", {
"fid[0]": dirId,
ignore_warn: "1"
});
result && result.state ? deletedCount++ : console.error(`删除目录失败: ${dirId}`, result);
} else {
for (const item of items) {
const result = await gmHttp.postForm("https://webapi.115.com/rb/delete", {
"fid[0]": item.folderId,
ignore_warn: "1"
});
result && result.state ? deletedCount++ : console.error(`删除文件失败: ${item.folderId}`, result);
}
}
}
loadObj.close();
show.ok(`作品 "${carNum2}" 已删除 ${deletedCount} 个文件夹`);
const newMatchList = await this.searchFiles(carNum2);
afterDelete && afterDelete(newMatchList);
const remainingCids = new Set(newMatchList.map((m) => m.dirId));
JavPackSubtitle.cached115Matches = JavPackSubtitle.cached115Matches.filter((m) => remainingCids.has(m.cid));
JavPackSubtitle.current115Cids = JavPackSubtitle.current115Cids.filter((cid) => remainingCids.has(cid));
try {
this.getBean("WangPan115TaskPlugin").deleteOfflineTasksByKeyword?.(carNum2);
} catch (e) {
console.warn("删除离线任务暂不可用:", e.message);
}
} catch (error) {
loadObj.close();
console.error("删除115文件失败:", error);
show.error(`删除失败: ${error.message || "网络错误"}`);
}
}
});
$(`#layui-layer${layerIndex}`).on("change", ".jhs-delete-select-all", (function () {
const checked = $(this).prop("checked");
$(`#layui-layer${layerIndex} .jhs-delete-folder-cb`).prop("checked", checked);
}));
}
async handleLoginRedirect() {
window.open("https://115.com");
@@ -351,6 +620,7 @@ const _WangPan115MatchPlugin = class _WangPan115MatchPlugin extends BasePlugin {
let searchKeyword = carNum2.toLowerCase().replace("fc2-", "");
return (null == (_a2 = (await searchFiles(searchKeyword)).data) ? void 0 : _a2.map((data => ({
folderId: data.fid,
dirId: data.cid,
videoId: data.pc,
name: data.n,
createTime: utils.formatDate(new Date(1e3 * data.te)),
@@ -392,6 +662,16 @@ const _WangPan115MatchPlugin = class _WangPan115MatchPlugin extends BasePlugin {
if (!($box2.find("[class^='jhs-match-']").length > 0)) if (this.loginStatus === _WangPan115MatchPlugin.LoginStatus.LOGGED_OUT) $box2.find(".video-title").prepend(`<a class='jhs-match-no-login-btn a-info' \n data-keyword="${carNum2}" \n title="未登录115网盘">未登录</a>`); else if (matchList.length > 0) {
const title = 1 === matchList.length ? "点击直接播放" : `点击查看${matchList.length}个匹配结果`;
$box2.find(".video-title").prepend(`<a class='jhs-match-btn a-success' \n data-keyword="${carNum2}"\n data-match='${JSON.stringify(matchList)}'\n title="${title}">匹配${matchList.length}个</a>`);
const $deleteBtn = $box2.find(".delete115Svg");
const $markBtn = $box2.find(".markDeleteSvg");
if ($deleteBtn.length > 0 && matchList.some(m => m.dirId != null)) {
$deleteBtn.attr("data-match", JSON.stringify(matchList));
$deleteBtn.show();
}
if ($markBtn.length > 0) {
$markBtn.show();
this.updateMarkBtnStyle($markBtn, carNum2);
}
} else $box2.find(".video-title").prepend(`<a class='jhs-match-error-btn a-info' \n data-keyword="${carNum2}" \n title="未匹配,点击重试">未匹配</a>`);
}
formatSize(bytes) {
+5 -6
View File
@@ -6,11 +6,11 @@ export default defineConfig({
monkey({
entry: 'src/main.js',
userscript: {
name: 'JAV-JHS',
namespace: 'JAV-JHS',
version: '3.3.6-fix',
name: 'JHS-enhance',
namespace: 'JHS-enhance',
version: '3.3.7',
author: 'xie bro,fireinrain',
description: 'Jav-鉴黄师 收藏、屏蔽、标记已下载; 屏蔽标签、屏蔽演员、同步收藏演员、新作品检测; 免VIP查看热播、Top250排行榜、Fc2ppv、可查看所有评论信息、相关清单; 支持云盘备份; 以图识图; 字幕搜索; JavDb|JavBus',
description: 'Jav-鉴黄师 增强脚本。列表页:作品状态标签、一键筛选、新作品检测、演员黑名单过滤;详情页:磁力链接高亮、DMM 多画质预览视频、标题翻译、整合字幕搜索(迅雷+SubTitleCat)并支持 115 直传、多源预览图(javfree/projectjav/javstore)含来源切换;数据:115 网盘目录匹配与多目录选择、云盘备份/恢复、跨 Tab 同步;其他:自动翻页、分类折叠、Top250、以图识图、热门榜单、评论查看、相关清单。支持 JavDB / JavBus / JavSee / SeeJav / FC2 / JavTrailers',
license: 'MIT',
icon: 'https://www.google.com/s2/favicons?sz=64&domain=javdb.com',
match: [
@@ -37,9 +37,8 @@ export default defineConfig({
'https://*seejav*/*actresses',
],
require: [
'https://raw.githubusercontent.com/Tampermonkey/utils/refs/heads/main/requires/gh_2215_make_GM_xhr_more_parallel_again.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/blueimp-md5@2.19.0/js/md5.min.js',
'https://cdn.jsdelivr.net/npm/toastify-js@1.12.0/src/toastify.min.js',