11
This commit is contained in:
47
app/api/v1/quickjs_scripts.py
Normal file
47
app/api/v1/quickjs_scripts.py
Normal file
@@ -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})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import auth
|
from app.api.v1 import auth, quickjs_scripts
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(auth.router)
|
api_router.include_router(auth.router)
|
||||||
|
api_router.include_router(quickjs_scripts.router)
|
||||||
|
|||||||
378
scripts/quickjs/douyin_pipeline.js
Normal file
378
scripts/quickjs/douyin_pipeline.js
Normal file
@@ -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
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user