This commit is contained in:
fengchuanhn@gmail.com
2026-05-17 12:54:13 +08:00
parent 1b8ca10ec9
commit 3db327d93b
38 changed files with 2820 additions and 14 deletions

View File

@@ -0,0 +1,129 @@
"use strict";
/**
* 从进程环境变量读取 desktop_configsRust 注入 AICLIENT_CFG_*)。
* 库表 name 经规范化后作为 env 后缀,如 name=LLM_API_KEY → AICLIENT_CFG_LLM_API_KEY
*/
const ENV_PREFIX = "AICLIENT_CFG_";
function normalizeKey(name) {
return String(name || "")
.trim()
.replace(/[^a-zA-Z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.toUpperCase();
}
/** @returns {Record<string, string>} 逻辑键(大写+下划线)→ 值 */
function readAllFromEnv() {
const out = {};
for (const [ek, ev] of Object.entries(process.env)) {
if (!ek.startsWith(ENV_PREFIX) || ev == null || ev === "") continue;
const logical = ek.slice(ENV_PREFIX.length);
out[logical] = ev;
}
return out;
}
function get(logicalKey, fallback = "") {
const k = normalizeKey(logicalKey);
return process.env[ENV_PREFIX + k] || fallback;
}
function parseJson(val, fallback = null) {
if (!val) return fallback;
try {
return JSON.parse(val);
} catch {
return fallback;
}
}
/**
* 将扁平配置组装为流水线使用的 modelConfig兼容现有脚本字段名
* 可在库表增加任意键;复杂对象用 JSON 字符串存于 OSS_CONFIG、ASR_SERVER_INFO 等。
*/
function buildModelConfig() {
const c = readAllFromEnv();
const pick = (...keys) => {
for (const k of keys) {
const v = c[normalizeKey(k)];
if (v) return v;
}
return "";
};
let apiUrl = pick("LLM_API_URL", "API_URL");
if (apiUrl) {
apiUrl = apiUrl.replace(/\/$/, "");
if (!apiUrl.includes("/chat/completions")) {
apiUrl = apiUrl.endsWith("/v1")
? `${apiUrl}/chat/completions`
: `${apiUrl}/v1/chat/completions`;
}
}
const asrMode = pick("ASR_MODE", "asr_mode") || "online";
let ossConfig = parseJson(pick("OSS_CONFIG"));
if (!ossConfig && pick("OSS_ACCESS_KEY_ID")) {
ossConfig = {
accessKeyId: pick("OSS_ACCESS_KEY_ID"),
accessKeySecret: pick("OSS_ACCESS_KEY_SECRET"),
bucket: pick("OSS_BUCKET"),
region: pick("OSS_REGION"),
endpoint: pick("OSS_ENDPOINT") || undefined,
};
}
let asrServerInfo = parseJson(pick("ASR_SERVER_INFO"));
if (!asrServerInfo && pick("ASR_SERVER_URL")) {
asrServerInfo = {
name: pick("ASR_SERVER_NAME", "local") || "local",
baseUrl: pick("ASR_SERVER_URL").replace(/\/$/, ""),
asrPath: pick("ASR_SERVER_ASR_PATH", "/asr") || "/asr",
};
}
return {
providerId: pick("LLM_PROVIDER_ID", "PROVIDER_ID") || "openai",
modelId: pick("LLM_MODEL_ID", "MODEL_ID"),
apiUrl,
apiKey: pick("LLM_API_KEY", "API_KEY"),
type: pick("LLM_TYPE", "TYPE") || "openai",
asrMode,
aliyunApiKey: pick("DASHSCOPE_API_KEY", "ALIYUN_API_KEY"),
ossConfig: ossConfig || undefined,
asrServerInfo: asrServerInfo || undefined,
};
}
function validateModelConfig(modelConfig) {
if (!modelConfig.apiKey) {
return { ok: false, error: "缺少配置 LLM_API_KEYdesktop_configs 表)" };
}
if (!modelConfig.apiUrl) {
return { ok: false, error: "缺少配置 LLM_API_URL" };
}
if (modelConfig.asrMode === "online") {
if (!modelConfig.aliyunApiKey) {
return { ok: false, error: "在线 ASR 需配置 DASHSCOPE_API_KEY" };
}
if (!modelConfig.ossConfig?.bucket) {
return { ok: false, error: "在线 ASR 需配置 OSS或 OSS_CONFIG JSON" };
}
}
if (modelConfig.asrMode === "local" && !modelConfig.asrServerInfo) {
return { ok: false, error: "本地 ASR 需配置 ASR_SERVER_URL 或 ASR_SERVER_INFO" };
}
return { ok: true };
}
module.exports = {
ENV_PREFIX,
readAllFromEnv,
get,
buildModelConfig,
validateModelConfig,
};

View File

@@ -0,0 +1,70 @@
"use strict";
/** 与 zhenqianba Electron 版 COMMON_BROWSER_ARGS / STEALTH 对齐 */
const COMMON_BROWSER_ARGS = [
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-infobars",
"--no-first-run",
"--no-default-browser-check",
"--disable-breakpad",
"--disable-component-update",
"--disable-sync",
"--disable-default-apps",
"--disable-popup-blocking",
"--disable-background-networking",
"--disable-background-timer-throttling",
"--disable-renderer-backgrounding",
"--disable-backgrounding-occluded-windows",
"--enable-gpu",
"--enable-webgl",
"--ignore-gpu-blacklist",
"--enable-accelerated-video-decode",
"--enable-gpu-rasterization",
"--enable-proprietary-codecs",
"--autoplay-policy=no-user-gesture-required",
"--disable-features=IsolateOrigins,site-per-process",
];
const BROWSER_IGNORE_DEFAULT_ARGS = ["--enable-automation"];
const STEALTH_INIT_SCRIPT = `
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined,
configurable: true
});
window.chrome = {
runtime: {
connect: function() { return { onDisconnect: { addListener: function() {} }, onMessage: { addListener: function() {} }, postMessage: function() {}, disconnect: function() {} }; },
sendMessage: function() {},
id: undefined
},
loadTimes: function() { return { firstPaintTime: 0, startLoadTime: 0, commitLoadTime: 0, finishDocumentLoadTime: 0, finishLoadTime: 0, firstPaintAfterLoadTime: 0, navigationType: 'Other', requestTime: Date.now() / 1000 }; },
csi: function() { return { startE: Date.now(), onloadT: Date.now(), pageT: Math.random() * 1000 + 500, tran: 15 }; },
app: { isInstalled: false }
};
Object.defineProperty(navigator, 'plugins', {
get: () => [{ name: 'Chrome PDF Plugin', description: 'Portable Document Format', filename: 'internal-pdf-viewer', length: 1 }],
configurable: true
});
Object.defineProperty(navigator, 'languages', {
get: () => ['zh-CN', 'zh', 'en'],
configurable: true
});
delete window.__playwright;
delete window.__pw_manual;
delete window.webdriver;
`;
const DEFAULT_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
module.exports = {
COMMON_BROWSER_ARGS,
BROWSER_IGNORE_DEFAULT_ARGS,
STEALTH_INIT_SCRIPT,
DEFAULT_USER_AGENT,
};

View File

@@ -0,0 +1,216 @@
"use strict";
/**
* Puppeteer 浏览器单例(参考 zhenqianba DouyinBrowserManager / Playwright 实现)
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const {
COMMON_BROWSER_ARGS,
BROWSER_IGNORE_DEFAULT_ARGS,
STEALTH_INIT_SCRIPT,
DEFAULT_USER_AGENT,
} = require("./douyin_browser_constants.js");
function getRuntimeDataPath(...parts) {
const base =
process.env.AICLIENT_DATA_DIR ||
path.join(os.homedir(), ".aiclient");
return path.join(base, ...parts);
}
function getChromiumExecutablePath() {
const fromEnv =
process.env.AICLIENT_CHROMIUM_PATH ||
process.env.PUPPETEER_EXECUTABLE_PATH;
if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
const candidates = [];
if (process.platform === "win32") {
const pf = process.env["ProgramFiles"] || "C:\\Program Files";
const pfx86 = process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)";
const local = process.env.LOCALAPPDATA || "";
candidates.push(
path.join(pf, "Google", "Chrome", "Application", "chrome.exe"),
path.join(pfx86, "Google", "Chrome", "Application", "chrome.exe"),
path.join(local, "Google", "Chrome", "Application", "chrome.exe"),
path.join(pf, "Microsoft", "Edge", "Application", "msedge.exe"),
path.join(pfx86, "Microsoft", "Edge", "Application", "msedge.exe")
);
} else if (process.platform === "darwin") {
candidates.push(
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
);
} else {
candidates.push(
"/usr/bin/google-chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser"
);
}
return candidates.find((p) => p && fs.existsSync(p));
}
function loadPuppeteer() {
try {
return require("puppeteer-core");
} catch (_) {
return require("puppeteer");
}
}
function isHeadless() {
if (process.env.AICLIENT_PUPPETEER_HEADLESS === "0") return false;
if (process.env.AICLIENT_PUPPETEER_HEADLESS === "false") return false;
return "new";
}
class DouyinBrowserManager {
constructor() {
this.browser = null;
this.isInitializing = false;
this.userDataPath = getRuntimeDataPath("browser-data", "douyin");
}
static getInstance() {
if (!DouyinBrowserManager.instance) {
DouyinBrowserManager.instance = new DouyinBrowserManager();
}
return DouyinBrowserManager.instance;
}
cleanupBrowserLocks(userDataPath) {
const lockFiles = [
"SingletonLock",
"SingletonSocket",
"SingletonCookie",
"Lock",
];
for (const lockFile of lockFiles) {
const lockPath = path.join(userDataPath, lockFile);
try {
if (fs.existsSync(lockPath)) {
fs.unlinkSync(lockPath);
console.log("已清理锁文件:", lockFile);
}
} catch (e) {
console.log("清理锁文件失败(忽略):", lockFile, e.message);
}
}
}
async launchBrowser(userDataPath) {
const puppeteer = loadPuppeteer();
if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, { recursive: true });
}
const executablePath = getChromiumExecutablePath();
if (!executablePath) {
throw new Error(
"未找到 Chromium/Chrome。请安装 Chrome/Edge或设置 AICLIENT_CHROMIUM_PATH并在 src-tauri/resources/nodejs 执行 npm install"
);
}
console.log("使用浏览器:", executablePath);
this.browser = await puppeteer.launch({
executablePath,
headless: isHeadless(),
userDataDir: userDataPath,
args: COMMON_BROWSER_ARGS,
ignoreDefaultArgs: BROWSER_IGNORE_DEFAULT_ARGS,
defaultViewport: { width: 1920, height: 1080 },
});
console.log("浏览器实例初始化成功");
this.browser.on("disconnected", () => {
console.log("浏览器已断开连接");
this.browser = null;
});
}
async initBrowser() {
if (this.isInitializing) {
while (this.isInitializing) {
await new Promise((r) => setTimeout(r, 500));
}
return;
}
if (this.browser && this.browser.isConnected()) {
return;
}
this.isInitializing = true;
try {
const executablePath = getChromiumExecutablePath();
if (executablePath && !fs.existsSync(executablePath)) {
console.warn("Chromium 路径不存在,将使用 Puppeteer 自带浏览器:", executablePath);
}
try {
console.log("[阶段1] 使用用户数据目录:", this.userDataPath);
await this.launchBrowser(this.userDataPath);
return;
} catch (error1) {
console.error("[阶段1] 启动失败:", error1.message);
try {
console.log("[阶段2] 清理锁文件后重试...");
this.cleanupBrowserLocks(this.userDataPath);
await this.launchBrowser(this.userDataPath);
return;
} catch (error2) {
console.error("[阶段2] 启动失败:", error2.message);
const tempDataPath = getRuntimeDataPath(
"temp",
`douyin-browser-${Date.now()}`
);
console.log("[阶段3] 使用临时目录:", tempDataPath);
fs.mkdirSync(tempDataPath, { recursive: true });
await this.launchBrowser(tempDataPath);
}
}
} finally {
this.isInitializing = false;
}
}
async newPage() {
if (!this.browser || !this.browser.isConnected()) {
await this.initBrowser();
}
const page = await this.browser.newPage();
await page.setUserAgent(DEFAULT_USER_AGENT);
await page.evaluateOnNewDocument(STEALTH_INIT_SCRIPT);
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(60000);
return page;
}
async close() {
try {
if (this.browser) {
await this.browser.close();
this.browser = null;
console.log("浏览器管理器已关闭");
}
} catch (error) {
console.error("关闭浏览器管理器失败:", error);
}
}
isInitialized() {
return this.browser != null && this.browser.isConnected();
}
getBrowserInfo() {
return { browser: this.browser };
}
static async cleanup() {
if (DouyinBrowserManager.instance) {
await DouyinBrowserManager.instance.close();
DouyinBrowserManager.instance = null;
}
}
}
module.exports = { DouyinBrowserManager, getRuntimeDataPath };

View File

@@ -0,0 +1,147 @@
"use strict";
/**
* 从混杂文本中提取抖音分享内容(链接、文案、话题)。
* 自 zhenqianba DouyinContentParser 移植CommonJS 导出供 require。
*/
const DIRECT_VIDEO_PATTERN =
/https?:\/\/[^\s"'<>]+?\.(mp4|mov|m4v|webm|avi|mkv)(\?[^\s"'<>]*)?/i;
class DouyinContentParser {
/**
* @param {string} input
* @returns {{ success: true, content: object } | { success: false, error: string }}
*/
static parseContent(input) {
try {
const raw = String(input || "").trim();
if (!raw) {
return { success: false, error: "输入内容为空" };
}
const videoUrl = this.extractVideoUrl(raw);
if (!videoUrl) {
return { success: false, error: "未找到有效的抖音视频链接" };
}
const description = this.extractDescription(raw, videoUrl);
const hashtags = this.extractHashtags(raw);
return {
success: true,
content: {
videoUrl,
description,
hashtags,
rawText: raw,
},
};
} catch (error) {
return {
success: false,
error: `解析失败: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
static extractVideoUrl(text) {
const directVideoMatch = text.match(DIRECT_VIDEO_PATTERN);
if (directVideoMatch && directVideoMatch[0]) {
return directVideoMatch[0];
}
const patterns = [
/https:\/\/v\.douyin\.com\/[\w_\-@]+\/?/i,
/https:\/\/(?:www\.)?douyin\.com\/(?:video|modal)\/\d+/i,
/https:\/\/iesdouyin\.com\/(?:share\/video|video)\/\d+/i,
/v\.douyin\.com\/[\w_\-@]+/i,
/douyin\.com\/(?:video|modal)\/\d+/i,
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (match) {
let url = match[0];
if (!url.startsWith("http")) {
url = "https://" + url;
}
return url;
}
}
return null;
}
static isDirectVideoUrl(url) {
return DIRECT_VIDEO_PATTERN.test(String(url || "").trim());
}
static extractDescription(text, videoUrl) {
const urlIndex = text.indexOf(videoUrl);
let descriptionText = "";
if (urlIndex > 0) {
descriptionText = text.substring(0, urlIndex).trim();
} else {
const descriptionMatch = text.match(/^([^h]*?)(?:https?:\/\/|$)/);
if (descriptionMatch && descriptionMatch[1]) {
descriptionText = descriptionMatch[1].trim();
}
}
return this.cleanDescription(descriptionText);
}
static cleanDescription(text) {
if (!text) return "";
let cleaned = text.replace(/^[\d\s./]+/, "").trim();
const startMatch = cleaned.match(/^[^\u4e00-\u9fff\w\s]*(.*)$/);
if (startMatch && startMatch[1]) {
const potential = startMatch[1].trim();
if (potential.length > 0) {
cleaned = potential;
}
}
if (/^[\W_]+$/.test(cleaned)) {
return "";
}
return cleaned;
}
static extractHashtags(text) {
const hashtagPattern = /#[\w\u4e00-\u9fff_]+/g;
const matches = text.match(hashtagPattern);
if (!matches) return [];
return Array.from(new Set(matches));
}
static extractVideoId(videoUrl) {
const shortMatch = videoUrl.match(/v\.douyin\.com\/([\w_\-@]+)/i);
if (shortMatch) return shortMatch[1];
const standardMatch = videoUrl.match(/\/(?:video|modal)\/(\d+)/i);
if (standardMatch) return standardMatch[1];
return null;
}
static generateTitleFromDescription(description, maxLength = 30) {
if (!description) {
return "分享精彩内容";
}
const sentenceEnd = description.match(/[。!?\n]/);
let title = "";
if (sentenceEnd) {
title = description.substring(0, sentenceEnd.index).trim();
} else {
title = description;
}
if (title.length > maxLength) {
title = title.substring(0, maxLength) + "...";
}
return title || "精彩分享";
}
static quickExtractUrl(text) {
const r = this.parseContent(text);
return r.success && r.content ? r.content.videoUrl : null;
}
static quickExtractDescription(text) {
const r = this.parseContent(text);
return r.success && r.content ? r.content.description : null;
}
}
module.exports = { DouyinContentParser, DIRECT_VIDEO_PATTERN };

View File

@@ -0,0 +1,225 @@
"use strict";
/**
* douyin_pipeline.js — Node 完整仿写流水线
* 对齐 quickjs/douyin_pipeline.js 与 Electron VideoRewriteIntegration.processDouyinShareComplete
*
* 由后端 `/api/v1/nodejs-scripts` 下发Tauri 调用 run_nodejs_script → __nodejsMain(params)
*/
const { DouyinContentParser } = require("./douyin_content_parser.js");
const { DouyinVideoDownloader } = require("./douyin_video_downloader.js");
const { VideoRewriteOptimizer } = require("./video_rewrite_optimizer.js");
const pipelineNative = require("./pipeline_native.js");
const desktopConfig = require("./desktop_config.js");
const log = (level, msg, fields) =>
globalThis.__native.log(level, msg, fields == null ? null : fields);
const progress = (s) => {
try {
globalThis.__native.emitProgress(s);
} catch (_) {
/* ignore */
}
};
async function rewriteWithAI(content, modelConfig, rewriteConfig, customPrompt) {
if (!modelConfig?.apiUrl || !modelConfig?.apiKey) {
log("error", "rewrite.missingConfig");
return null;
}
const prompt = VideoRewriteOptimizer.buildRewritePrompt(
content,
rewriteConfig || {},
customPrompt
);
const result = await pipelineNative.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 || 2000,
});
if (!result?.success) {
log("error", "rewrite.failed", { error: result?.error });
return null;
}
return result.content;
}
/**
* 完整流程:解析 → Puppeteer/直链下载 → ffmpeg 提音频 → ASR → AI 仿写
*/
async function processDouyinShareComplete(
shareText,
modelConfig,
rewriteConfig,
customPrompt
) {
let videoPath = null;
let audioPath = null;
try {
log("info", "pipeline.start", {
inputLength: shareText?.length,
asrMode: modelConfig?.asrMode,
});
progress("正在解析视频URL...");
const parseResult = DouyinContentParser.parseContent(shareText);
if (!parseResult.success || !parseResult.content) {
return { success: false, error: parseResult.error || "内容解析失败" };
}
const { videoUrl, hashtags } = parseResult.content;
log("info", "pipeline.urlExtracted", { videoUrl });
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 });
progress("视频下载完成,正在提取音频...");
try {
audioPath = await pipelineNative.ffmpegExtractAudio(videoPath);
} catch (e) {
return {
success: false,
error: `音频提取失败: ${e.message || e}`,
};
}
log("info", "pipeline.audioExtracted", { audioPath });
const asrMode = modelConfig?.asrMode || "online";
let recognizedText = "";
if (asrMode === "online") {
progress("音频提取完成正在上传到OSS...");
const ossConfig = modelConfig.ossConfig;
if (!ossConfig?.bucket) {
return { success: false, error: "未配置阿里云 OSSbucket / region / key" };
}
const upload = await pipelineNative.aliyunOssUpload(audioPath, ossConfig);
if (!upload?.success || !upload.url) {
return {
success: false,
error: `音频上传失败: ${upload?.error || "unknown"}`,
};
}
log("info", "pipeline.ossOk", { audioUrl: upload.url.slice(0, 80) });
progress("音频上传完成,正在识别文案(在线模式)...");
const asr = await pipelineNative.aliyunAsrFiletrans(upload.url, {
apiKey: modelConfig.aliyunApiKey,
model: "qwen3-asr-flash-filetrans",
});
if (!asr?.success) {
return {
success: false,
error: `语音识别失败(在线模式): ${asr?.error || "unknown"}`,
};
}
recognizedText = asr.text || "";
} else {
progress("音频提取完成,正在识别文案(本地模式)...");
const info = modelConfig.asrServerInfo;
if (!info) {
return {
success: false,
error: "未配置本地语音识别服务器,请在 .env 中配置 VITE_ASR_SERVER_URL",
};
}
const asr = await pipelineNative.localAsr(audioPath, info);
if (!asr?.success) {
return {
success: false,
error: `语音识别失败(本地模式): ${asr?.error || "unknown"}`,
};
}
recognizedText = asr.text || "";
}
if (!modelConfig?.apiUrl || !modelConfig?.apiKey) {
return { success: false, error: "未配置AI模型请先在 .env 中配置" };
}
progress("文案识别完成,正在改写...");
const rewrittenDescription = await rewriteWithAI(
recognizedText,
modelConfig,
rewriteConfig,
customPrompt
);
if (!rewrittenDescription) {
return {
success: false,
error: "AI仿写失败请检查模型配置和网络连接",
};
}
const rewrittenTitle =
DouyinContentParser.generateTitleFromDescription(rewrittenDescription);
const keepTags = rewriteConfig?.keepHashtags !== false;
const fullContent =
keepTags && hashtags?.length
? `${rewrittenDescription}\n\n${hashtags.join(" ")}`
: rewrittenDescription;
progress("完成!");
log("info", "pipeline.success", {
originalLength: recognizedText.length,
rewrittenLength: rewrittenDescription.length,
});
return {
success: true,
original: {
videoUrl,
description: recognizedText,
hashtags: hashtags || [],
title: "视频音频识别结果",
},
rewritten: {
description: rewrittenDescription,
title: rewrittenTitle,
fullContent,
},
};
} catch (e) {
const msg = (e && (e.stack || e.message)) || String(e);
log("error", "pipeline.error", { message: msg });
return { success: false, error: msg };
} finally {
if (videoPath) pipelineNative.deleteFile(videoPath);
if (audioPath) pipelineNative.deleteFile(audioPath);
}
}
globalThis.__nodejsMain = async function (params) {
const p = params || {};
const modelConfig = desktopConfig.buildModelConfig();
const check = desktopConfig.validateModelConfig(modelConfig);
if (!check.ok) {
return { success: false, error: check.error };
}
return processDouyinShareComplete(
p.shareText || p.share_text || "",
modelConfig,
p.rewriteConfig,
p.customPrompt || null
);
};

View File

@@ -0,0 +1,484 @@
"use strict";
/**
* 抖音视频下载 — Puppeteer 版(参考 zhenqianba Electron DouyinVideoDownloader
* 直链走 HTTP分享链接用浏览器拦截 aweme detail API 后下载。
*/
const fs = require("fs");
const path = require("path");
const https = require("https");
const http = require("http");
const { DouyinBrowserManager, getRuntimeDataPath } = require("./douyin_browser_manager.js");
const DIRECT_VIDEO_PATTERN =
/https?:\/\/[^\s"'<>]+?\.(mp4|mov|m4v|webm|avi|mkv)(\?[^\s"'<>]*)?/i;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
class DouyinVideoDownloader {
constructor(externalBrowser) {
this.externalBrowser = externalBrowser || null;
this.browserManager = DouyinBrowserManager.getInstance();
this.userDataPath = getRuntimeDataPath("browser-data", "douyin");
}
getDownloadDir() {
const downloadDir = getRuntimeDataPath("downloads", "videos");
if (!fs.existsSync(downloadDir)) {
fs.mkdirSync(downloadDir, { recursive: true });
}
return downloadDir;
}
inferVideoExtension(videoUrl) {
try {
const pathname = new URL(videoUrl).pathname.toLowerCase();
const matchedExt = pathname.match(/\.(mp4|mov|m4v|webm|avi|mkv)$/i);
if (matchedExt) return `.${matchedExt[1].toLowerCase()}`;
} catch (e) {
console.warn("inferVideoExtension failed:", e.message);
}
return ".mp4";
}
isDirectVideoUrl(videoUrl) {
return DIRECT_VIDEO_PATTERN.test(String(videoUrl || "").trim());
}
downloadRemoteFile(fileUrl, savePath, headers, redirectCount = 0) {
return new Promise((resolve, reject) => {
if (redirectCount > 5) {
reject(new Error("Too many redirects while downloading video"));
return;
}
const client = fileUrl.startsWith("https://") ? https : http;
const fileStream = fs.createWriteStream(savePath);
let settled = false;
const fail = (error) => {
if (settled) return;
settled = true;
fileStream.destroy();
if (fs.existsSync(savePath)) fs.unlinkSync(savePath);
reject(error);
};
const request = client.get(fileUrl, { headers }, (response) => {
const statusCode = response.statusCode ?? 0;
const location = response.headers.location;
if (statusCode >= 300 && statusCode < 400 && location) {
fileStream.close();
if (fs.existsSync(savePath)) fs.unlinkSync(savePath);
const redirectUrl = location.startsWith("http")
? location
: new URL(location, fileUrl).toString();
this.downloadRemoteFile(redirectUrl, savePath, headers, redirectCount + 1)
.then(resolve)
.catch(reject);
return;
}
if (statusCode !== 200) {
fail(new Error(`HTTP ${statusCode}: ${response.statusMessage}`));
return;
}
response.pipe(fileStream);
fileStream.on("finish", () => {
if (settled) return;
settled = true;
fileStream.close();
resolve();
});
});
request.on("error", fail);
fileStream.on("error", (err) => {
request.destroy();
fail(err);
});
request.setTimeout(120000, () => {
request.destroy();
fail(new Error("Download timed out after 120 seconds"));
});
});
}
async initBrowser() {
if (this.externalBrowser) {
console.log("使用外部提供的浏览器实例");
return;
}
await this.browserManager.initBrowser();
}
async extractAwemeIdAsync(url) {
let normalizedUrl = String(url || "").trim();
if (!normalizedUrl.startsWith("http")) {
normalizedUrl = "https://" + normalizedUrl;
}
const patterns = [
/\/video\/(\d+)/,
/modal_id=(\d+)/,
/share\/video\/(\d+)/,
/item_id=(\d+)/,
/aweme_id=(\d+)/,
];
for (const pattern of patterns) {
const match = normalizedUrl.match(pattern);
if (match) return match[1];
}
if (
normalizedUrl.includes("v.douyin.com") ||
normalizedUrl.includes("iesdouyin.com")
) {
try {
const realUrl = await this.resolveShortUrl(normalizedUrl);
if (realUrl && realUrl !== normalizedUrl) {
for (const pattern of patterns) {
const match = realUrl.match(pattern);
if (match) return match[1];
}
}
} catch (e) {
console.error("解析短链接失败:", e.message);
}
}
return null;
}
resolveShortUrl(shortUrl) {
return new Promise((resolve, reject) => {
const url = new URL(shortUrl);
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: "HEAD",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
},
timeout: 30000,
};
const req = https.request(options, (res) => {
if (
res.statusCode &&
res.statusCode >= 300 &&
res.statusCode < 400 &&
res.headers.location
) {
resolve(res.headers.location);
} else {
resolve(shortUrl);
}
});
req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject(new Error("短链接解析超时"));
});
req.end();
});
}
interceptVideoInfo(page, awemeId) {
return new Promise((resolve) => {
let found = false;
const handler = async (response) => {
if (found) return;
const url = response.url();
if (
url.includes("aweme/v1/web/aweme/detail") &&
url.includes(awemeId)
) {
try {
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch {
const jsonStr = text.replace(/^[^{]*/, "");
data = JSON.parse(jsonStr);
}
if (data.aweme_detail) {
found = true;
page.off("response", handler);
resolve(data.aweme_detail);
}
} catch (error) {
console.error("解析 API 响应失败:", error.message);
}
}
};
page.on("response", handler);
setTimeout(() => {
if (!found) {
page.off("response", handler);
console.log("API 拦截超时,将尝试其他方法");
resolve(null);
}
}, 30000);
});
}
async downloadVideo(videoUrl, savePath) {
const headers = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Referer: "https://www.douyin.com/",
Accept: "*/*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
};
return this.downloadRemoteFile(videoUrl, savePath, headers);
}
async downloadDirectVideo(videoUrl) {
try {
if (!this.isDirectVideoUrl(videoUrl)) {
return { success: false, error: "Unsupported direct video url" };
}
const downloadDir = this.getDownloadDir();
const extension = this.inferVideoExtension(videoUrl);
const videoPath = path.join(downloadDir, `direct_${Date.now()}${extension}`);
const headers = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Accept: "*/*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
};
await this.downloadRemoteFile(videoUrl, videoPath, headers);
return {
success: true,
videoPath,
audioPath: videoPath.replace(path.extname(videoPath), "_audio.wav"),
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
async downloadDouyinVideo(videoUrl) {
let page = null;
try {
console.log("开始下载抖音视频:", videoUrl);
const awemeId = await this.extractAwemeIdAsync(videoUrl);
if (!awemeId) {
return { success: false, error: "无法从URL中提取视频ID请检查链接格式" };
}
console.log("提取到视频ID:", awemeId);
const downloadDir = this.getDownloadDir();
const videoPath = path.join(
downloadDir,
`douyin_${awemeId}_${Date.now()}.mp4`
);
await this.initBrowser();
if (this.externalBrowser) {
page = await this.externalBrowser.newPage();
} else {
page = await this.browserManager.newPage();
}
const videoInfoPromise = this.interceptVideoInfo(page, awemeId);
const directVideoUrl = `https://www.douyin.com/video/${awemeId}`;
console.log("访问视频页面:", directVideoUrl);
try {
await page.goto(directVideoUrl, {
waitUntil: "domcontentloaded",
timeout: 30000,
});
const currentUrl = page.url();
const isLoginPage =
currentUrl.includes("/login") || currentUrl.includes("login?");
if (isLoginPage) {
console.log("页面跳转到登录页,尝试引导登录...");
await this.ensureLoggedIn(page);
await page.goto(directVideoUrl, {
waitUntil: "domcontentloaded",
timeout: 30000,
});
if (page.url().includes("/login")) {
return {
success: false,
error:
"无法访问视频页面,请先扫码登录抖音(设置 AICLIENT_PUPPETEER_HEADLESS=0 显示浏览器窗口)",
};
}
}
} catch (gotoError) {
console.log(
"页面 goto 超时(已忽略),继续等待 API:",
gotoError.message
);
}
const videoInfo = await videoInfoPromise;
if (!videoInfo) {
try {
const pageContent = await page.content();
if (pageContent.includes("aweme_detail")) {
return {
success: true,
videoPath,
videoInfo: {
aweme_id: awemeId,
desc: `抖音视频 ${awemeId}`,
},
audioPath: videoPath.replace(".mp4", "_audio.wav"),
};
}
} catch (pageError) {
console.error("页面解析失败:", pageError.message);
}
return {
success: false,
error: "无法获取视频信息,可能视频不存在或访问受限",
};
}
const videoUrls = videoInfo.video?.play_addr?.url_list;
if (!videoUrls || !videoUrls.length) {
return { success: false, error: "无法获取视频下载链接,可能视频版权限制" };
}
const downloadUrl = videoUrls[0];
console.log("开始下载视频文件...");
await this.downloadVideo(downloadUrl, videoPath);
console.log("视频下载完成:", videoPath, "size:", fs.statSync(videoPath).size);
return {
success: true,
videoPath,
videoInfo,
audioPath: videoPath.replace(".mp4", "_audio.wav"),
};
} catch (error) {
console.error("下载视频失败:", error);
let errorMessage = error instanceof Error ? error.message : String(error);
if (
errorMessage.includes("3221225781") ||
errorMessage.includes("0xC0000135")
) {
errorMessage =
"浏览器启动失败(错误码: 0xC0000135。请安装 VC++ 运行库: https://aka.ms/vs/17/release/vc_redist.x64.exe";
} else if (/puppeteer|browser|Could not find Chrome/i.test(errorMessage)) {
errorMessage =
"浏览器启动失败。请在 src-tauri/resources/nodejs 执行 npm install或设置 AICLIENT_CHROMIUM_PATH";
} else if (/timeout/i.test(errorMessage)) {
errorMessage = "网络超时,请检查网络连接或重试";
}
return { success: false, error: errorMessage };
} finally {
if (page) {
try {
await page.close();
} catch (closeError) {
console.log("关闭页面失败:", closeError.message);
}
}
}
}
async ensureLoggedIn(page) {
try {
if (this.externalBrowser) {
console.log("使用外部浏览器,跳过登录检查");
return;
}
const loginCookieNames = [
"sessionid",
"uid_tt",
"LOGIN_STATUS",
"sid_guard",
"sid_tt",
];
let cookies = await page.cookies("https://www.douyin.com");
let loginCookies = cookies.filter(
(c) => loginCookieNames.includes(c.name) && c.value
);
if (loginCookies.length > 0) {
console.log("检测到已登录 Cookie");
return;
}
await page.goto("https://www.douyin.com/", {
waitUntil: "domcontentloaded",
timeout: 30000,
});
await sleep(2000);
const isLoggedIn = await page.evaluate(() => {
const loginBtnSelectors = [
'[data-e2e="login-button"]',
".login-btn",
'button[class*="login"]',
];
const userInfoSelectors = [
'[data-e2e="user-info"]',
'[data-e2e="user-avatar"]',
'[class*="userInfo"]',
".user-avatar",
".nickname",
];
const hasLoginButton = loginBtnSelectors.some((s) =>
document.querySelector(s)
);
const hasUserInfo = userInfoSelectors.some((s) =>
document.querySelector(s)
);
return hasUserInfo || !hasLoginButton;
});
if (isLoggedIn) {
console.log("用户已登录");
return;
}
const headless =
process.env.AICLIENT_PUPPETEER_HEADLESS !== "0" &&
process.env.AICLIENT_PUPPETEER_HEADLESS !== "false";
if (headless) {
console.log(
"无头模式下无法扫码登录。请设置环境变量 AICLIENT_PUPPETEER_HEADLESS=0 后重试"
);
return;
}
console.log("=".repeat(60));
console.log("请在浏览器窗口中使用抖音 APP 扫码登录");
console.log("=".repeat(60));
const loginTimeout = 120000;
const checkInterval = 3000;
const startTime = Date.now();
while (Date.now() - startTime < loginTimeout) {
await sleep(checkInterval);
cookies = await page.cookies("https://www.douyin.com");
loginCookies = cookies.filter(
(c) => loginCookieNames.includes(c.name) && c.value
);
if (loginCookies.length > 0) {
console.log("登录成功");
break;
}
}
} catch (error) {
console.error("登录引导失败:", error.message);
}
}
async close() {
if (this.externalBrowser) return;
try {
await this.browserManager.close();
} catch (error) {
console.error("关闭浏览器失败:", error.message);
}
}
}
DouyinVideoDownloader.DIRECT_VIDEO_PATTERN = DIRECT_VIDEO_PATTERN;
module.exports = { DouyinVideoDownloader };

View File

@@ -0,0 +1,321 @@
"use strict";
/**
* Node 流水线宿主能力(对齐 QuickJS __native / Rust js_runtime/native.rs
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const crypto = require("crypto");
const { spawn } = require("child_process");
const https = require("https");
const http = require("http");
const TEMP_DIR = path.join(os.tmpdir(), "aiclient-node-pipeline");
fs.mkdirSync(TEMP_DIR, { recursive: true });
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function tempPath(prefix, ext) {
return path.join(TEMP_DIR, `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext || "tmp"}`);
}
function deleteFile(p) {
try {
if (p && fs.existsSync(p)) fs.unlinkSync(p);
} catch (_) {
/* ignore */
}
}
function runCommand(cmd, args, timeoutMs = 300000) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
let stderr = "";
child.stderr.on("data", (d) => {
stderr += d.toString();
});
const timer = setTimeout(() => {
child.kill();
reject(new Error(`命令超时: ${cmd}`));
}, timeoutMs);
child.on("error", (e) => {
clearTimeout(timer);
reject(e);
});
child.on("close", (code) => {
clearTimeout(timer);
if (code === 0) resolve();
else reject(new Error(`${cmd} 退出码 ${code}: ${stderr.slice(0, 500)}`));
});
});
}
function locateFfmpeg() {
if (process.env.AICLIENT_FFMPEG_PATH && fs.existsSync(process.env.AICLIENT_FFMPEG_PATH)) {
return process.env.AICLIENT_FFMPEG_PATH;
}
return process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg";
}
async function ffmpegExtractAudio(videoPath) {
const dest = tempPath("audio", "wav");
const ffmpeg = locateFfmpeg();
await runCommand(ffmpeg, [
"-y",
"-i",
videoPath,
"-vn",
"-acodec",
"pcm_s16le",
"-ar",
"16000",
"-ac",
"1",
dest,
]);
return dest;
}
function rfc1123Date() {
return new Date().toUTCString();
}
function signOss(secret, str) {
return crypto.createHmac("sha1", secret).update(str).digest("base64");
}
async function aliyunOssUpload(localPath, cfg) {
const ak = cfg.accessKeyId;
const sk = cfg.accessKeySecret;
const bucket = cfg.bucket;
const region = cfg.region;
if (!ak || !sk || !bucket || !region) {
return { success: false, error: "OSS 配置不完整" };
}
const objectKey = `audio/${Date.now()}-${Math.random().toString(36).slice(2)}.wav`;
const host = `${bucket}.${region}.aliyuncs.com`;
const endpoint = (cfg.endpoint || `https://${host}`).replace(/\/$/, "");
const url = `${endpoint}/${objectKey}`;
const body = fs.readFileSync(localPath);
const date = rfc1123Date();
const contentType = "audio/wav";
const canonicalizedResource = `/${bucket}/${objectKey}`;
const strToSign = `PUT\n\n${contentType}\n${date}\n${canonicalizedResource}`;
const signature = signOss(sk, strToSign);
const authorization = `OSS ${ak}:${signature}`;
return new Promise((resolve) => {
const u = new URL(url);
const opts = {
hostname: u.hostname,
port: u.port || 443,
path: u.pathname,
method: "PUT",
headers: {
Date: date,
"Content-Type": contentType,
Authorization: authorization,
"Content-Length": body.length,
},
};
const req = https.request(opts, (res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve({ success: true, url });
} else {
resolve({
success: false,
error: `OSS HTTP ${res.statusCode}: ${Buffer.concat(chunks).toString().slice(0, 200)}`,
});
}
});
});
req.on("error", (e) => resolve({ success: false, error: e.message }));
req.write(body);
req.end();
});
}
async function fetchJson(url, options = {}) {
const res = await fetch(url, options);
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
data = { _raw: text };
}
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`);
}
return data;
}
async function aliyunAsrFiletrans(audioUrl, opts) {
const apiKey = opts.apiKey || opts.api_key;
const model = opts.model || "qwen3-asr-flash-filetrans";
if (!apiKey) {
return { success: false, error: "缺少 DashScope apiKey" };
}
const submit = await fetchJson(
"https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription",
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"X-DashScope-Async": "enable",
},
body: JSON.stringify({
model,
input: { file_urls: [audioUrl] },
parameters: {},
}),
}
);
const taskId = submit?.output?.task_id;
if (!taskId) {
return { success: false, error: `ASR 提交失败: ${JSON.stringify(submit).slice(0, 200)}` };
}
for (let i = 0; i < 60; i++) {
await sleep(5000);
const poll = await fetchJson(
`https://dashscope.aliyuncs.com/api/v1/tasks/${taskId}`,
{ headers: { Authorization: `Bearer ${apiKey}` } }
);
const status = poll?.output?.task_status;
if (status === "SUCCEEDED") {
const transUrl = poll?.output?.results?.[0]?.transcription_url;
if (!transUrl) {
return { success: false, error: "ASR 成功但无 transcription_url" };
}
const trans = await fetchJson(transUrl);
let fullText = "";
if (trans.transcripts && trans.transcripts.length) {
fullText = trans.transcripts.map((t) => t.text || "").filter(Boolean).join("\n");
} else if (trans.text) {
fullText = trans.text;
}
return { success: true, text: fullText };
}
if (status === "FAILED" || status === "UNKNOWN") {
return {
success: false,
error: poll?.output?.message || "ASR 任务失败",
};
}
}
return { success: false, error: "ASR 轮询超时" };
}
async function localAsr(audioPath, info) {
const baseUrl = (info.baseUrl || info.localPath || "").replace(/\/$/, "");
if (!baseUrl) {
return { success: false, error: "未配置本地 ASR 服务地址" };
}
const asrPath = info.asrPath || "/asr";
const url = `${baseUrl}${asrPath.startsWith("/") ? asrPath : `/${asrPath}`}`;
const boundary = `----aiclient${Date.now()}`;
const fileBuf = fs.readFileSync(audioPath);
const head = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="audio"; filename="audio.wav"\r\nContent-Type: audio/wav\r\n\r\n`,
"utf8"
);
const tail = Buffer.from(`\r\n--${boundary}--\r\n`, "utf8");
const body = Buffer.concat([head, fileBuf, tail]);
const u = new URL(url);
const client = u.protocol === "https:" ? https : http;
return new Promise((resolve) => {
const req = client.request(
{
hostname: u.hostname,
port: u.port || (u.protocol === "https:" ? 443 : 80),
path: u.pathname + u.search,
method: "POST",
headers: {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
"Content-Length": body.length,
},
},
(res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
try {
const raw = Buffer.concat(chunks).toString("utf8");
const data = JSON.parse(raw);
if (data.code === 0 || data.success) {
const records =
data.data?.data?.records ||
data.data?.records ||
data.records ||
[];
const text = Array.isArray(records)
? records.map((r) => r.text || "").join("")
: data.text || data.data?.text || "";
resolve({ success: true, text: String(text) });
} else {
resolve({ success: false, error: data.msg || data.error || raw.slice(0, 200) });
}
} catch (e) {
resolve({ success: false, error: e.message });
}
});
}
);
req.on("error", (e) => resolve({ success: false, error: e.message }));
req.write(body);
req.end();
});
}
async function openaiChat(req) {
let apiUrl = (req.apiUrl || "").replace(/\/$/, "");
if (!apiUrl.includes("/chat/completions")) {
apiUrl = apiUrl.endsWith("/v1")
? `${apiUrl}/chat/completions`
: `${apiUrl}/v1/chat/completions`;
}
const body = {
model: req.model,
messages: req.messages,
stream: false,
temperature: typeof req.temperature === "number" ? req.temperature : 0.8,
max_tokens: req.max_tokens || req.maxTokens || 2000,
};
try {
const data = await fetchJson(apiUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${req.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const content = data?.choices?.[0]?.message?.content;
if (!content) {
return { success: false, error: "模型返回为空" };
}
return { success: true, content: String(content).trim() };
} catch (e) {
return { success: false, error: e.message };
}
}
module.exports = {
tempPath,
deleteFile,
ffmpegExtractAudio,
aliyunOssUpload,
aliyunAsrFiletrans,
localAsr,
openaiChat,
};

View File

@@ -0,0 +1,66 @@
"use strict";
/** 仿写提示词(对齐 zhenqianba VideoRewriteOptimizer */
class VideoRewriteOptimizer {
static replacePromptVariables(template, content) {
return String(template)
.replace(/\{\{content\}\}/g, content)
.replace(/\{content\}/g, content)
.replace(/\{\{text\}\}/g, content)
.replace(/\{text\}/g, content);
}
static getStyleGuide(style) {
const guides = {
casual: "轻松随意、接地气、易产生共鸣,像和朋友聊天一样",
professional: "正式专业、有信服力、适合商务或教育内容",
emotional: "充满情感、富有感染力、能打动人心",
humorous: "幽默诙谐、容易引起笑声和转发、保持积极态度",
};
return guides[style] || guides.casual;
}
static getLengthGuide(length) {
const guides = {
short: "简洁有力100字以内快速传达核心信息",
medium: "适中篇幅100-300字既能完整表达又不显冗长",
long: "详细深入300-500字充分展开论点和细节",
};
return guides[length] || guides.medium;
}
static buildDefaultPrompt(content, config) {
const cfg = config || {};
const styleGuide = this.getStyleGuide(cfg.style || "casual");
const lengthGuide = this.getLengthGuide(cfg.length || "medium");
return `你是一个专业的视频内容创意编写专家,擅长创作吸引人的视频文案。
## 原始文案:
${content}
## 仿写要求:
1. **风格**: ${styleGuide}
2. **长度**: ${lengthGuide}
3. **核心保持**: 保留原文案的核心信息和主题
4. **创意提升**: 在措辞、表述角度、情感吸引力上创新
5. **格式**:
${cfg.keepHashtags !== false ? "- 保留原有话题标签(#开头的内容)" : "- 不使用话题标签"}
${cfg.keepEmoji !== false ? "- 可以适当添加表情符号增加生动性" : "- 不使用表情符号"}
6. **禁止事项**:
- 不要改变事实信息
- 不要添加虚假承诺
- 不要包含敏感词汇
请直接输出仿写后的文案,不需要任何额外说明或标记。`;
}
static buildRewritePrompt(content, config = {}, customPrompt) {
if (customPrompt && String(customPrompt).trim()) {
return this.replacePromptVariables(customPrompt, content);
}
return this.buildDefaultPrompt(content, config);
}
}
module.exports = { VideoRewriteOptimizer };