11
This commit is contained in:
216
scripts/nodejs/douyin_profile_scraper.js
Normal file
216
scripts/nodejs/douyin_profile_scraper.js
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抖音用户主页采集(对齐 Electron ipAgent:analyzeDouyinAccount)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const https = require("https");
|
||||||
|
const { DouyinBrowserManager } = require("./douyin_browser_manager.js");
|
||||||
|
|
||||||
|
const PROFILE_URL_RE =
|
||||||
|
/douyin\.com\/user|iesdouyin\.com\/share\/user|v\.douyin\.com/i;
|
||||||
|
|
||||||
|
const SCRAPE_SCRIPT = `
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
let accountName = "";
|
||||||
|
const nicknameEl = document.querySelector(
|
||||||
|
'[class*="nickname"], [class*="account"], h1'
|
||||||
|
);
|
||||||
|
if (nicknameEl) accountName = nicknameEl.textContent?.trim() || "";
|
||||||
|
|
||||||
|
if (!accountName) {
|
||||||
|
const metaEl = document.querySelector('meta[property="og:title"]');
|
||||||
|
if (metaEl) accountName = metaEl.getAttribute("content") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accountName) {
|
||||||
|
const allHeaders = document.querySelectorAll(
|
||||||
|
'h1, h2, .dy-name, [data-testid*="name"], [class*="userName"]'
|
||||||
|
);
|
||||||
|
for (const el of allHeaders) {
|
||||||
|
const text = el.textContent?.trim();
|
||||||
|
if (text && text.length > 2 && text.length < 30) {
|
||||||
|
accountName = text;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accountName) {
|
||||||
|
const titleText = document.title || "";
|
||||||
|
const match = titleText.match(/(.+?)(?:的抖音|抖音号|-)/);
|
||||||
|
accountName = match ? match[1].trim() : titleText.split("-")[0]?.trim() || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoTitles = [];
|
||||||
|
const seenTexts = new Set();
|
||||||
|
const excludeWords = [
|
||||||
|
"抖音号", "IP属地", "获赞", "粉丝", "关注", "作品", "点赞", "评论",
|
||||||
|
"分享", "收藏", "私信", "粉丝团", "加关注", "最新作品", "合作推荐", "喜欢作者",
|
||||||
|
];
|
||||||
|
|
||||||
|
const shouldExclude = (text) => {
|
||||||
|
if (!text || text.length < 5 || text.length > 300) return true;
|
||||||
|
return excludeWords.some((word) => text.includes(word));
|
||||||
|
};
|
||||||
|
|
||||||
|
const allElements = document.querySelectorAll(
|
||||||
|
'p, span[class*="desc"], div[class*="title"]'
|
||||||
|
);
|
||||||
|
allElements.forEach((el) => {
|
||||||
|
if (videoTitles.length >= 6) return;
|
||||||
|
const text = el.textContent?.trim();
|
||||||
|
if (
|
||||||
|
text &&
|
||||||
|
text.length > 10 &&
|
||||||
|
text.length < 500 &&
|
||||||
|
!shouldExclude(text) &&
|
||||||
|
!seenTexts.has(text)
|
||||||
|
) {
|
||||||
|
videoTitles.push(text);
|
||||||
|
seenTexts.add(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (videoTitles.length < 3) {
|
||||||
|
const titleSelectors = [
|
||||||
|
'[class*="title"]',
|
||||||
|
'[class*="desc"]',
|
||||||
|
'[class*="caption"]',
|
||||||
|
'[data-testid*="title"]',
|
||||||
|
'[class*="feed-item"]',
|
||||||
|
'[class*="video-item"]',
|
||||||
|
".dy-video-title",
|
||||||
|
".aweme-detail",
|
||||||
|
'[class*="aweme"]',
|
||||||
|
];
|
||||||
|
for (const selector of titleSelectors) {
|
||||||
|
if (videoTitles.length >= 6) break;
|
||||||
|
document.querySelectorAll(selector).forEach((el) => {
|
||||||
|
if (videoTitles.length >= 6) return;
|
||||||
|
const text = el.textContent?.trim();
|
||||||
|
if (text && !shouldExclude(text) && text.length > 5 && !seenTexts.has(text)) {
|
||||||
|
videoTitles.push(text);
|
||||||
|
seenTexts.add(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanTitles = videoTitles
|
||||||
|
.map((title) => title.replace(/^\\d+\\s+/, "").trim())
|
||||||
|
.filter((t) => t && t.length > 5);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountName: accountName || "未知账号",
|
||||||
|
videoTitles: Array.from(new Set(cleanTitles)).slice(0, 6),
|
||||||
|
pageTitle: document.title,
|
||||||
|
pageUrl: window.location.href,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
accountName: document.title?.split("-")[0] || "错误",
|
||||||
|
videoTitles: [],
|
||||||
|
pageTitle: document.title,
|
||||||
|
pageUrl: window.location.href,
|
||||||
|
error: err.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
`;
|
||||||
|
|
||||||
|
function emitProgress(msg) {
|
||||||
|
try {
|
||||||
|
globalThis.__native?.emitProgress?.(msg);
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveShortUrl(shortUrl) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let u;
|
||||||
|
try {
|
||||||
|
u = new URL(shortUrl);
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const opts = {
|
||||||
|
hostname: u.hostname,
|
||||||
|
path: u.pathname + u.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(opts, (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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function normalizeProfileUrl(input) {
|
||||||
|
let url = String(input || "").trim();
|
||||||
|
if (!url) throw new Error("请输入账号主页或分享链接");
|
||||||
|
|
||||||
|
const urlMatch = url.match(/https?:\/\/[^\s\u4e00-\u9fa5"'<>]+/i);
|
||||||
|
if (urlMatch) url = urlMatch[0];
|
||||||
|
else if (!url.startsWith("http")) url = `https://${url}`;
|
||||||
|
|
||||||
|
if (/v\.douyin\.com|iesdouyin\.com/i.test(url) && !/\/user\//i.test(url)) {
|
||||||
|
emitProgress("正在解析分享链接...");
|
||||||
|
url = await resolveShortUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PROFILE_URL_RE.test(url)) {
|
||||||
|
throw new Error("请输入抖音账号主页或用户分享链接(支持 v.douyin.com 短链)");
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrapeDouyinProfile(profileUrl) {
|
||||||
|
const manager = DouyinBrowserManager.getInstance();
|
||||||
|
emitProgress("正在打开浏览器并采集数据,请稍候...");
|
||||||
|
await manager.initBrowser();
|
||||||
|
const page = await manager.newPage();
|
||||||
|
try {
|
||||||
|
await page.goto(profileUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
||||||
|
emitProgress("页面已加载,正在提取账号与视频标题...");
|
||||||
|
await new Promise((r) => setTimeout(r, 5000));
|
||||||
|
const data = await page.evaluate(SCRAPE_SCRIPT);
|
||||||
|
if (data?.error) {
|
||||||
|
throw new Error(data.error);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await page.close();
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normalizeProfileUrl,
|
||||||
|
scrapeDouyinProfile,
|
||||||
|
};
|
||||||
38
scripts/nodejs/ip_brain_collect.js
Normal file
38
scripts/nodejs/ip_brain_collect.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP 大脑:采集抖音对标账号最新视频标题
|
||||||
|
* 由 `/api/v1/nodejs-scripts?name=ip_brain_collect.js` 下发
|
||||||
|
*/
|
||||||
|
|
||||||
|
const {
|
||||||
|
normalizeProfileUrl,
|
||||||
|
scrapeDouyinProfile,
|
||||||
|
} = require("./douyin_profile_scraper.js");
|
||||||
|
|
||||||
|
globalThis.__nodejsMain = async function (params) {
|
||||||
|
const raw = params?.url || params?.profileUrl || "";
|
||||||
|
try {
|
||||||
|
const url = await normalizeProfileUrl(raw);
|
||||||
|
const data = await scrapeDouyinProfile(url);
|
||||||
|
const titles = Array.isArray(data.videoTitles) ? data.videoTitles : [];
|
||||||
|
if (titles.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"未能采集到视频标题,请确认链接为抖音用户主页且已登录/可访问(可设置 AICLIENT_PUPPETEER_HEADLESS=0 查看浏览器)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
accountName: data.accountName || "",
|
||||||
|
pageUrl: data.pageUrl || url,
|
||||||
|
videoTitles: titles,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: (e && e.message) || String(e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
77
scripts/nodejs/ip_topic_rewrite.js
Normal file
77
scripts/nodejs/ip_topic_rewrite.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选题库一键仿写标题(对齐 Electron ipAgent:rewriteTitles)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const pipelineNative = require("./pipeline_native.js");
|
||||||
|
const desktopConfig = require("./desktop_config.js");
|
||||||
|
|
||||||
|
const DEFAULT_TOPIC_REWRITE_PROMPT = `参考以下标题,帮我生成新的标题。要求:
|
||||||
|
1. 保持相似的主题和风格
|
||||||
|
2. 标题简洁有力
|
||||||
|
3. 不要添加表情符号和标签
|
||||||
|
4. 直接输出与参考数量相同的新标题,每行一个,不要编号
|
||||||
|
|
||||||
|
参考标题:
|
||||||
|
{{content}}
|
||||||
|
|
||||||
|
请生成新标题:`;
|
||||||
|
|
||||||
|
function buildTopicRewritePrompt(titles, customPrompt) {
|
||||||
|
const titlesText = titles.map((t, i) => `${i + 1}. ${t}`).join("\n");
|
||||||
|
let prompt = String(customPrompt || "").trim();
|
||||||
|
if (!prompt) {
|
||||||
|
prompt = DEFAULT_TOPIC_REWRITE_PROMPT;
|
||||||
|
}
|
||||||
|
return prompt
|
||||||
|
.replace(/\{\{content\}\}/g, titlesText)
|
||||||
|
.replace(/\{content\}/g, titlesText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRewrittenLines(content, fallbackTitles) {
|
||||||
|
const lines = String(content || "")
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.replace(/^[\d\s.\-、]+/, "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (lines.length >= fallbackTitles.length) {
|
||||||
|
return lines.slice(0, fallbackTitles.length);
|
||||||
|
}
|
||||||
|
const out = [...lines];
|
||||||
|
for (let i = lines.length; i < fallbackTitles.length; i++) {
|
||||||
|
out.push(fallbackTitles[i]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.__nodejsMain = async function (params) {
|
||||||
|
const titles = Array.isArray(params?.titles) ? params.titles.filter(Boolean) : [];
|
||||||
|
if (titles.length === 0) {
|
||||||
|
return { success: false, error: "没有可仿写的标题" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelConfig = desktopConfig.buildModelConfig();
|
||||||
|
const check = desktopConfig.validateModelConfig(modelConfig);
|
||||||
|
if (!check.ok) {
|
||||||
|
return { success: false, error: check.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = buildTopicRewritePrompt(titles, params?.rewritePrompt);
|
||||||
|
const chat = await pipelineNative.openaiChat({
|
||||||
|
apiUrl: modelConfig.apiUrl,
|
||||||
|
apiKey: modelConfig.apiKey,
|
||||||
|
model: modelConfig.apiModelId,
|
||||||
|
messages: [{ role: "user", content: prompt }],
|
||||||
|
temperature: 0.85,
|
||||||
|
max_tokens: 1500,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!chat?.success || !chat.content) {
|
||||||
|
return { success: false, error: chat?.error || "标题仿写失败" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rewrittenTitles = parseRewrittenLines(chat.content, titles);
|
||||||
|
return { success: true, rewrittenTitles };
|
||||||
|
};
|
||||||
94
scripts/nodejs/script_legal_check.js
Normal file
94
scripts/nodejs/script_legal_check.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 法务审核(对齐 zhenqianba handleLegalCheck / _i)
|
||||||
|
* 由 `/api/v1/nodejs-scripts?name=script_legal_check.js` 下发
|
||||||
|
*/
|
||||||
|
|
||||||
|
const pipelineNative = require("./pipeline_native.js");
|
||||||
|
const desktopConfig = require("./desktop_config.js");
|
||||||
|
|
||||||
|
function buildLegalPrompt(content) {
|
||||||
|
return `你是一名专业的短视频法务审核专家。请审核以下短视频的文案是否存在违禁词、敏感词、虚假宣传、极限词(如"第一"、"最好")等法律风险以及短视频平台常见的违禁词,避免绝对化表述和违禁词,避免负向,消极的立场,对大健康,医疗等重点行业要严格审核。
|
||||||
|
|
||||||
|
【待审核文案】
|
||||||
|
${content}
|
||||||
|
|
||||||
|
【审核要求】
|
||||||
|
1. 找出文案中的所有违禁词/敏感词。
|
||||||
|
2. 为每个违禁词提供一个合规的替换词(如果建议直接删除,替换词为空字符串)。
|
||||||
|
3. 说明每个违禁词的违规原因。
|
||||||
|
4. 给出整体审核意见。
|
||||||
|
5. 给出修正后的完整优化文案。
|
||||||
|
|
||||||
|
【输出格式】
|
||||||
|
请直接返回 JSON 格式:
|
||||||
|
{
|
||||||
|
"risks": [
|
||||||
|
{
|
||||||
|
"word": "违禁词原文",
|
||||||
|
"recommendation": "替换词",
|
||||||
|
"reason": "违规原因"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"analysis": "整体审核意见",
|
||||||
|
"fixedText": "修正后的完整文案",
|
||||||
|
"hasRisk": true
|
||||||
|
}
|
||||||
|
|
||||||
|
除 JSON 外不要输出任何其他内容。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLegalJson(raw) {
|
||||||
|
let text = String(raw || "").trim();
|
||||||
|
const m = text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/) || text.match(/\{[\s\S]*\}/);
|
||||||
|
if (m) text = m[1] || m[0];
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
const risks = Array.isArray(data.risks) ? data.risks : [];
|
||||||
|
return {
|
||||||
|
risks,
|
||||||
|
analysis: data.analysis || "",
|
||||||
|
fixedText: data.fixedText || "",
|
||||||
|
hasRisk: data.hasRisk === true || risks.length > 0,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { risks: [], analysis: text, fixedText: "", hasRisk: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.__nodejsMain = async function (params) {
|
||||||
|
const content = String(params?.content || "").trim();
|
||||||
|
if (!content) {
|
||||||
|
return { success: false, error: "缺少待审核文案" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelConfig = desktopConfig.buildModelConfig();
|
||||||
|
const check = desktopConfig.validateModelConfig(modelConfig);
|
||||||
|
if (!check.ok) {
|
||||||
|
return { success: false, error: check.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = await pipelineNative.openaiChat({
|
||||||
|
apiUrl: modelConfig.apiUrl,
|
||||||
|
apiKey: modelConfig.apiKey,
|
||||||
|
model: modelConfig.apiModelId,
|
||||||
|
messages: [{ role: "user", content: buildLegalPrompt(content) }],
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!chat?.success || !chat.content) {
|
||||||
|
return { success: false, error: chat?.error || "AI 审核请求失败" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseLegalJson(chat.content);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
hasViolations: parsed.hasRisk,
|
||||||
|
risks: parsed.risks,
|
||||||
|
analysis: parsed.analysis,
|
||||||
|
fixedText: parsed.fixedText,
|
||||||
|
totalCount: parsed.risks.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user