commit 2f635006ea3ccfb44b3005e5157374f68928c6b2 Author: ApplePine Date: Sun Jun 29 13:05:58 2025 +0800 release(project): 初始版本 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41b95d4 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6dd13e7 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +To install dependencies: +```sh +bun install +``` + +To run: +```sh +bun run dev +``` + +open http://localhost:3000 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..1511959 --- /dev/null +++ b/frontend/README.md @@ -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 ` + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..3c22358 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..da1e4f7 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,253 @@ + + + + + diff --git a/frontend/src/auto-imports.d.ts b/frontend/src/auto-imports.d.ts new file mode 100644 index 0000000..5917248 --- /dev/null +++ b/frontend/src/auto-imports.d.ts @@ -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') +} diff --git a/frontend/src/components.d.ts b/frontend/src/components.d.ts new file mode 100644 index 0000000..6e48d6d --- /dev/null +++ b/frontend/src/components.d.ts @@ -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'] + } +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..52668a0 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,6 @@ +import { createApp } from 'vue' +import App from './App.vue' + +const app = createApp(App) + +app.mount('#app') diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..27e7282 --- /dev/null +++ b/frontend/vite.config.js @@ -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, + }, + } +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..84da18a --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..c6243b6 --- /dev/null +++ b/src/index.ts @@ -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 { + 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, +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c442b33 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "strict": true, + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx" + } +} \ No newline at end of file