12 KiB
🔧 文案提取助手修复 — 抖音链接无法提取文案 (Day 25)
概述
文案提取助手粘贴抖音链接后无法提取文案,yt-dlp 报错 Fresh cookies are needed,手动回退方案也因抖音页面结构变化失效。本日完成了完整修复,并清理了不再需要的 DOUYIN_COOKIE 配置。
🐛 问题诊断
错误链路
- yt-dlp 失败:
ERROR: [Douyin] Fresh cookies (not necessarily logged in) are needed- yt-dlp 版本
2025.12.08过旧 - 抖音 API
aweme/v1/web/aweme/detail/需要签名 cookie(s_v_web_id等),即使升级 yt-dlp 到最新版 + 传入 cookie 仍无法解决,属 yt-dlp 已知问题
- yt-dlp 版本
- 手动回退失败:
Could not find RENDER_DATA in page- 旧方案通过桌面端用户主页 +
modal_id访问,抖音 SSR 已不再返回videoDetail数据
- 旧方案通过桌面端用户主页 +
.env中DOUYIN_COOKIE:时间戳 2024 年 12 月,早已过期
✅ 修复方案:移动端分享页 + 自动获取 ttwid
核心思路
放弃依赖 yt-dlp 下载抖音视频和手动维护 cookie,改为:
- 自动从 ByteDance 公共 API 获取新鲜
ttwid(匿名令牌,不绑定账号) - 用
ttwid访问移动端分享页m.douyin.com/share/video/{id} - 从页面内嵌 JSON 中提取
play_addr播放地址并下载
关键代码(_download_douyin_manual 重写)
# 1. 获取新鲜 ttwid
ttwid_resp = await client.post(
"https://ttwid.bytedance.com/ttwid/union/register/",
json={"region": "cn", "aid": 6383, "service": "www.douyin.com", ...}
)
ttwid = ttwid_resp.cookies.get("ttwid", "")
# 2. 访问移动端分享页
page_resp = await client.get(
f"https://m.douyin.com/share/video/{video_id}",
headers={"cookie": f"ttwid={ttwid}", ...}
)
# 3. 提取 play_addr
addr_match = re.search(r'"play_addr":\{"uri":"([^"]+)","url_list":\["([^"]+)"', page_text)
video_url = addr_match.group(2).replace(r"\u002F", "/")
优势
- 不再依赖手动维护的
DOUYIN_COOKIE,ttwid 每次请求自动获取 - 不受 yt-dlp 对抖音支持状况影响
- 所有用户通用,不绑定特定账号
🧹 清理 DOUYIN_COOKIE 配置
DOUYIN_COOKIE 仅用于文案提取,新方案不再需要,已从以下位置删除:
| 文件 | 变更 |
|---|---|
backend/.env |
删除 DOUYIN_COOKIE 配置项及注释 |
backend/app/core/config.py |
删除 DOUYIN_COOKIE: str = "" 字段定义 |
backend/app/modules/tools/service.py |
删除 yt-dlp 传 cookie 逻辑和 _write_netscape_cookies 辅助函数 |
🔤 前端文案修正
将文案提取界面中的"AI 洗稿结果"改为"AI 改写结果"。
| 文件 | 变更 |
|---|---|
frontend/src/features/home/ui/ScriptExtractionModal.tsx |
AI 洗稿结果 → AI 改写结果 |
backend/app/modules/tools/service.py |
注释中"洗稿"→"改写" |
backend/app/services/glm_service.py |
docstring 中"洗稿"→"改写文案" |
📦 其他变更
- yt-dlp 升级:
2025.12.08→2026.2.21 - yt-dlp 初始化修正:改为
YoutubeDL(ydl_opts)直接传参初始化(原先空初始化后 update params 不生效) - User-Agent 更新:yt-dlp 中
Chrome/91→Chrome/131
涉及文件汇总
后端修改
| 文件 | 变更 |
|---|---|
backend/app/modules/tools/service.py |
重写 _download_douyin_manual(移动端分享页方案);修正 yt-dlp 初始化;清理 cookie 相关代码;注释改写 |
backend/app/services/glm_service.py |
docstring "洗稿" → "改写文案" |
backend/app/core/config.py |
删除 DOUYIN_COOKIE 字段 |
backend/.env |
删除 DOUYIN_COOKIE 配置 |
backend/requirements.txt |
yt-dlp 版本升级 |
前端修改
| 文件 | 变更 |
|---|---|
frontend/src/features/home/ui/ScriptExtractionModal.tsx |
"AI 洗稿结果" → "AI 改写结果" |
✏️ AI 智能改写 — 自定义提示词功能
概述
文案提取助手的"AI 智能改写"原先使用硬编码 prompt,用户无法定制改写风格。本次在 checkbox 右侧新增"自定义提示词"折叠区域,用户可编辑自定义 prompt,持久化到 localStorage,后端按需替换默认 prompt。
后端修改
路由层 (router.py):extract_script_tool 新增可选 Form 参数 custom_prompt: Optional[str] = Form(None),透传给 service。
服务层 (service.py):extract_script() 签名新增 custom_prompt,透传给 glm_service.rewrite_script(script, custom_prompt)。
AI 层 (glm_service.py):rewrite_script(self, text, custom_prompt=None),若 custom_prompt 有值则用自定义 prompt + 原文拼接,否则保持原有默认 prompt。
if custom_prompt and custom_prompt.strip():
prompt = f"""{custom_prompt.strip()}
原始文案:
{text}"""
else:
prompt = f"""请将以下视频文案进行改写。...(原有默认)"""
前端修改
Hook (useScriptExtraction.ts):
- 新增
customPrompt/showCustomPrompt状态 - 初始值从
localStorage.getItem("vigent_rewriteCustomPrompt")恢复 customPrompt变化时防抖 300ms 保存到 localStoragehandleExtract()中若doRewrite && customPrompt.trim()有值,追加formData.append("custom_prompt", ...)- modal 重置时不清空 customPrompt(持久化偏好)
UI (ScriptExtractionModal.tsx):
- checkbox 同行右侧新增"自定义提示词 ▼"按钮(仅
doRewrite时显示) - 点击展开 textarea 编辑区域,底部提示"留空则使用默认提示词"
- 取消勾选 AI 智能改写时,自定义提示词区域自动隐藏
涉及文件
| 文件 | 变更 |
|---|---|
backend/app/modules/tools/router.py |
新增 custom_prompt Form 参数 |
backend/app/modules/tools/service.py |
extract_script() 透传 custom_prompt |
backend/app/services/glm_service.py |
rewrite_script() 支持自定义 prompt |
frontend/.../useScriptExtraction.ts |
新增状态、localStorage 持久化、FormData 传参 |
frontend/.../ScriptExtractionModal.tsx |
UI 按钮 + 展开 textarea |
验证
- 后端
python -m py_compile三个文件通过 - 前端
npx tsc --noEmit通过
🐛 SSR 构建修复 — localStorage is not defined
问题
npm run build 报错 ReferenceError: localStorage is not defined,因为 useScriptExtraction.ts 中 useState 的初始化函数在 SSR(Node.js)环境下也会执行,而服务端没有 localStorage。
修复
useState 初始化加 typeof window !== "undefined" 守卫:
const [customPrompt, setCustomPrompt] = useState(
() => typeof window !== "undefined" ? localStorage.getItem(CUSTOM_PROMPT_KEY) || "" : ""
);
| 文件 | 变更 |
|---|---|
frontend/.../useScriptExtraction.ts |
useState 初始化增加 SSR 安全守卫 |
🎬 片头副标题功能
概述
新增片头副标题(secondary_title),显示在主标题下方,用于补充说明或悬念引导。副标题有独立的样式配置(字体、字号、颜色等),可由 AI 同时生成,20 字限制,仅在视频画面中显示,不参与发布标题。
命名约定:后端 secondary_title(snake_case),前端 videoSecondaryTitle(camelCase),用户界面"片头副标题"。
后端修改
| 文件 | 变更 |
|---|---|
backend/app/modules/videos/schemas.py |
GenerateRequest 新增 4 个可选字段:secondary_title、secondary_title_style_id、secondary_title_font_size、secondary_title_top_margin |
backend/app/services/glm_service.py |
AI prompt 增加副标题生成要求(不超过20字),JSON 格式新增 secondary_title 字段 |
backend/app/modules/ai/router.py |
GenerateMetaResponse 增加 secondary_title: str = "",endpoint 返回时取 result.get("secondary_title", "") |
backend/app/modules/videos/workflow.py |
use_remotion 条件增加 or req.secondary_title;副标题样式解析复用 get_style("title", ...);字号/间距覆盖;prepare_style_for_remotion 处理副标题字体;remotion_service.render() 传入 secondary_title + secondary_title_style |
backend/app/services/remotion_service.py |
render() 新增 secondary_title 和 secondary_title_style 参数,构建 CLI 参数 --secondaryTitle 和 --secondaryTitleStyle |
Remotion 修改
| 文件 | 变更 |
|---|---|
remotion/render.ts |
RenderOptions 新增 secondaryTitle? + secondaryTitleStyle?;parseArgs() 新增 switch case;inputProps 新增两个字段 |
remotion/src/components/Title.tsx |
TitleProps 新增 secondaryTitle? 和 secondaryTitleStyle?;AbsoluteFill 改为 flexDirection: 'column' 垂直堆叠;主标题 <h1> 后增加副标题 <h2>,独立样式(默认字号 48px、字重 700),共享淡入淡出动画;副标题字体使用独立 @font-face(SecondaryTitleFont)避免与主标题冲突 |
remotion/src/Video.tsx |
VideoProps 新增 secondaryTitle? + secondaryTitleStyle?;传递给 <Title> 组件;渲染条件改为 {(title || secondaryTitle) && ...} |
remotion/src/Root.tsx |
defaultProps 新增 secondaryTitle: undefined + secondaryTitleStyle: undefined |
前端修改
| 文件 | 变更 |
|---|---|
frontend/src/shared/lib/title.ts |
新增 SECONDARY_TITLE_MAX_LENGTH = 20 和 clampSecondaryTitle() |
frontend/src/features/home/model/useHomeController.ts |
新增状态 videoSecondaryTitle、selectedSecondaryTitleStyleId、secondaryTitleFontSize、secondaryTitleTopMargin、secondaryTitleSizeLocked;新建 secondaryTitleInput = useTitleInput({ maxLength: 20 })(不 sync 到发布页);handleGenerateMeta() 接收并填充 secondary_title;handleGenerate() 构建 payload 增加副标题字段;return 暴露所有新状态 |
frontend/src/features/home/model/useHomePersistence.ts |
新增 localStorage key:secondaryTitle、secondaryTitleStyle、secondaryTitleFontSize、secondaryTitleTopMargin;对应的恢复和保存 effect |
frontend/src/features/home/ui/TitleSubtitlePanel.tsx |
Props 新增副标题相关;主标题输入框下方添加"片头副标题(限制20个字)"输入框;副标题样式选择器(复用 titleStyles 预设)、字号滑块(30-100px)、间距滑块(0-100px) |
frontend/src/features/home/ui/FloatingStylePreview.tsx |
标题预览改为 flex column 布局;主标题下方增加副标题预览行,独立样式渲染 |
frontend/src/features/home/ui/HomePage.tsx |
从 useHomeController 解构新状态,传给 TitleSubtitlePanel |
🐛 参考音频上传 — 中文文件名 InvalidKey 修复
问题
上传中文名参考音频(如"我的声音.wav")时,Supabase Storage 报 InvalidKey,因为存储路径直接使用了原始中文文件名。
修复
在 ref_audios/service.py 新增 sanitize_filename() 函数,将存储路径的文件名清洗为 ASCII 安全字符(仅 A-Za-z0-9._-):
- NFKD 规范化 → 丢弃非 ASCII → 非法字符替换为
_ - 纯中文/emoji 清洗后为空时,使用 MD5 哈希兜底(如
audio_e924b1193007) - 文件名限长 50 字符
- 原始中文文件名保留在 metadata 中作为展示名,前端显示不受影响
修复前: cbbe.../1771915755_我的声音.wav → InvalidKey
修复后: cbbe.../1771915755_audio_xxxxxxxx.wav → 上传成功
| 文件 | 变更 |
|---|---|
backend/app/modules/ref_audios/service.py |
新增 sanitize_filename() 函数,上传路径使用清洗后文件名 |