Files
yaoyaoai/scripts/quickjs/douyin_pipeline.js.bakcup
fengchuanhn@gmail.com 3db327d93b 22
2026-05-17 12:54:13 +08:00

379 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================================
// 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: "未配置阿里云 OSSbucket / 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
);
};