diff --git a/app/api/v1/quickjs_scripts.py b/app/api/v1/quickjs_scripts.py new file mode 100644 index 0000000..7c88b10 --- /dev/null +++ b/app/api/v1/quickjs_scripts.py @@ -0,0 +1,47 @@ +"""QuickJS 流水线脚本:按文件名下发源码,供桌面端拉取后在嵌入引擎中执行。""" + +import re +from pathlib import Path + +from fastapi import APIRouter + +from app.schemas.common import ApiResponse + +router = APIRouter(prefix="/quickjs-scripts", tags=["quickjs"]) + +# pythonbackend/scripts/quickjs/*.js +_SCRIPTS_DIR = Path(__file__).resolve().parents[3] / "scripts" / "quickjs" + +_SAFE_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*\.js$") + + +def _validate_name(name: str) -> str | None: + if not name or not _SAFE_NAME.fullmatch(name.strip()): + return None + return name.strip() + + +@router.get("", response_model=ApiResponse[dict]) +async def get_quickjs_script(name: str) -> ApiResponse[dict]: + safe = _validate_name(name) + if not safe: + return ApiResponse(ok=False, message="无效的脚本名称", data=None) + path = _SCRIPTS_DIR / safe + try: + base_resolved = _SCRIPTS_DIR.resolve() + resolved = path.resolve() + resolved.relative_to(base_resolved) + except (OSError, ValueError): + return ApiResponse(ok=False, message="无效的脚本路径", data=None) + if not path.is_file(): + return ApiResponse(ok=False, message=f"脚本不存在: {safe}", data=None) + source = path.read_text(encoding="utf-8") + return ApiResponse(ok=True, message="", data={"source": source}) + + +@router.get("/list", response_model=ApiResponse[dict]) +async def list_quickjs_scripts() -> ApiResponse[dict]: + if not _SCRIPTS_DIR.is_dir(): + return ApiResponse(ok=True, message="", data={"names": []}) + names = sorted(p.name for p in _SCRIPTS_DIR.glob("*.js") if p.is_file()) + return ApiResponse(ok=True, message="", data={"names": names}) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 68daf1e..5b7352d 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -1,6 +1,7 @@ from fastapi import APIRouter -from app.api.v1 import auth +from app.api.v1 import auth, quickjs_scripts api_router = APIRouter() api_router.include_router(auth.router) +api_router.include_router(quickjs_scripts.router) diff --git a/scripts/quickjs/douyin_pipeline.js b/scripts/quickjs/douyin_pipeline.js new file mode 100644 index 0000000..fa27b14 --- /dev/null +++ b/scripts/quickjs/douyin_pipeline.js @@ -0,0 +1,378 @@ +// ============================================================================ +// douyin_pipeline.js +// +// Pure-JS port of the original `VideoRewriteIntegration.processDouyinShareComplete` +// pipeline. All host capabilities (network, fs, ffmpeg, OSS, ASR, AI chat, +// log, progress) come from the `__native` global injected by the Rust side +// (see `js_runtime/native.rs`). Nothing in this file should ever depend on +// Node.js, Electron or browser globals. +// +// 由后端 `/api/v1/quickjs-scripts?name=douyin_pipeline.js` 下发;桌面端 QuickJS +// eval 后调用 `globalThis.__quickjsMain(params)`。 +// +// `params` shape mirrors the original Electron IPC payload: +// { +// shareText: string, +// modelConfig: { providerId, modelId, apiUrl, apiKey, +// asrMode: "online" | "local", +// aliyunApiKey, ossConfig, asrServerInfo }, +// rewriteConfig: object, +// customPrompt: string | null +// } +// ============================================================================ + +const log = (level, msg, fields) => __native.log(level, msg, fields || null); +const progress = (s) => { try { __native.emitProgress(s); } catch (_) { /* ignore */ } }; + +// --------------------------------------------------------------------------- +// DouyinContentParser (pure-JS, no host calls) +// --------------------------------------------------------------------------- +const DouyinContentParser = { + // Extract a sharable URL from a raw "share text". Handles: + // - direct https://... links + // - "复制此链接,打开Dou音搜索,直接观看视频!https://v.douyin.com/xxxx/" + // - bare v.douyin.com short links + parseContent(shareText) { + if (!shareText || typeof shareText !== "string") { + return { success: false, error: "分享文本为空" }; + } + const urlRegex = /https?:\/\/[^\s\u4e00-\u9fa5]+/g; + const matches = shareText.match(urlRegex); + if (!matches || matches.length === 0) { + return { success: false, error: "未在文本中识别到任何 URL" }; + } + // Prefer douyin/iesdouyin domains, fallback to first match. + const douyinUrl = + matches.find((u) => /douyin\.com|iesdouyin\.com/i.test(u)) || matches[0]; + return { success: true, content: { videoUrl: douyinUrl, raw: shareText } }; + }, + + // Whether `url` already points at an actual media file we can fetch directly. + isDirectVideoUrl(url) { + if (!url) return false; + return /\.(mp4|mov|m4v|webm|mkv|flv|avi)(\?|$)/i.test(url); + }, + + // Derive a short title (<= 20 chars) from a long description. + generateTitleFromDescription(desc) { + if (!desc) return "改写视频"; + const firstLine = String(desc).split(/[\r\n]/)[0].trim(); + const cleaned = firstLine.replace(/[#@].*$/g, "").trim(); + return (cleaned || "改写视频").slice(0, 20); + }, +}; + +// --------------------------------------------------------------------------- +// DouyinVideoDownloader +// Resolves the final media URL (following 302 redirects on the share +// domain) and saves the binary to a host-managed temp file. +// --------------------------------------------------------------------------- +class DouyinVideoDownloader { + async downloadDirectVideo(url) { + log("info", "downloader.directVideo", { url }); + const dest = __native.tempPath("video", "mp4"); + const res = await __native.httpRequest({ + method: "GET", + url, + headers: { "User-Agent": this._ua() }, + response_type: "file", + dest_path: dest, + timeout_ms: 120000, + }); + if (!res || res.status >= 400) { + return { success: false, error: `直链下载失败 status=${res && res.status}` }; + } + return { success: true, videoPath: dest }; + } + + async downloadDouyinVideo(shareUrl) { + log("info", "downloader.douyin", { shareUrl }); + // Step 1: follow redirect on v.douyin.com -> iesdouyin.com to get itemId + let landing; + try { + landing = await __native.httpRequest({ + method: "GET", + url: shareUrl, + headers: { "User-Agent": this._ua() }, + response_type: "text", + follow_redirects: true, + timeout_ms: 30000, + }); + } catch (e) { + return { success: false, error: `解析跳转失败: ${e.message || e}` }; + } + const finalUrl = (landing && landing.final_url) || shareUrl; + const itemId = + this._extract(/\/video\/(\d+)/, finalUrl) || + this._extract(/item_ids?=(\d+)/, finalUrl) || + this._extract(/modal_id=(\d+)/, finalUrl); + if (!itemId) { + return { + success: false, + error: "未能从分享链接中提取视频 itemId,可能是抖音改版", + }; + } + // Step 2: ask iesdouyin for the playable URL. + const apiUrl = + "https://www.iesdouyin.com/web/api/v2/aweme/iteminfo/?item_ids=" + itemId; + let info; + try { + info = await __native.httpRequest({ + method: "GET", + url: apiUrl, + headers: { "User-Agent": this._ua() }, + response_type: "json", + timeout_ms: 30000, + }); + } catch (e) { + return { success: false, error: `获取视频信息失败: ${e.message || e}` }; + } + const item = + info && info.body && info.body.item_list && info.body.item_list[0]; + const playAddr = + item && item.video && item.video.play_addr && item.video.play_addr.url_list; + if (!playAddr || !playAddr.length) { + return { success: false, error: "iesdouyin 接口未返回播放地址" }; + } + // Force HTTPS + replace `playwm` (watermarked) with `play` if present. + const mediaUrl = playAddr[0] + .replace(/^http:\/\//, "https://") + .replace("/playwm/", "/play/"); + return this.downloadDirectVideo(mediaUrl); + } + + _ua() { + return ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) " + + "Chrome/124.0.0.0 Safari/537.36" + ); + } + _extract(re, s) { + const m = String(s || "").match(re); + return m ? m[1] : null; + } +} + +// --------------------------------------------------------------------------- +// AI rewrite (OpenAI-compatible chat completions) +// --------------------------------------------------------------------------- +const VideoRewriteOptimizer = { + buildRewritePrompt(content, rewriteConfig, customPrompt) { + if (customPrompt && String(customPrompt).trim().length > 0) { + return String(customPrompt).replace(/\{\{\s*content\s*\}\}/g, content); + } + const cfg = rewriteConfig || {}; + const style = cfg.style || "短视频口播风格"; + const tone = cfg.tone || "自然、口语化"; + const lengthHint = cfg.length ? `控制在 ${cfg.length} 字以内` : "保持与原文相近的长度"; + return [ + "你是一名专业的短视频文案改写助手。", + `请基于下面这段视频转写文案,用【${style}】重新仿写一版。`, + `要求:语气 ${tone},${lengthHint},保留原意但避免与原文雷同;`, + "不要解释、不要加任何额外标题,直接输出仿写后的正文。", + "", + "【原文案】", + content, + ].join("\n"); + }, +}; + +async function rewriteWithAI(content, modelConfig, rewriteConfig, customPrompt) { + if (!modelConfig || !modelConfig.apiUrl || !modelConfig.apiKey) { + log("error", "rewrite.missingConfig"); + return null; + } + const prompt = VideoRewriteOptimizer.buildRewritePrompt( + content, + rewriteConfig || {}, + customPrompt + ); + const result = await __native.openaiChat({ + apiUrl: modelConfig.apiUrl, + apiKey: modelConfig.apiKey, + model: modelConfig.modelId, + messages: [ + { role: "system", content: "你是专业的短视频文案改写助手。" }, + { role: "user", content: prompt }, + ], + temperature: typeof modelConfig.temperature === "number" ? modelConfig.temperature : 0.8, + max_tokens: modelConfig.maxTokens || 1024, + }); + if (!result || !result.success) { + log("error", "rewrite.failed", { error: result && result.error }); + return null; + } + return (result.content || "").trim(); +} + +// --------------------------------------------------------------------------- +// VideoRewriteIntegration.processDouyinShareComplete +// The 1:1 port of the Electron version's pipeline. +// --------------------------------------------------------------------------- +async function processDouyinShareComplete(shareText, modelConfig, rewriteConfig, customPrompt) { + let videoPath = null; + let audioPath = null; + try { + log("info", "pipeline.start", { + inputLength: shareText && shareText.length, + modelConfig: { + providerId: modelConfig && modelConfig.providerId, + modelId: modelConfig && modelConfig.modelId, + asrMode: modelConfig && modelConfig.asrMode, + }, + }); + + // 1) parse share text -> video URL + progress("正在解析视频URL..."); + const parseResult = DouyinContentParser.parseContent(shareText); + if (!parseResult.success || !parseResult.content) { + return { success: false, error: parseResult.error || "内容解析失败" }; + } + const { videoUrl } = parseResult.content; + log("info", "pipeline.urlExtracted", { videoUrl }); + + // 2) download video + progress("正在下载视频..."); + const downloader = new DouyinVideoDownloader(); + const downloadResult = DouyinContentParser.isDirectVideoUrl(videoUrl) + ? await downloader.downloadDirectVideo(videoUrl) + : await downloader.downloadDouyinVideo(videoUrl); + if (!downloadResult.success || !downloadResult.videoPath) { + return { success: false, error: downloadResult.error || "视频下载失败" }; + } + videoPath = downloadResult.videoPath; + log("info", "pipeline.videoDownloaded", { videoPath }); + + // 3) extract audio (ffmpeg sidecar) + progress("视频下载完成,正在提取音频..."); + try { + audioPath = await __native.ffmpegExtractAudio(videoPath); + } catch (e) { + return { + success: false, + error: `音频提取失败: ${(e && e.message) || e}`, + }; + } + log("info", "pipeline.audioExtracted", { audioPath }); + + // 4) ASR + const asrMode = (modelConfig && modelConfig.asrMode) || "online"; + let recognizedText = ""; + if (asrMode === "online") { + progress("音频提取完成,正在上传到OSS..."); + const ossConfig = modelConfig.ossConfig; + if (!ossConfig || !ossConfig.bucket) { + return { success: false, error: "未配置阿里云 OSS(bucket / region / key)" }; + } + const upload = await __native.aliyunOssUpload(audioPath, ossConfig); + if (!upload || !upload.success || !upload.url) { + return { + success: false, + error: `音频上传失败: ${(upload && upload.error) || "unknown"}`, + }; + } + log("info", "pipeline.ossOk", { audioUrl: upload.url.slice(0, 100) + "..." }); + + progress("音频上传完成,正在识别文案(在线模式)..."); + const asr = await __native.aliyunAsrFiletrans(upload.url, { + apiKey: modelConfig.aliyunApiKey, + model: "qwen3-asr-flash-filetrans", + }); + if (!asr || !asr.success) { + return { + success: false, + error: `语音识别失败(在线模式): ${(asr && asr.error) || "unknown"}`, + }; + } + recognizedText = asr.text || ""; + log("info", "pipeline.asrOk", { + method: "aliyun-online", + len: recognizedText.length, + }); + } else { + progress("音频提取完成,正在识别文案(本地模式)..."); + const info = modelConfig.asrServerInfo; + if (!info) { + return { + success: false, + error: "未配置本地语音识别服务器,请在「设置」中配置", + }; + } + const asr = await __native.localAsr(audioPath, info); + if (!asr || !asr.success) { + return { + success: false, + error: `语音识别失败(本地模式): ${(asr && asr.error) || "unknown"}`, + }; + } + recognizedText = asr.text || ""; + log("info", "pipeline.asrOk", { + method: "local", + server: info.name, + len: recognizedText.length, + }); + } + + // 5) rewrite + if (!modelConfig || !modelConfig.apiUrl || !modelConfig.apiKey) { + return { success: false, error: "未配置AI模型,请先在设置中配置" }; + } + progress("文案识别完成,正在改写..."); + const rewrittenDescription = await rewriteWithAI( + recognizedText, + modelConfig, + rewriteConfig, + customPrompt + ); + if (!rewrittenDescription) { + return { success: false, error: "AI仿写失败,请检查模型配置和网络连接" }; + } + const rewrittenTitle = DouyinContentParser.generateTitleFromDescription(rewrittenDescription); + + log("info", "pipeline.success", { + originalLength: recognizedText.length, + rewrittenLength: rewrittenDescription.length, + }); + progress("完成!"); + + return { + success: true, + original: { + videoUrl, + description: recognizedText, + hashtags: [], + title: "视频音频识别结果", + }, + rewritten: { + description: rewrittenDescription, + title: rewrittenTitle, + fullContent: rewrittenDescription, + }, + }; + } catch (e) { + log("error", "pipeline.error", { message: (e && e.message) || String(e) }); + return { success: false, error: (e && e.message) || String(e) }; + } finally { + // 6) cleanup local temp files + if (videoPath) { + try { __native.deleteFile(videoPath); } catch (_) {} + } + if (audioPath) { + try { __native.deleteFile(audioPath); } catch (_) {} + } + } +} + +// --------------------------------------------------------------------------- +// 单一入口:Rust eval 本脚本后调用 globalThis.__quickjsMain(params) +// --------------------------------------------------------------------------- +globalThis.__quickjsMain = function (params) { + const p = params || {}; + return processDouyinShareComplete( + p.shareText, + p.modelConfig, + p.rewriteConfig, + p.customPrompt || null + ); +};