From 35944cee396e0bde467ac0ebec1659558423a2e8 Mon Sep 17 00:00:00 2001 From: "fengchuanhn@gmail.com" Date: Mon, 18 May 2026 09:45:44 +0800 Subject: [PATCH] 11 --- scripts/nodejs/douyin_profile_scraper.js | 216 +++++++++++++++++++++++ scripts/nodejs/ip_brain_collect.js | 38 ++++ scripts/nodejs/ip_topic_rewrite.js | 77 ++++++++ scripts/nodejs/script_legal_check.js | 94 ++++++++++ 4 files changed, 425 insertions(+) create mode 100644 scripts/nodejs/douyin_profile_scraper.js create mode 100644 scripts/nodejs/ip_brain_collect.js create mode 100644 scripts/nodejs/ip_topic_rewrite.js create mode 100644 scripts/nodejs/script_legal_check.js diff --git a/scripts/nodejs/douyin_profile_scraper.js b/scripts/nodejs/douyin_profile_scraper.js new file mode 100644 index 0000000..63e3aff --- /dev/null +++ b/scripts/nodejs/douyin_profile_scraper.js @@ -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, +}; diff --git a/scripts/nodejs/ip_brain_collect.js b/scripts/nodejs/ip_brain_collect.js new file mode 100644 index 0000000..8815ec1 --- /dev/null +++ b/scripts/nodejs/ip_brain_collect.js @@ -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), + }; + } +}; diff --git a/scripts/nodejs/ip_topic_rewrite.js b/scripts/nodejs/ip_topic_rewrite.js new file mode 100644 index 0000000..5cf4140 --- /dev/null +++ b/scripts/nodejs/ip_topic_rewrite.js @@ -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 }; +}; diff --git a/scripts/nodejs/script_legal_check.js b/scripts/nodejs/script_legal_check.js new file mode 100644 index 0000000..6add2ea --- /dev/null +++ b/scripts/nodejs/script_legal_check.js @@ -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, + }; +};