release(project): 初始版本

This commit is contained in:
ApplePine 2025-06-29 13:05:58 +08:00
commit 2f635006ea
14 changed files with 737 additions and 0 deletions

27
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
{
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
}
}