<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文案提取器 - Transcript Extractor</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
'sans': ['Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
},
colors: {
'douyin': {
'red': '#FE2C55',
'pink': '#FF6B9D',
'coral': '#FF4F6D',
'dark': '#161823',
'gray': {
50: '#F8F8F8',
100: '#F1F1F2',
200: '#E5E5E6',
300: '#C9C9CA',
400: '#9E9E9F',
500: '#6C6C6D',
600: '#4A4A4B',
700: '#2F2F30',
800: '#1F1F20',
900: '#121213',
}
}
},
boxShadow: {
'card': '0 2px 8px rgba(0, 0, 0, 0.04), 0 4px 24px rgba(0, 0, 0, 0.06)',
'card-hover': '0 4px 12px rgba(0, 0, 0, 0.06), 0 8px 32px rgba(0, 0, 0, 0.08)',
'input': '0 0 0 4px rgba(254, 44, 85, 0.1)',
}
}
}
}
</script>
<style>
* { box-sizing: border-box; }
body {
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Custom scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #9ca3af; }
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fadeIn { animation: fadeIn 0.3s ease-out; }
@keyframes pulse-soft {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.animate-pulse-soft { animation: pulse-soft 2s ease-in-out infinite; }
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-spin-slow { animation: spin-slow 1.5s linear infinite; }
/* Gradient backgrounds */
.bg-gradient-douyin {
background: linear-gradient(135deg, #FE2C55 0%, #FF6B9D 100%);
}
.bg-gradient-mesh {
background:
radial-gradient(at 40% 20%, rgba(254, 44, 85, 0.08) 0px, transparent 50%),
radial-gradient(at 80% 0%, rgba(255, 107, 157, 0.06) 0px, transparent 50%),
radial-gradient(at 0% 50%, rgba(254, 44, 85, 0.04) 0px, transparent 50%);
}
/* Focus states */
.focus-ring:focus {
outline: none;
box-shadow: 0 0 0 4px rgba(254, 44, 85, 0.15);
}
/* Transitions */
.transition-smooth {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Text ellipsis */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
</head>
<body class="bg-douyin-gray-50 min-h-screen bg-gradient-mesh" x-data="douyinExtractor()" x-init="checkHealth()">
<!-- Top Navigation Bar -->
<nav class="sticky top-0 z-50 bg-white/80 backdrop-blur-xl border-b border-douyin-gray-100">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<div class="flex items-center gap-3">
<div class="w-9 h-9 bg-gradient-douyin rounded-xl flex items-center justify-center shadow-lg shadow-douyin-red/20">
<svg class="w-5 h-5 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.59 6.69a4.83 4.83 0 01-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 01-5.2 1.74 2.89 2.89 0 012.31-4.64 2.93 2.93 0 01.88.13V9.4a6.84 6.84 0 00-1-.05A6.33 6.33 0 005 20.1a6.34 6.34 0 0010.86-4.43v-7a8.16 8.16 0 004.77 1.52v-3.4a4.85 4.85 0 01-1-.1z"/>
</svg>
</div>
<div>
<h1 class="text-lg font-semibold text-douyin-gray-900">文案提取器</h1>
<p class="text-xs text-douyin-gray-400 -mt-0.5">Transcript Extractor</p>
</div>
</div>
<!-- API Status Badge -->
<div class="flex items-center gap-4">
<button @click="showSettings = true"
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm cursor-pointer hover:opacity-80 transition-smooth"
:class="apiKeyConfigured ? 'bg-green-50 text-green-700' : 'bg-amber-50 text-amber-700'">
<span class="relative flex h-2 w-2">
<span class="absolute inline-flex h-full w-full rounded-full opacity-75"
:class="apiKeyConfigured ? 'bg-green-400 animate-ping' : 'bg-amber-400'"></span>
<span class="relative inline-flex rounded-full h-2 w-2"
:class="apiKeyConfigured ? 'bg-green-500' : 'bg-amber-500'"></span>
</span>
<span x-text="apiKeyConfigured ? 'API 已连接' : '点击配置 API'"></span>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</button>
<!-- Help Button -->
<button @click="showHelp = !showHelp"
class="w-9 h-9 rounded-full bg-douyin-gray-100 hover:bg-douyin-gray-200 flex items-center justify-center transition-smooth">
<svg class="w-5 h-5 text-douyin-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button>
</div>
</div>
</div>
</nav>
<!-- Settings Modal (API Key Configuration) -->
<div x-show="showSettings" x-transition.opacity class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4"
@click.self="showSettings = false">
<div x-show="showSettings" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
class="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 animate-fadeIn">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-douyin rounded-xl flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
</div>
<div>
<h3 class="text-lg font-semibold text-douyin-gray-900">API 配置</h3>
<p class="text-xs text-douyin-gray-400">配置硅基流动 API Key</p>
</div>
</div>
<button @click="showSettings = false" class="w-8 h-8 rounded-full hover:bg-douyin-gray-100 flex items-center justify-center">
<svg class="w-5 h-5 text-douyin-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- API Key Input -->
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-douyin-gray-700 mb-2">API Key</label>
<div class="relative">
<input
:type="showApiKey ? 'text' : 'password'"
x-model="apiKeyInput"
placeholder="sk-xxxxxxxxxxxxxxxx"
class="w-full px-4 py-3 pr-20 text-sm border border-douyin-gray-200 rounded-xl focus-ring transition-smooth font-mono"
>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<button @click="showApiKey = !showApiKey"
class="w-8 h-8 rounded-lg hover:bg-douyin-gray-100 flex items-center justify-center transition-smooth">
<svg x-show="!showApiKey" class="w-4 h-4 text-douyin-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
<svg x-show="showApiKey" class="w-4 h-4 text-douyin-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
<button x-show="apiKeyInput"
@click="apiKeyInput = ''"
class="w-8 h-8 rounded-lg hover:bg-douyin-gray-100 flex items-center justify-center transition-smooth">
<svg class="w-4 h-4 text-douyin-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<p class="mt-2 text-xs text-douyin-gray-400">API Key 仅保存在浏览器本地,不会上传到服务器</p>
</div>
<!-- Status Indicator -->
<div class="p-4 rounded-xl" :class="apiKeyInput ? 'bg-green-50 border border-green-100' : 'bg-douyin-gray-50'">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg flex items-center justify-center"
:class="apiKeyInput ? 'bg-green-100' : 'bg-douyin-gray-200'">
<svg x-show="apiKeyInput" class="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<svg x-show="!apiKeyInput" class="w-5 h-5 text-douyin-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div>
<p class="text-sm font-medium" :class="apiKeyInput ? 'text-green-700' : 'text-douyin-gray-600'"
x-text="apiKeyInput ? 'API Key 已配置' : '未配置 API Key'"></p>
<p class="text-xs" :class="apiKeyInput ? 'text-green-600' : 'text-douyin-gray-400'"
x-text="apiKeyInput ? '可以使用文案提取功能' : '仅可获取视频信息'"></p>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3">
<a href="https://cloud.siliconflow.cn/i/TxUlXG3u" target="_blank"
class="flex-1 h-11 border border-douyin-gray-200 hover:bg-douyin-gray-50 text-douyin-gray-700 font-medium text-sm rounded-xl transition-smooth flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
获取 API Key
</a>
<button @click="saveApiKey"
class="flex-1 h-11 bg-gradient-douyin hover:opacity-90 text-white font-medium text-sm rounded-xl transition-smooth flex items-center justify-center gap-2 shadow-lg shadow-douyin-red/20">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
保存配置
</button>
</div>
<!-- Clear Button -->
<button x-show="apiKey"
@click="clearApiKey"
class="w-full h-10 text-sm text-red-500 hover:text-red-600 hover:bg-red-50 rounded-xl transition-smooth flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
清除已保存的 API Key
</button>
</div>
</div>
</div>
<!-- Help Modal -->
<div x-show="showHelp" x-transition.opacity class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4"
@click.self="showHelp = false">
<div x-show="showHelp" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
class="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 animate-fadeIn">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-douyin-gray-900">使用说明</h3>
<button @click="showHelp = false" class="w-8 h-8 rounded-full hover:bg-douyin-gray-100 flex items-center justify-center">
<svg class="w-5 h-5 text-douyin-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="space-y-4 text-sm text-douyin-gray-600">
<div class="p-4 bg-douyin-gray-50 rounded-xl">
<p class="font-medium text-douyin-gray-900 mb-2">1. 配置 API Key</p>
<p>点击顶部状态栏,在弹窗中输入 API Key</p>
</div>
<div class="p-4 bg-douyin-gray-50 rounded-xl">
<p class="font-medium text-douyin-gray-900 mb-2">2. 粘贴分享链接</p>
<p>支持各种短视频平台的分享链接</p>
</div>
<div class="p-4 bg-douyin-gray-50 rounded-xl">
<p class="font-medium text-douyin-gray-900 mb-2">3. 点击提取</p>
<p>「获取信息」无需 API,「提取文案」需要 API</p>
</div>
</div>
<a href="https://cloud.siliconflow.cn/i/TxUlXG3u" target="_blank"
class="mt-4 block w-full text-center py-2.5 bg-gradient-douyin text-white rounded-xl font-medium hover:opacity-90 transition-smooth">
获取免费 API Key
</a>
</div>
</div>
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- Left Panel - Input -->
<div class="lg:col-span-5 space-y-6">
<!-- Input Card -->
<div class="bg-white rounded-2xl shadow-card p-6">
<div class="mb-5">
<label class="block text-sm font-medium text-douyin-gray-700 mb-2">分享链接</label>
<div class="relative">
<textarea
x-model="url"
@keydown.meta.enter="extractText"
@keydown.ctrl.enter="extractText"
placeholder="粘贴分享链接或文本,例如: 7.89 02/February/2025 xxx https://v.douyin.com/xxxxx/"
rows="4"
class="w-full px-4 py-3 text-sm border border-douyin-gray-200 rounded-xl resize-none focus-ring transition-smooth placeholder:text-douyin-gray-400"
:disabled="loading"
></textarea>
<button
x-show="url"
@click="url = ''; resetState()"
class="absolute top-3 right-3 w-6 h-6 rounded-full bg-douyin-gray-100 hover:bg-douyin-gray-200 flex items-center justify-center transition-smooth">
<svg class="w-3.5 h-3.5 text-douyin-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<p class="mt-2 text-xs text-douyin-gray-400">提示:按 <kbd class="px-1.5 py-0.5 bg-douyin-gray-100 rounded text-douyin-gray-600">⌘</kbd> + <kbd class="px-1.5 py-0.5 bg-douyin-gray-100 rounded text-douyin-gray-600">Enter</kbd> 快速提取</p>
</div>
<!-- Action Buttons -->
<div class="flex gap-3">
<button
@click="extractInfo"
:disabled="!url || loading"
class="flex-1 h-11 bg-douyin-gray-100 hover:bg-douyin-gray-200 text-douyin-gray-700 font-medium text-sm rounded-xl transition-smooth disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
获取信息
</button>
<button
@click="extractText"
:disabled="!url || loading || !apiKeyConfigured"
class="flex-1 h-11 bg-gradient-douyin hover:opacity-90 text-white font-medium text-sm rounded-xl transition-smooth disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 shadow-lg shadow-douyin-red/20">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
提取文案
</button>
</div>
<!-- API Warning -->
<div x-show="!apiKeyConfigured" @click="showSettings = true"
class="mt-4 p-3 bg-amber-50 border border-amber-100 rounded-xl cursor-pointer hover:bg-amber-100 transition-smooth">
<div class="flex items-center justify-between">
<div class="flex items-start gap-2">
<svg class="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
<div class="text-xs text-amber-800">
<p class="font-medium">需要配置 API Key 才能提取文案</p>
<p class="mt-1 text-amber-600">点击此处配置 API Key</p>
</div>
</div>
<svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</div>
</div>
</div>
<!-- Video Info Card -->
<div x-show="videoInfo" x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0"
class="bg-white rounded-2xl shadow-card overflow-hidden">
<!-- Card Header -->
<div class="px-6 py-4 border-b border-douyin-gray-100 flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-8 h-8 bg-gradient-douyin rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</div>
<span class="font-semibold text-douyin-gray-900">视频信息</span>
</div>
<span class="text-xs text-douyin-gray-400 font-mono" x-text="videoInfo?.video_id"></span>
</div>
<!-- Card Body -->
<div class="p-6">
<div class="flex items-start gap-2 mb-4 group">
<h3 class="font-medium text-douyin-gray-900 flex-1" x-text="videoInfo?.title"></h3>
<button @click="copyToClipboard(videoInfo?.title)"
class="opacity-0 group-hover:opacity-100 w-8 h-8 rounded-lg bg-douyin-gray-100 hover:bg-douyin-gray-200 flex items-center justify-center flex-shrink-0 transition-smooth">
<svg class="w-4 h-4 text-douyin-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
<!-- Info Items -->
<div class="space-y-3">
<div class="flex items-center gap-3 p-3 bg-douyin-gray-50 rounded-xl group hover:bg-douyin-gray-100 transition-smooth">
<div class="w-10 h-10 bg-white rounded-lg flex items-center justify-center shadow-sm">
<svg class="w-5 h-5 text-douyin-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-xs text-douyin-gray-400 mb-0.5">视频 ID</p>
<p class="text-sm font-mono text-douyin-gray-700 truncate" x-text="videoInfo?.video_id"></p>
</div>
<button @click="copyToClipboard(videoInfo?.video_id)"
class="opacity-0 group-hover:opacity-100 w-8 h-8 rounded-lg bg-white hover:bg-douyin-gray-200 flex items-center justify-center transition-smooth">
<svg class="w-4 h-4 text-douyin-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
<a x-show="videoInfo?.download_url"
:href="`/api/video/download?url=${encodeURIComponent(videoInfo?.download_url)}&filename=${encodeURIComponent(videoInfo?.video_id || 'video')}.mp4`"
class="flex items-center gap-3 p-3 bg-green-50 rounded-xl group hover:bg-green-100 transition-smooth cursor-pointer">
<div class="w-10 h-10 bg-white rounded-lg flex items-center justify-center shadow-sm">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-xs text-green-600 mb-0.5">无水印视频</p>
<p class="text-sm font-medium text-green-700">点击下载原视频</p>
</div>
<svg class="w-5 h-5 text-green-500 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
</a>
</div>
</div>
</div>
<!-- Loading State -->
<div x-show="loading" x-transition class="bg-white rounded-2xl shadow-card p-6">
<div class="flex items-center gap-4 mb-6">
<div class="relative w-12 h-12">
<div class="absolute inset-0 rounded-full border-4 border-douyin-gray-100"></div>
<div class="absolute inset-0 rounded-full border-4 border-douyin-red border-t-transparent animate-spin-slow"></div>
</div>
<div>
<p class="font-medium text-douyin-gray-900" x-text="loadingText"></p>
<p class="text-sm text-douyin-gray-400">请稍候...</p>
</div>
</div>
<!-- Progress Steps -->
<div x-show="extractingText" class="space-y-2">
<template x-for="(stepItem, index) in steps" :key="index">
<div class="flex items-center gap-3 p-2 rounded-lg transition-smooth"
:class="step > index + 1 ? 'bg-green-50' : step === index + 1 ? 'bg-douyin-red/5' : ''">
<div class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium transition-smooth"
:class="step > index + 1 ? 'bg-green-500 text-white' : step === index + 1 ? 'bg-douyin-red text-white' : 'bg-douyin-gray-200 text-douyin-gray-500'">
<svg x-show="step > index + 1" class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
<span x-show="step <= index + 1" x-text="index + 1"></span>
</div>
<span class="text-sm" :class="step >= index + 1 ? 'text-douyin-gray-900' : 'text-douyin-gray-400'" x-text="stepItem"></span>
</div>
</template>
</div>
</div>
<!-- Error State -->
<div x-show="error" x-transition class="bg-red-50 border border-red-100 rounded-2xl p-5">
<div class="flex items-start gap-3">
<div class="w-10 h-10 bg-red-100 rounded-xl flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
</div>
<div class="flex-1">
<h4 class="font-medium text-red-800">提取失败</h4>
<p class="text-sm text-red-600 mt-1" x-text="error"></p>
</div>
<button @click="error = ''" class="w-8 h-8 rounded-lg hover:bg-red-100 flex items-center justify-center">
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Right Panel - Results -->
<div class="lg:col-span-7">
<!-- Empty State -->
<div x-show="!transcript && !loading" class="bg-white rounded-2xl shadow-card h-full min-h-[500px] flex flex-col items-center justify-center p-8 text-center">
<div class="w-24 h-24 bg-douyin-gray-100 rounded-3xl flex items-center justify-center mb-6">
<svg class="w-12 h-12 text-douyin-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-douyin-gray-900 mb-2">等待提取</h3>
<p class="text-sm text-douyin-gray-500 max-w-xs">粘贴分享链接,点击「提取文案」按钮,AI 将自动识别视频中的语音内容</p>
<!-- Feature Pills -->
<div class="flex flex-wrap justify-center gap-2 mt-6">
<span class="px-3 py-1.5 bg-douyin-gray-100 rounded-full text-xs text-douyin-gray-600">无水印下载</span>
<span class="px-3 py-1.5 bg-douyin-gray-100 rounded-full text-xs text-douyin-gray-600">AI 语音识别</span>
<span class="px-3 py-1.5 bg-douyin-gray-100 rounded-full text-xs text-douyin-gray-600">一键复制</span>
</div>
</div>
<!-- Transcript Card -->
<div x-show="transcript" x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0"
class="bg-white rounded-2xl shadow-card overflow-hidden h-full flex flex-col">
<!-- Card Header -->
<div class="px-6 py-4 border-b border-douyin-gray-100 flex items-center justify-between flex-shrink-0">
<div class="flex items-center gap-2">
<div class="w-8 h-8 bg-gradient-douyin rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<span class="font-semibold text-douyin-gray-900">提取结果</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-douyin-gray-400 mr-2" x-text="`${transcript?.length || 0} 字`"></span>
<button @click="copyTranscript"
class="h-9 px-4 bg-douyin-gray-100 hover:bg-douyin-gray-200 rounded-lg text-sm font-medium text-douyin-gray-700 transition-smooth flex items-center gap-2">
<svg x-show="!copied" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg x-show="copied" class="w-4 h-4 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
<span x-text="copied ? '已复制' : '复制'"></span>
</button>
<button @click="downloadTranscript"
class="h-9 px-4 bg-gradient-douyin hover:opacity-90 rounded-lg text-sm font-medium text-white transition-smooth flex items-center gap-2 shadow-lg shadow-douyin-red/20">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
下载
</button>
</div>
</div>
<!-- Transcript Content -->
<div class="flex-1 overflow-y-auto p-6">
<div class="prose prose-sm max-w-none">
<p class="text-douyin-gray-700 leading-relaxed whitespace-pre-wrap" x-text="transcript"></p>
</div>
</div>
<!-- Card Footer -->
<div class="px-6 py-3 bg-douyin-gray-50 border-t border-douyin-gray-100 flex items-center justify-between flex-shrink-0">
<div class="flex items-center gap-2 text-xs text-douyin-gray-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>Powered by SiliconFlow SenseVoice</span>
</div>
<div class="flex items-center gap-1 text-xs text-douyin-gray-400">
<span x-text="videoInfo?.title" class="max-w-xs truncate"></span>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Toast Notification -->
<div x-show="toast" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-4" x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0" x-transition:leave-end="opacity-0 translate-y-4"
class="fixed bottom-8 left-1/2 -translate-x-1/2 z-50">
<div class="px-4 py-3 bg-douyin-gray-900 text-white text-sm rounded-xl shadow-2xl flex items-center gap-2">
<svg class="w-4 h-4 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
<span x-text="toast"></span>
</div>
</div>
<!-- Footer -->
<footer class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-douyin-gray-400">
<p>Made with <span class="text-douyin-red">♥</span> by <a href="https://github.com/yzfly" target="_blank" class="hover:text-douyin-red transition-colors">yzfly</a></p>
<div class="flex items-center gap-4">
<a href="https://github.com/yzfly/douyin-mcp-server" target="_blank" class="hover:text-douyin-red transition-colors flex items-center gap-1">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
GitHub
</a>
<span>·</span>
<span>v1.4.0</span>
</div>
</div>
</footer>
<script>
function douyinExtractor() {
return {
url: '',
loading: false,
loadingText: '',
extractingText: false,
step: 0,
steps: ['解析分享链接', '下载视频', '提取音频', '识别语音'],
error: '',
videoInfo: null,
transcript: '',
copied: false,
toast: '',
showHelp: false,
showSettings: false,
showApiKey: false,
apiKey: '',
apiKeyInput: '',
serverApiKeyConfigured: false,
get apiKeyConfigured() {
return this.serverApiKeyConfigured || !!this.apiKey;
},
async checkHealth() {
// Load API key from localStorage
const savedKey = localStorage.getItem('douyin_api_key');
if (savedKey) {
this.apiKey = savedKey;
this.apiKeyInput = savedKey;
}
try {
const res = await fetch('/api/health');
const data = await res.json();
this.serverApiKeyConfigured = data.api_key_configured;
} catch (e) {
console.error('Health check failed:', e);
}
},
saveApiKey() {
if (this.apiKeyInput) {
this.apiKey = this.apiKeyInput;
localStorage.setItem('douyin_api_key', this.apiKeyInput);
this.showSettings = false;
this.showToast('API Key 已保存');
} else {
this.showToast('请输入 API Key');
}
},
clearApiKey() {
this.apiKey = '';
this.apiKeyInput = '';
localStorage.removeItem('douyin_api_key');
this.showToast('API Key 已清除');
},
resetState() {
this.error = '';
this.videoInfo = null;
this.transcript = '';
this.step = 0;
this.extractingText = false;
},
showToast(message) {
this.toast = message;
setTimeout(() => this.toast = '', 2000);
},
async copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
this.showToast('已复制到剪贴板');
} catch (e) {
this.error = '复制失败';
}
},
async extractInfo() {
if (!this.url || this.loading) return;
this.resetState();
this.loading = true;
this.loadingText = '正在获取视频信息...';
try {
const res = await fetch('/api/video/info', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: this.url })
});
const data = await res.json();
if (data.success) {
this.videoInfo = data;
this.showToast('视频信息获取成功');
} else {
this.error = data.error || '获取视频信息失败';
}
} catch (e) {
this.error = '网络错误,请检查网络连接后重试';
} finally {
this.loading = false;
}
},
async extractText() {
if (!this.url || this.loading) return;
// Check if API key is configured
if (!this.apiKeyConfigured) {
this.showSettings = true;
return;
}
this.resetState();
this.loading = true;
this.extractingText = true;
this.loadingText = '正在提取文案...';
// Simulate progress steps
this.step = 1;
const stepInterval = setInterval(() => {
if (this.step < 4) this.step++;
}, 2500);
try {
const res = await fetch('/api/video/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: this.url,
api_key: this.apiKey // Send local API key if available
})
});
const data = await res.json();
clearInterval(stepInterval);
this.step = 5;
if (data.success) {
this.videoInfo = {
video_id: data.video_id,
title: data.title,
download_url: data.download_url
};
this.transcript = data.text;
this.showToast('文案提取成功');
} else {
this.error = data.error || '提取文案失败';
}
} catch (e) {
clearInterval(stepInterval);
this.error = '网络错误,请检查网络连接后重试';
} finally {
this.loading = false;
}
},
async copyTranscript() {
try {
await navigator.clipboard.writeText(this.transcript);
this.copied = true;
this.showToast('文案已复制到剪贴板');
setTimeout(() => this.copied = false, 2000);
} catch (e) {
this.error = '复制失败,请手动选择文本复制';
}
},
downloadTranscript() {
const content = `# ${this.videoInfo?.title || '视频文案'}\n\n视频ID: ${this.videoInfo?.video_id || ''}\n\n---\n\n${this.transcript}`;
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${this.videoInfo?.video_id || 'transcript'}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.showToast('文案已下载');
}
}
}
</script>
</body>
</html>