release(project): 初始版本
This commit is contained in:
commit
2f635006ea
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# lock files
|
||||
*.lock
|
||||
11
README.md
Normal file
11
README.md
Normal file
@ -0,0 +1,11 @@
|
||||
To install dependencies:
|
||||
```sh
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
```sh
|
||||
bun run dev
|
||||
```
|
||||
|
||||
open http://localhost:3000
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
5
frontend/README.md
Normal file
5
frontend/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Vue 3 + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
22
frontend/index.html
Normal file
22
frontend/index.html
Normal file
@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>抖音解析</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
"element-plus": "^2.9.11",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"eslint": "^9.28.0",
|
||||
"prettier": "^3.5.3",
|
||||
"terser": "^5.40.0",
|
||||
"unplugin-auto-import": "^19.3.0",
|
||||
"unplugin-vue-components": "^28.7.0",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-compression": "^0.5.1"
|
||||
}
|
||||
}
|
||||
253
frontend/src/App.vue
Normal file
253
frontend/src/App.vue
Normal file
@ -0,0 +1,253 @@
|
||||
<script setup lang="js">
|
||||
import axios from 'axios'
|
||||
|
||||
const downloadKey = ref('')
|
||||
const formRef = ref(null)
|
||||
const formData = ref({
|
||||
inputText: '',
|
||||
})
|
||||
const urlPattern = /https?:\/\/[^\s]+/
|
||||
const shareUrl = computed(() => {
|
||||
const match = formData.value.inputText.match(urlPattern)
|
||||
return match ? match[0] : ''
|
||||
})
|
||||
const isValidInput = computed(() => {
|
||||
return formData.value.inputText.trim() !== '' && urlPattern.test(formData.value.inputText)
|
||||
})
|
||||
const rules = {
|
||||
inputText: [
|
||||
{ required: true, message: '请输入分享链接或者带链接的分享文本', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (!value || !urlPattern.test(value)) {
|
||||
callback(new Error('请输入有效的URL或包含URL的文本'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
}
|
||||
const handleParse = async () => {
|
||||
if (formRef.value) {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
ElMessage.success('验证通过,开始解析...')
|
||||
// 请求后端
|
||||
try {
|
||||
const response = await axios.post('/api/parse', {
|
||||
shareUrl: shareUrl.value,
|
||||
})
|
||||
if (response.data && response.data.success) {
|
||||
resRef.value = response.data.data
|
||||
console.log(resRef.value.video2)
|
||||
} else {
|
||||
ElMessage.error('解析失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('解析失败,请稍后再试')
|
||||
console.error('解析错误: ', e)
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('验证失败,请检查输入内容')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
const resRef = ref(null)
|
||||
// 下载视频
|
||||
const handleDownload = async () => {
|
||||
if (resRef.value && resRef.value.video2) {
|
||||
if (!downloadKey.value.trim()) {
|
||||
ElMessage.error('请输入下载密钥')
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 使用后端代理下载
|
||||
const downloadUrl = `/api/download?key=${downloadKey.value}&url=${encodeURIComponent(resRef.value.video2)}`
|
||||
// 判断是否为移动端
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
|
||||
if (isMobile) {
|
||||
// 直接跳转下载链接
|
||||
window.location.href = resRef.value.video2
|
||||
ElMessage.success('正在跳转下载...')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await axios.get(downloadUrl, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
const blob = new Blob([response.data], { type: response.data.type || 'video/mp4' })
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = `douyin_video_${resRef.value.title}.mp4`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(link.href)
|
||||
ElMessage.success('开始下载视频')
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error)
|
||||
ElMessage.error('下载失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error)
|
||||
ElMessage.error('下载失败')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error('没有可下载的视频链接')
|
||||
}
|
||||
}
|
||||
// 复制视频链接
|
||||
const copyVideoLink = () => {
|
||||
if (resRef.value && resRef.value.video2) {
|
||||
navigator.clipboard.writeText(resRef.value.video2)
|
||||
ElMessage.success('视频链接已复制到剪贴板')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-row :gutter="24" id="header-card-container">
|
||||
<el-col :xl="7" :lg="6" :md="4" :sm="2" :xs="0"></el-col>
|
||||
<el-col :xl="10" :lg="12" :md="16" :sm="20" :xs="24">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>视频解析</span>
|
||||
</div>
|
||||
</template>
|
||||
<div></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24" class="card-container">
|
||||
<el-col :xl="7" :lg="6" :md="4" :sm="2" :xs="0"></el-col>
|
||||
<el-col :xl="10" :lg="12" :md="16" :sm="20" :xs="24">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>输入</span>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<el-form ref="formRef" :model="formData" :rules="rules">
|
||||
<el-form-item label="分享内容:" prop="inputText">
|
||||
<el-input
|
||||
v-model="formData.inputText"
|
||||
autosize
|
||||
large
|
||||
type="textarea"
|
||||
placeholder="输入分享文本或链接"
|
||||
:class="{
|
||||
'valid-input': isValidInput,
|
||||
'invalid-input': formData.inputText && !isValidInput,
|
||||
}"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button type="warning" class="full-width-btn" @click="formRef.resetFields()"
|
||||
>重置输入</el-button
|
||||
>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="full-width-btn"
|
||||
@click="handleParse"
|
||||
:disabled="!isValidInput"
|
||||
>解析抖音</el-button
|
||||
>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24" v-if="resRef" class="card-container">
|
||||
<el-col :xl="7" :lg="6" :md="4" :sm="2" :xs="0"></el-col>
|
||||
<el-col :xl="10" :lg="12" :md="16" :sm="20" :xs="24">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>解析结果</span>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<!-- 视频显示区域 -->
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
"
|
||||
>
|
||||
<iframe
|
||||
:src="resRef.video2"
|
||||
allowfullscreen
|
||||
referrerpolicy="no-referrer"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
frameborder="0"
|
||||
style="min-height: 400px; width: 100%"
|
||||
></iframe>
|
||||
</div>
|
||||
<!-- 下载密钥输入 -->
|
||||
<div style="margin-bottom: 16px">
|
||||
<el-input
|
||||
v-model="downloadKey"
|
||||
placeholder="输入下载密钥,没有的话复制链接自行下载"
|
||||
type="password"
|
||||
show-password
|
||||
style="width: 100%"
|
||||
>
|
||||
<template #prepend>下载密钥</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<!-- 按钮区域 -->
|
||||
<div>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="full-width-btn"
|
||||
@click="handleDownload"
|
||||
v-if="resRef.video2"
|
||||
:disabled="!downloadKey.trim()"
|
||||
>
|
||||
下载视频
|
||||
</el-button>
|
||||
<el-button
|
||||
type="success"
|
||||
class="full-width-btn"
|
||||
@click="copyVideoLink"
|
||||
v-if="resRef.video2"
|
||||
>
|
||||
复制视频链接自行下载
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#header-card-container {
|
||||
margin-top: 40px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.valid-input {
|
||||
border-color: #67c23a;
|
||||
}
|
||||
|
||||
.invalid-input {
|
||||
border-color: #f56c6c;
|
||||
}
|
||||
.full-width-btn {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.card-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
77
frontend/src/auto-imports.d.ts
vendored
Normal file
77
frontend/src/auto-imports.d.ts
vendored
Normal file
@ -0,0 +1,77 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const ElMessage: typeof import('element-plus/es')['ElMessage']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const h: typeof import('vue')['h']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
||||
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useLink: typeof import('vue-router')['useLink']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
19
frontend/src/components.d.ts
vendored
Normal file
19
frontend/src/components.d.ts
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
}
|
||||
}
|
||||
6
frontend/src/main.js
Normal file
6
frontend/src/main.js
Normal file
@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.mount('#app')
|
||||
85
frontend/vite.config.js
Normal file
85
frontend/vite.config.js
Normal file
@ -0,0 +1,85 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import compress from 'vite-plugin-compression'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig((command, mode) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const isDev = mode === 'development'
|
||||
return {
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [
|
||||
ElementPlusResolver({
|
||||
importStyle: 'css',
|
||||
}),
|
||||
],
|
||||
imports: ['vue'],
|
||||
dts: 'src/auto-imports.d.ts',
|
||||
}),
|
||||
Components({
|
||||
resolvers: [
|
||||
ElementPlusResolver({
|
||||
importStyle: 'css',
|
||||
}),
|
||||
],
|
||||
dts: 'src/components.d.ts',
|
||||
}),
|
||||
compress({
|
||||
verbose: true,
|
||||
disable: false,
|
||||
threshold: 10240,
|
||||
algorithm: 'gzip',
|
||||
ext: '.gz',
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:30001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
cssCodeSplit: true,
|
||||
minify: 'terser',
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: true,
|
||||
drop_debugger: true,
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['vue', 'element-plus', 'axios'],
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
// 基础库分包
|
||||
if (id.includes('node_modules')) {
|
||||
if (id.includes('element-plus')) {
|
||||
return 'element-plus'
|
||||
} else if (id.includes('vue') || id.includes('@vue')) {
|
||||
return 'vue-vendor'
|
||||
} else if (id.includes('axios')) {
|
||||
return 'axios'
|
||||
}
|
||||
} else {
|
||||
return 'vendor'
|
||||
}
|
||||
},
|
||||
// 控制打包文件目录
|
||||
chunkFileNames: 'assets/js/[name]-[hash].js',
|
||||
entryFileNames: 'assets/js/[name]-[hash].js',
|
||||
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
|
||||
},
|
||||
},
|
||||
brotliSize: true,
|
||||
chunkSizeWarningLimit: 500,
|
||||
},
|
||||
}
|
||||
})
|
||||
15
package.json
Normal file
15
package.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "douyin_parser",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch --hot src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
"hono": "^4.7.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"eslint": "^9.28.0",
|
||||
"prettier": "^3.5.3"
|
||||
}
|
||||
}
|
||||
160
src/index.ts
Normal file
160
src/index.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import { writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
const DOWNLOAD_KEY = 'djl'
|
||||
|
||||
app.use('/api/*', cors())
|
||||
|
||||
app.get('/', (c) => {
|
||||
return c.text('抖音视频解析API - 使用 /api/parse POST 提交 shareUrl 参数(形如:https://v.douyin.com/IqPTpKdCngY/)')
|
||||
})
|
||||
|
||||
// 获取重定向地址
|
||||
async function getRedirectedUrl(url: string): Promise<string> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
redirect: 'follow',
|
||||
})
|
||||
return response.url
|
||||
} catch (e) {
|
||||
console.error('Error fetching URL:', e)
|
||||
throw new Error('Failed to fetch the URL')
|
||||
}
|
||||
}
|
||||
|
||||
app.post('/api/parse', async (c) => {
|
||||
try {
|
||||
let body
|
||||
try {
|
||||
body = await c.req.json()
|
||||
} catch (e) {
|
||||
return c.json({ error: 'Invalid request body' }, 400)
|
||||
}
|
||||
const shareUrl = body.shareUrl || undefined
|
||||
if (!shareUrl) {
|
||||
return c.json({ error: 'Missing shareUrl parameter' }, 400)
|
||||
}
|
||||
let videoId: string
|
||||
const redirectedUrl = await getRedirectedUrl(shareUrl)
|
||||
const idMatch = redirectedUrl.match(/(\d+)/)
|
||||
if (!idMatch) {
|
||||
return c.json({ error: 'Failed to extract video ID from URL' }, 400)
|
||||
}
|
||||
videoId = idMatch[1]
|
||||
const headers = {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36',
|
||||
Referer: 'https://www.douyin.com/?is_from_mobile_home=1&recommend=1',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Connection': 'keep-alive',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
}
|
||||
const url = `https://www.iesdouyin.com/share/video/${videoId}/`
|
||||
const response = await fetch(url, { headers })
|
||||
if (!response.ok) {
|
||||
return c.json({ error: 'Failed to fetch video data' }, 500)
|
||||
}
|
||||
const html = await response.text()
|
||||
const routerDataMatch = html.match(/_ROUTER_DATA\s*=\s*(\{.*?\});/)
|
||||
if (!routerDataMatch) {
|
||||
return c.json({ error: 'Failed to extract router data' }, 500)
|
||||
}
|
||||
const jsonData = JSON.parse(routerDataMatch[1])
|
||||
const itemList = jsonData?.loaderData?.['video_(id)/page']?.videoInfoRes?.item_list?.[0]
|
||||
if (!itemList) {
|
||||
return c.json({ error: '视频数据格式异常' }, 500)
|
||||
}
|
||||
const nickname = itemList.author?.nickname
|
||||
const title = itemList.desc
|
||||
const awemeId = itemList.aweme_id
|
||||
const video = itemList.video?.play_addr?.uri
|
||||
const video2 = await getRedirectedUrl(
|
||||
itemList.video?.play_addr?.url_list?.[0]
|
||||
.replace(/playwm/, 'play')
|
||||
.replace(/ratio=\d+p/, 'ratio=3840p'),
|
||||
)
|
||||
const videoUrl = video
|
||||
? video.includes('mp3')
|
||||
? video
|
||||
: `https://www.douyin.com/aweme/v1/play/?video_id=${video}`
|
||||
: null
|
||||
const cover = itemList.video?.cover?.url_list?.[0]
|
||||
// const images = itemList.images
|
||||
if (!nickname || !videoUrl) {
|
||||
return c.json({
|
||||
success: false,
|
||||
message: '解析失败,可能是视频已被删除或者接口变更',
|
||||
})
|
||||
}
|
||||
return c.json({
|
||||
success: true,
|
||||
message: '解析成功',
|
||||
data: {
|
||||
name: nickname,
|
||||
awemeId: awemeId,
|
||||
title: title,
|
||||
video: videoUrl,
|
||||
video2: video2 || null,
|
||||
cover: cover,
|
||||
// images: images ? images.map((image: any) => image.url_list[0]) : [],
|
||||
// type: images ? '图集' : '视频',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('解析错误:', error)
|
||||
return c.json({ error: '服务器内部错误' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/download', async (c) => {
|
||||
const videoUrl = c.req.query('url')
|
||||
const providedKey = c.req.query('key')
|
||||
if (!providedKey) {
|
||||
return c.json({ error: 'Missing download key' }, 401)
|
||||
}
|
||||
if (!videoUrl) {
|
||||
return c.json({ error: 'Missing video URL' }, 400)
|
||||
}
|
||||
try {
|
||||
// 验证密钥
|
||||
if (providedKey !== DOWNLOAD_KEY) {
|
||||
return c.json({ error: 'Invalid download key' }, 403)
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15',
|
||||
Referer: 'https://www.douyin.com/',
|
||||
Accept: '*/*',
|
||||
}
|
||||
|
||||
const response = await fetch(decodeURIComponent(videoUrl), { headers })
|
||||
|
||||
if (!response.ok) {
|
||||
return c.text('Video not found', 404)
|
||||
}
|
||||
|
||||
// 设置下载响应头
|
||||
c.header('Content-Type', 'video/mp4')
|
||||
c.header('Content-Disposition', 'attachment; filename="douyin_video.mp4"')
|
||||
c.header('Content-Length', response.headers.get('Content-Length') || '0')
|
||||
|
||||
return new Response(response.body, {
|
||||
headers: c.res.headers,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('下载错误:', error)
|
||||
return c.text('Internal server error', 500)
|
||||
}
|
||||
})
|
||||
|
||||
export default {
|
||||
port: 30001,
|
||||
fetch: app.fetch,
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx"
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user