字幕搜索下载预览 (Vue + Element UI)
// ==UserScript==
// @name 字幕搜索下载预览 (Vue + Element UI)
// @namespace http://tampermonkey.net/
// @version 2.2
// @description 使用 Vue 和 Element UI 优化 UI 的下载字幕脚本,增加预览功能
// @author qingtian1
// @include *
// @icon https://www.google.com/s2/favicons?sz=64&domain=bbs.tampermonkey.net.cn
// @require https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.min.js
// @require https://unpkg.com/element-ui@2.14.1/lib/index.js
// @require https://cdn.jsdelivr.net/npm/jschardet@3.0.0/dist/jschardet.min.js
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @connect api-shoulei-ssl.xunlei.com
// @connect subtitle.v.geilijiasu.com
// ==/UserScript==
(function() {
'use strict'
// 防止在 iframe 中重复运行代码
if (window.self !== window.top) {
console.log('当前页面不是主窗口,无法运行脚本')
return // 如果当前不是主窗口,直接退出
}
// 动态插入 Element UI 样式
const elementStyle = document.createElement('link')
elementStyle.rel = 'stylesheet'
elementStyle.href = 'https://unpkg.com/element-ui@2.14.1/lib/theme-chalk/index.css'
document.head.appendChild(elementStyle)
// 动态插入自定义样式
const customStyle = document.createElement('style')
customStyle.type = 'text/css'
customStyle.innerHTML = `
.myVueCard {
position: fixed;
z-index: 9999;
left: 0;
top: 0;
height: 100vh;
width: 70vw;
background-color: rgba(255, 255, 255, 0.9);
transition: all 0.5s;
box-shadow: 2px 3px 3px 0 rgba(0, 0, 0, 0.1);
}
.card__btn {
transition: all 0.5s;
border-radius: 30px 0 0 30px;
width: 30px;
height: 60px;
background-color: rgb(178, 94, 239);
cursor: pointer;
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
text-align: center;
}
.card__btn svg {
height: 20px;
width: 20px;
position: absolute;
right: 5px;
top: 20px;
transition: all 0.5s;
}
.card--hide {
left: -70vw;
}
.card--hide .card__btn {
border-radius: 0 30px 30px 0;
right: -30px;
}
.card--hide .card__btn svg {
transform: rotate(180deg);
}
.el-message-box__wrapper {
z-index: 10001 !important;
}
`
document.head.appendChild(customStyle)
// 创建 Vue 挂载点
const appContainer = document.createElement('div')
appContainer.id = 'subtitleApp'
document.body.appendChild(appContainer)
// Vue 实例
new Vue({
el: '#subtitleApp',
data() {
return {
isHide: false, // 是否隐藏侧边栏
keyword: '', // 搜索关键字
subtitleList: [], // 字幕列表
loading: false, // 加载状态
showTable: false, // 是否显示表格
showPreview: false, // 是否显示预览对话框
previewContent: '', // 预览的字幕内容
previewLoading: false, // 预览加载状态
isInputFocused: false, // 输入框是否聚焦
isSearchFocused: false, // 输入框是否聚焦
search: ''
}
},
mounted() {
// 添加全局事件监听器,监听输入框的 ESC 键
document.addEventListener('keydown', this.handleKeydown)
const subtitleDownloadDefaultIsHide = GM_getValue('subtitleDownloadDefaultIsHide')
if (subtitleDownloadDefaultIsHide !== null && subtitleDownloadDefaultIsHide !== undefined) {
this.isHide = subtitleDownloadDefaultIsHide
}
document.addEventListener('selectionchange', () => {
const selectedText = window.getSelection().toString() // 实时获取选中的文本
if (selectedText && selectedText.trim() !== '' && !this.isInputFocused) {
// console.log('当前选中的文本:', selectedText)
this.keyword = selectedText
}
})
},
methods: {
customSort({ column, prop, order }) {
let sortIndex = 1
if (order === 'descending') {
sortIndex = -1
}
if (['分钟', '时间'].includes(column['label'])) {
prop = 'duration'
this.subtitleList.sort((pre, next) => {
const preValue = pre[prop]
if (this.isBlank(preValue)) {
return 1
}
const nextValue = next[prop]
if (this.isBlank(nextValue)) {
return -1
}
const diff = preValue - nextValue
return sortIndex * diff
})
} else if (['name'].includes(prop)) {
this.subtitleList.sort((pre, next) => {
const preValue = pre[prop]
if (this.isBlank(preValue)) {
return 1
}
const nextValue = next[prop]
if (this.isBlank(nextValue)) {
return -1
}
const strDiff = preValue.localeCompare(nextValue)
return sortIndex * strDiff
})
} else {
console.log('not fond sort function')
}
},
isBlank(value) {
if (value === undefined ||
value === '' ||
value === null ||
(value instanceof Array && value.length === 0) ||
(value instanceof Object && Object.keys(value).length === 0)
) {
return true
}
return false
},
// 切换侧边栏显示状态
toggleSidebar() {
this.isHide = !this.isHide
GM_setValue('subtitleDownloadDefaultIsHide', this.isHide)
},
// 监听键盘按键
handleKeydown(event) {
// console.log('isInputFocused', this.isInputFocused)
if (event.keyCode === 27 && this.isInputFocused) {
this.keyword = ''
this.whenInputClear()
} else if (event.keyCode === 27 && this.isSearchFocused) {
this.search = ''
}
},
whenInputClear() {
this.showTable = false
this.subtitleList.length = 0
},
whenInputChange(value) {
if (value.trim() === '') {
this.showTable = false
this.subtitleList.length = 0
}
},
// 搜索字幕
async searchSubtitles() {
if (!this.keyword.trim()) {
this.$message.error('请输入关键字')
return
}
this.loading = true
this.subtitleList = []
const list = await this.requestToXunlei(this.keyword.trim())
if (list.length === 0) {
this.$message.warning('未找到相关字幕')
} else {
this.subtitleList = list
this.showTable = true
}
this.loading = false
},
// 请求迅雷 API
requestToXunlei(keyword) {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name=${keyword}`,
// responseType: 'blob', // 确保返回的是 Blob 数据
anonymous: true, // 设置匿名,避免携带 cookies,绕过跨域验证
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', // 模拟 Chrome
'Cache-Control': 'no-cache', // 请求不要缓存
'Pragma': 'no-cache', // HTTP 1.0 的缓存控制(备用)
'Expires': '0', // 设置过期时间为过去时间,强制刷新
'Referer': 'https://api-shoulei-ssl.xunlei.com/' // 模拟来源页面
},
onload: (response) => {
const res = JSON.parse(response.responseText)
console.log('res', res)
if (res.code === 0 && Array.isArray(res.data)) {
res.data.sort((pre, next) => {
const preExt = pre['ext']
if (this.isBlank(preExt)) {
return 1
}
const nextExt = next['ext']
if (this.isBlank(nextExt)) {
return -1
}
const extDiff = nextExt.localeCompare(preExt)
if (extDiff !== 0) {
return extDiff
}
const preDuration = pre['duration']
if (this.isBlank(preDuration)) {
return 1
}
const nextDuration = next['duration']
if (this.isBlank(nextDuration)) {
return -1
}
return nextDuration - preDuration
})
resolve(res.data)
} else {
resolve([])
}
},
onerror: () => resolve([])
})
})
},
// 下载字幕
downloadSubtitle(row) {
const endExt = `.${row.ext}`
this.$prompt('请输入文件名,不包含后缀', '保存字幕', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: row.name.replace(endExt, ''), // 默认文件名
inputValidator: (value) => {
return value.trim() !== '' || '文件名不能为空'
}
}).then(({ value }) => {
const filename = value.endsWith(endExt) ? value : `${value}${endExt}` // 自动补充扩展名
console.log(filename)
GM_xmlhttpRequest({
method: 'GET',
url: row.url,
responseType: 'blob', // 确保返回的是 Blob 数据
anonymous: true, // 设置匿名,避免携带 cookies,绕过跨域验证
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', // 模拟 Chrome
'Cache-Control': 'no-cache', // 请求不要缓存
'Pragma': 'no-cache', // HTTP 1.0 的缓存控制(备用)
'Expires': '0', // 设置过期时间为过去时间,强制刷新
'Referer': 'https://subtitle.v.geilijiasu.com/' // 模拟来源页面
},
onload: (response) => {
const blob = response.response
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(link.href)
this.$set(row, 'isDownloaded', true) // Vue 响应式地更新属性
},
onerror: () => {
this.$message.error('下载失败,请重试')
}
})
}).catch(() => {
this.$message.info('取消保存操作')
})
},
// 将 ArrayBuffer 转换为 Base64
arrayBufferToBase64(buffer) {
let binary = ''
const bytes = new Uint8Array(buffer)
const length = bytes.byteLength
for (let i = 0; i < length; i++) {
binary += String.fromCharCode(bytes[i])
}
return 'data:application/octet-stream;base64,' + window.btoa(binary)
},
// 预览字幕
previewSubtitle(row) {
this.previewLoading = true
this.showPreview = true
this.previewContent = '' // 清空之前的预览内容
GM_xmlhttpRequest({
method: 'GET',
url: row.url,
responseType: 'arraybuffer', // 确保返回的是 Blob 数据
anonymous: true, // 设置匿名,避免携带 cookies,绕过跨域验证
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', // 模拟 Chrome
'Cache-Control': 'no-cache', // 请求不要缓存
'Pragma': 'no-cache', // HTTP 1.0 的缓存控制(备用)
'Expires': '0', // 设置过期时间为过去时间,强制刷新
'Referer': 'https://subtitle.v.geilijiasu.com/' // 模拟来源页面
},
onload: (response) => {
const arrayBuffer = response.response
const base64String = this.arrayBufferToBase64(arrayBuffer)
// console.log('Base64 String:', base64String)
// 解码 Base64 后进行编码检测
const detected = jschardet.detect(atob(base64String.split(';base64,')[1])) // 从 base64 数据中提取并解码
console.log('Detected encoding:', detected)
const encoding = detected.encoding.toLowerCase()
const blob = new Blob([arrayBuffer])
// 读取文件内容
const reader = new FileReader()
reader.onload = (event) => {
const textContent = event.target.result
// console.log('Text content:', textContent)
this.previewContent = textContent
}
reader.readAsText(blob, encoding) // 使用检测到的编码读取文件
this.previewLoading = false
this.$set(row, 'isPreview', true) // Vue 响应式地更新属性
},
onerror: () => {
this.$message.error('加载预览失败')
this.previewLoading = false
}
})
},
// 转换毫秒为分钟
convertMillisecondsToMinutes(milliseconds) {
return Math.floor(milliseconds / 1000 / 60)
},
// 格式化时间
formatMillisecondsToTime(milliseconds) {
const totalSeconds = Math.floor(milliseconds / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
},
template: `
<div class="myVueCard" :class="{ 'card--hide': isHide }">
<div class="app-container" style="position: relative;top: 50px; left: 50px;">
<el-form inline @submit.native.prevent>
<el-form-item label="关键字">
<el-input
v-model="keyword"
placeholder="请输入关键字"
clearable
@clear="whenInputClear"
@input="whenInputChange"
@keyup.enter.native="searchSubtitles"
@focus="isInputFocused = true"
@blur="isInputFocused = false"
></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="searchSubtitles" :loading="loading">搜索</el-button>
</el-form-item>
</el-form>
<el-table v-if="showTable"
:data="subtitleList.filter(data => !search || data.name.toLowerCase().includes(search.toLowerCase()))"
border
style="margin-top: 20px;width: 90%"
@sort-change="customSort"
stripe
max-height="650">
<el-table-column
prop="name"
label="文件名"
sortable="custom"
:sort-orders="['descending','ascending']"
></el-table-column>
<el-table-column
label="分钟"
sortable="custom"
:sort-orders="['descending','ascending']"
width="80">
<template slot-scope="scope">
{{ convertMillisecondsToMinutes(scope.row.duration) }}
</template>
</el-table-column>
<el-table-column
label="时间"
sortable="custom"
:sort-orders="['descending','ascending']"
width="150">
<template slot-scope="scope">
{{ formatMillisecondsToTime(scope.row.duration) }}
</template>
</el-table-column>
<el-table-column align="right" width="200">
<template slot="header" slot-scope="scope">
<el-input
v-model="search"
size="mini"
@focus="isSearchFocused = true"
@blur="isSearchFocused = false"
clearable
placeholder="输入关键字搜索"/>
</template>
<template slot-scope="scope">
<el-button
:type="scope.row.isDownloaded ? 'success' : 'primary'"
size="mini"
@click="downloadSubtitle(scope.row)"
>
{{ scope.row.isDownloaded ? '已下载' : '下载' }}
</el-button>
<el-button
:type="scope.row.isPreview ? 'info' : 'warning'"
size="mini"
@click="previewSubtitle(scope.row)"
>
{{ scope.row.isPreview ? '已预览' : '预览' }}
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 预览对话框 -->
<el-dialog
title="字幕预览"
:visible.sync="showPreview"
width="60%"
:before-close="() => { showPreview = false }"
>
<div v-if="previewLoading" style="text-align: center; padding: 20px;">
<el-spinner></el-spinner>
加载中...
</div>
<pre v-else style="white-space: pre-wrap; word-wrap: break-word; max-height: 400px; overflow-y: auto;">
{{ previewContent }}
</pre>
<span slot="footer" class="dialog-footer">
<el-button @click="showPreview = false">关闭</el-button>
</span>
</el-dialog>
</div>
<div class="card__btn" @click="toggleSidebar">
<svg t="1589962875590" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path
d="M730.020653 1018.946715l91.277028-89.978692a16.760351 16.760351 0 0 0 5.114661-11.803064 15.343983 15.343983 0 0 0-5.114661-11.803064l-400.123871-393.435467L821.691117 118.254899a17.075099 17.075099 0 0 0 0-23.606129L730.020653 4.670079a17.232473 17.232473 0 0 0-23.999564 0L202.030255 500.08402a16.445603 16.445603 0 0 0-4.721226 11.803064 15.265296 15.265296 0 0 0 5.114661 11.803064l503.597399 495.413941a17.153786 17.153786 0 0 0 23.999564 0z"
fill="#FFFFFF"
></path>
</svg>
</div>
</div>
`
})
})()