#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const os = require('os'); const { spawnSync } = require('child_process'); const API_ROOT = 'https://server.fmode.cn/api/voc-social'; function parseArgs(argv) { const args = {}; for (let i = 0; i < argv.length; i++) { const token = argv[i]; if (!token.startsWith('--')) continue; const eq = token.indexOf('='); if (eq >= 0) { args[token.slice(2, eq)] = token.slice(eq + 1); } else { const key = token.slice(2); const next = argv[i + 1]; if (next && !next.startsWith('--')) { args[key] = next; i++; } else { args[key] = true; } } } return args; } function usage() { return [ 'Usage:', ' node scripts/tools/douyin-speaking-daily-runner.js --profile --output ', ' node scripts/tools/douyin-speaking-daily-runner.js --project --industry --keywords "kw1,kw2"', '', 'Requires VOC_TOKEN env var or ~/.openclaw/voc-credentials.json with { "vocToken": "..." } for live fetch.' ].join('\n'); } function emitResult(args, result) { console.log(JSON.stringify(result, null, 2)); if (args.resultPrefix || args['result-prefix']) { const prefix = args.resultPrefix || args['result-prefix']; console.log(`${prefix}=${JSON.stringify(result)}`); } } function bool(value, fallback = false) { if (value === undefined || value === null || value === '') return fallback; if (typeof value === 'boolean') return value; return ['1', 'true', 'yes', 'y', 'on'].includes(String(value).toLowerCase()); } function intValue(value, fallback) { const number = Number(value); return Number.isFinite(number) ? Math.max(0, Math.round(number)) : fallback; } function asArray(value) { if (!value) return []; return Array.isArray(value) ? value : [value]; } function splitList(value) { if (!value) return []; if (Array.isArray(value)) return value.map(String).map(item => item.trim()).filter(Boolean); const text = String(value).trim(); if (!text || /^\{\{[^}]+\}\}$/.test(text)) return []; if (text.startsWith('[')) { try { const parsed = JSON.parse(text); return Array.isArray(parsed) ? parsed.map(item => typeof item === 'string' ? item : JSON.stringify(item)) : []; } catch { return []; } } return text.split(/[,,;;|、\n]/).map(item => item.trim()).filter(Boolean); } function uniqueStrings(values) { return [...new Set(values.map(value => cleanText(value)).filter(Boolean))]; } function cleanText(value) { return String(value || '').replace(/\s+/g, ' ').trim(); } function slugify(value) { return String(value || 'douyin-speaking-daily') .trim() .replace(/[\\/:*?"<>|\s]+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') || 'douyin-speaking-daily'; } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } function readJsonMaybe(value) { if (!value) return undefined; const text = String(value).trim(); if (!text || /^\{\{[^}]+\}$/.test(text)) return undefined; const absolute = path.resolve(text); if (fs.existsSync(absolute)) return readJson(absolute); try { return JSON.parse(text); } catch { return undefined; } } function hasConcreteArg(value) { if (value === undefined || value === null) return false; const text = String(value).trim(); return Boolean(text) && !/^\{\{[^}]+\}$/.test(text); } function firstConcreteArg(...values) { return values.find(value => hasConcreteArg(value)); } function outputSearchRoots() { return [ path.join(process.cwd(), 'outputs'), path.join(process.cwd(), 'openclaw-voc-output'), path.join(process.cwd(), 'memory') ]; } function findLatestFileByName(fileName, roots = outputSearchRoots()) { const queue = roots.map(root => path.resolve(root)).filter(root => fs.existsSync(root)); let latest = null; while (queue.length) { const dir = queue.shift(); let entries = []; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (!['node_modules', '.git', 'dist'].includes(entry.name)) queue.push(full); } else if (entry.isFile() && entry.name === fileName) { const stat = fs.statSync(full); if (!latest || stat.mtimeMs > latest.mtimeMs) latest = { path: full, mtimeMs: stat.mtimeMs }; } } } return latest?.path || ''; } function naturalCommandText(args) { return cleanText(args.message || args.userMessage || args['user-message'] || args.intent || args.command); } function isContinuationMessage(text) { return /选题\s*\d+|第\s*\d+\s*条|定稿|改稿|调整|开头|老板|专家|案例|场景|具体|太泛|压成|保留\s*\d|不要|降权|禁区|转写|逐字稿/i.test(text || ''); } function loadProfile(args) { const fileProfile = hasConcreteArg(args.profile) ? readJson(path.resolve(args.profile)) : {}; const inline = { projectName: args.project || args.projectName || args['project-name'], industry: args.industry, accountPositioning: args.accountPositioning || args['account-positioning'] || args.positioning, targetAudience: args.targetAudience || args['target-audience'] || args.audience, conversionGoal: args.conversionGoal || args['conversion-goal'] || args.goal, coreOffer: args.coreOffer || args['core-offer'] || args.offer, contentPillars: splitList(args.contentPillars || args['content-pillars'] || args.pillars), keywords: splitList(args.keywords), referenceAccounts: splitList(args.referenceAccounts || args['reference-accounts'] || args.accounts), accountDiscoveryDirection: args.accountDiscoveryDirection || args['account-discovery-direction'], forbiddenTopics: splitList(args.forbiddenTopics || args['forbidden-topics'] || args.forbidden), tone: args.tone, dailyOutputCount: intValue(args.dailyOutputCount || args['daily-output-count'] || args.count, undefined) }; const confirmed = readJsonMaybe(args.confirmedAccounts || args['confirmed-accounts']); if (confirmed) inline.confirmedAccounts = Array.isArray(confirmed) ? confirmed : [confirmed]; const profile = { ...fileProfile }; Object.entries(inline).forEach(([key, value]) => { if (Array.isArray(value)) { if (value.length) profile[key] = value; } else if (value !== undefined && value !== null && value !== '') { profile[key] = value; } }); profile.projectName = profile.projectName || 'douyin-speaking-daily'; profile.keywords = splitList(profile.keywords); profile.referenceAccounts = splitList(profile.referenceAccounts); profile.contentPillars = splitList(profile.contentPillars); profile.forbiddenTopics = splitList(profile.forbiddenTopics); profile.confirmedAccounts = asArray(profile.confirmedAccounts); profile.dailyOutputCount = intValue(profile.dailyOutputCount, 8) || 8; return profile; } function loadVocToken() { const envToken = process.env.VOC_TOKEN || process.env.OPENCLAW_VOC_TOKEN || process.env.VOC_SOCIAL_TOKEN; if (envToken) return envToken; const credentialsPath = path.join(os.homedir(), '.openclaw', 'voc-credentials.json'); if (fs.existsSync(credentialsPath)) { const data = readJson(credentialsPath); if (data.vocToken) return data.vocToken; if (data.token) return data.token; } return ''; } async function requestJson({ method = 'GET', pathUrl, body, token, query }) { const url = new URL(`${API_ROOT}${pathUrl}`); Object.entries(query || {}).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value)); }); const response = await fetch(url.toString(), { method, headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', ...(method === 'POST' ? { 'Content-Type': 'application/json' } : {}) }, body: method === 'POST' ? JSON.stringify(body || {}) : undefined }); const text = await response.text(); let data; try { data = JSON.parse(text); } catch { data = { rawText: text }; } if (!response.ok) { const error = new Error(data.message || data.message_zh || data.mess || data.descInfo || `HTTP ${response.status}`); error.data = data; error.httpStatus = response.status; error.responseCode = data.code; throw error; } return data; } function apiErrorRecord(base, error) { return { ...base, message: error.message, ...(error.httpStatus ? { httpStatus: error.httpStatus } : {}), ...(error.responseCode !== undefined ? { code: error.responseCode } : {}) }; } function isGatewayAuthError(error) { const message = String(error.message || ''); return error.httpStatus === 403 || /没有开通社交平台API权限|社交平台API权限|余额不足|not authorized|permission/i.test(message); } function toNumber(value) { const number = Number(value || 0); return Number.isFinite(number) ? number : 0; } function videoScore(video) { return toNumber(video.statistics?.digg_count || video.digg_count || video.likeCount) + toNumber(video.statistics?.comment_count || video.comment_count || video.commentCount) * 4 + toNumber(video.statistics?.share_count || video.share_count || video.shareCount) * 6 + toNumber(video.statistics?.play_count || video.play_count || video.playCount) * 0.003; } function normalizeAweme(aweme, extra = {}) { const statistics = aweme.statistics || {}; const author = aweme.author || {}; return { aweme_id: cleanText(aweme.aweme_id || aweme.awemeId || aweme.id), keyword: extra.keyword || aweme.keyword || '', sourceType: extra.sourceType || aweme.sourceType || 'video', accountSource: extra.accountSource || '', desc: cleanText(aweme.desc || aweme.title || aweme.content), title: cleanText(aweme.desc || aweme.title || aweme.content), author: { uid: author.uid || author.user_id || '', sec_uid: author.sec_uid || author.sec_user_id || extra.sec_user_id || '', nickname: author.nickname || author.nick_name || extra.nickname || '' }, statistics: { digg_count: toNumber(statistics.digg_count ?? aweme.digg_count ?? aweme.likeCount), comment_count: toNumber(statistics.comment_count ?? aweme.comment_count ?? aweme.commentCount), share_count: toNumber(statistics.share_count ?? aweme.share_count ?? aweme.shareCount), play_count: toNumber(statistics.play_count ?? aweme.play_count ?? aweme.playCount) }, create_time: aweme.create_time || aweme.publishTime || '', share_url: aweme.share_url || aweme.url || '', cha_list: asArray(aweme.cha_list), raw: aweme }; } function extractSearchVideos(response, keyword) { const cards = asArray(response?.data?.business_data) .concat(asArray(response?.data?.data?.business_data)) .concat(asArray(response?.business_data)); const videos = []; cards.forEach(card => { const aweme = card?.data?.aweme_info || card?.aweme_info || card?.data?.aweme_detail; if (aweme?.aweme_id) videos.push(normalizeAweme(aweme, { keyword, sourceType: 'keyword' })); }); asArray(response?.data?.aweme_list).forEach(aweme => { if (aweme?.aweme_id) videos.push(normalizeAweme(aweme, { keyword, sourceType: 'keyword' })); }); return dedupeVideos(videos); } function dedupeVideos(videos) { const map = new Map(); videos.filter(video => video.aweme_id).forEach(video => { const existing = map.get(video.aweme_id); if (!existing || videoScore(video) > videoScore(existing)) map.set(video.aweme_id, video); }); return [...map.values()]; } function extractAccountCandidates(response, source) { const buckets = asArray(response?.data?.data) .concat(asArray(response?.data?.user_list)) .concat(asArray(response?.user_list)); const list = []; buckets.forEach(bucket => { asArray(bucket?.user_list).forEach(item => list.push(item)); if (bucket?.user_info || bucket?.user_id || bucket?.sec_uid) list.push(bucket); }); return list.map((item, index) => { const info = item.user_info || item; const sec = info.sec_uid || info.sec_user_id || info.user_id || item.user_id || item.sec_uid || ''; return { id: sec || `candidate_${source}_${index}`, sec_user_id: sec, nickname: info.nickname || info.nick_name || item.nick_name || '', unique_id: info.unique_id || '', follower_count: toNumber(info.follower_count ?? info.fans_cnt ?? item.fans_cnt), like_count: toNumber(info.total_favorited ?? info.like_cnt ?? item.like_cnt), aweme_count: toNumber(info.aweme_count ?? info.publish_cnt ?? item.publish_cnt), signature: info.signature || item.signature || '', source }; }).filter(item => item.sec_user_id || item.nickname); } function accountCandidateScore(candidate) { const evidence = candidate.evidence || {}; const evidenceBoost = evidence.aweme_id || candidate.match_reason === 'general_search_video_author' ? 1000000 : 0; return toNumber(candidate.score) + evidenceBoost + toNumber(evidence.interaction_score) + toNumber(candidate.follower_count) * 0.02 + toNumber(candidate.like_count) * 0.002 + toNumber(candidate.aweme_count) * 2; } function dedupeAccountCandidates(candidates) { const map = new Map(); asArray(candidates).filter(Boolean).forEach((candidate, index) => { const sec = cleanText(candidate.sec_user_id || candidate.sec_uid || candidate.user_id || candidate.id); const nickname = cleanText(candidate.nickname || candidate.nick_name || candidate.name); const key = sec || nickname || `candidate_${index}`; if (!key) return; const normalized = { ...candidate, sec_user_id: sec, nickname, source: candidate.source || candidate.sourceInput || '' }; const existing = map.get(key); if (!existing || accountCandidateScore(normalized) > accountCandidateScore(existing)) { map.set(key, normalized); } }); return [...map.values()].sort((a, b) => accountCandidateScore(b) - accountCandidateScore(a)); } function accountCandidateFromVideo(video, source) { const rawAuthor = video?.raw?.author || {}; const author = { ...rawAuthor, ...(video?.author || {}) }; const sec = cleanText(author.sec_uid || author.sec_user_id || author.user_id || author.uid); const nickname = cleanText(author.nickname || author.nick_name); if (!sec && !nickname) return undefined; const evidenceScore = videoScore(video); return { id: sec || `${source}_${nickname}`, sec_user_id: sec, nickname, unique_id: cleanText(author.unique_id || author.short_id), follower_count: toNumber(author.follower_count ?? author.fans_cnt), like_count: toNumber(author.total_favorited ?? author.like_count ?? author.like_cnt), aweme_count: toNumber(author.aweme_count ?? author.publish_cnt), signature: cleanText(author.signature), source, match_reason: 'general_search_video_author', evidence: { keyword: cleanText(video.keyword || source), aweme_id: cleanText(video.aweme_id), desc: cleanText(video.desc || video.title).slice(0, 180), interaction_score: evidenceScore, statistics: video.statistics || {} }, score: evidenceScore }; } function extractAuthorAccountCandidates(videos, source) { return dedupeAccountCandidates( asArray(videos).map(video => accountCandidateFromVideo(video, source)).filter(Boolean) ); } function compactDiscoveryTerm(value) { const text = cleanText(value); if (!text) return ''; const head = cleanText(text.split(/[:\uFF1A]/)[0]); return (head || text).replace(/类账号|账号|方向/g, '').trim(); } function discoveryTerms(profile) { return uniqueStrings([ ...splitList(profile.accountDiscoveryDirection), ...splitList(profile.industry), ...splitList(profile.keywords) ].map(compactDiscoveryTerm).filter(Boolean)); } async function discoverAccountsFromGeneralSearch({ token, profile, seedVideos, options, warnings, errors }) { const candidates = extractAuthorAccountCandidates(seedVideos, 'keyword_general_search'); const searched = new Set(asArray(seedVideos).map(video => cleanText(video.keyword)).filter(Boolean)); const terms = discoveryTerms(profile).filter(term => !searched.has(term)).slice(0, options.maxKeywords); for (const term of terms) { try { const response = await requestJson({ method: 'POST', pathUrl: '/douyin/search/fetch_general_search_v2', token, body: { keyword: term, cursor: 0, sort_type: '1', publish_time: '180', filter_duration: '0', content_type: '1', search_id: '', backtrace: '' } }); const videos = extractSearchVideos(response, term) .sort((a, b) => videoScore(b) - videoScore(a)) .slice(0, Math.max(options.videosPerKeyword, 10)); candidates.push(...extractAuthorAccountCandidates(videos, `direction_general_search:${term}`)); } catch (error) { errors.push(apiErrorRecord({ stage: 'account_discovery_general_search', keyword: term }, error)); } } const deduped = dedupeAccountCandidates(candidates).slice(0, Math.max(options.accountsLimit * 3, 10)); if (deduped.length) { warnings.push(`账号方向发现:已从综合搜索视频作者中提取 ${deduped.length} 个候选账号,需用户确认后再进入监听池。`); } return deduped; } function dedupeAccounts(accounts) { const map = new Map(); asArray(accounts).forEach((account, index) => { const sec = parseSecUserId(account); const raw = typeof account === 'object' && account ? account : {}; const nickname = cleanText(raw.nickname || raw.nick_name || raw.name || (typeof account === 'string' ? account : '')); const key = sec || raw.user_id || raw.sec_uid || raw.id || nickname || `account_${index}`; if (!key || map.has(key)) return; map.set(key, typeof account === 'object' ? account : { sec_user_id: sec, nickname }); }); return [...map.values()]; } function summarizeAccountCandidate(candidate) { const evidence = candidate.evidence || {}; return { sec_user_id: candidate.sec_user_id || candidate.sec_uid || candidate.user_id || candidate.id || '', nickname: candidate.nickname || candidate.nick_name || '', follower_count: toNumber(candidate.follower_count ?? candidate.fans_cnt), like_count: toNumber(candidate.like_count ?? candidate.like_cnt), aweme_count: toNumber(candidate.aweme_count ?? candidate.publish_cnt), signature: cleanText(candidate.signature), source: candidate.source || candidate.sourceInput || '', match_reason: candidate.match_reason || '', evidence_keyword: evidence.keyword || '', evidence_aweme_id: evidence.aweme_id || '', evidence_desc: evidence.desc || '' }; } function requiredProfileFields(profile) { return [ { field: 'industry', ok: Boolean(cleanText(profile.industry)) }, { field: 'accountPositioning', ok: Boolean(cleanText(profile.accountPositioning)) }, { field: 'targetAudience', ok: Boolean(cleanText(profile.targetAudience)) }, { field: 'conversionGoal', ok: Boolean(cleanText(profile.conversionGoal)) }, { field: 'keywords', ok: asArray(profile.keywords).length > 0 } ]; } function profileComplete(profile) { return requiredProfileFields(profile).every(item => item.ok); } function readyForDailyReport(profile) { return profileComplete(profile) && ( asArray(profile.confirmedAccounts).length > 0 || asArray(profile.referenceAccounts).length > 0 || Boolean(cleanText(profile.accountDiscoveryDirection)) ); } function stateAfterRun(status, profile) { if (status === 'needs_account_confirmation') return 'awaitingAccountConfirmation'; if (status === 'ok') return 'awaitingReportCalibration'; if (readyForDailyReport(profile)) return 'readyForManualRun'; return 'collectingProfile'; } function buildProfileAfterRun({ profile, rawInput, result, reportResult, outputDir }) { const currentCandidates = asArray(rawInput.accountCandidates).map(summarizeAccountCandidate).filter(item => item.sec_user_id || item.nickname); const updated = { ...profile, platform: profile.platform || 'douyin', candidateAccounts: currentCandidates.length ? currentCandidates : asArray(profile.candidateAccounts), confirmedAccounts: dedupeAccounts(profile.confirmedAccounts), lastReportAt: reportResult.generatedAt || new Date().toISOString(), lastOutputDir: outputDir, lastRunStatus: result.status, lastRunSummary: result.summary, updatedAt: new Date().toISOString() }; updated.state = stateAfterRun(result.status, updated); updated.readyForDailyReport = readyForDailyReport(updated); updated.automationReady = Boolean(updated.automationReady); return updated; } function maybeWriteProfileAfterRun({ args, profile, rawInput, result, reportResult, outputDir }) { if (!bool(args.writeProfile || args['write-profile'], false)) return undefined; const target = path.resolve( hasConcreteArg(args.profileOutput || args['profile-output']) ? (args.profileOutput || args['profile-output']) : hasConcreteArg(args.profile) ? args.profile : path.join('memory', 'douyin-speaking-profile.json') ); const updated = buildProfileAfterRun({ profile, rawInput, result, reportResult, outputDir }); ensureDir(path.dirname(target)); fs.writeFileSync(target, `${JSON.stringify(updated, null, 2)}\n`, 'utf8'); return { path: target, profile: updated }; } function renderAccountCandidatesMarkdown(candidates) { const summarized = asArray(candidates).map(summarizeAccountCandidate).filter(item => item.sec_user_id || item.nickname); if (!summarized.length) return ''; const lines = [ '# 抖音口播账号候选确认', '', '已根据账号昵称或发现方向找到候选账号。P0 规则要求先由用户确认监听池,暂不把候选账号直接当作近期作品监听结果。', '', '## 候选账号' ]; summarized.slice(0, 10).forEach((item, index) => { lines.push(''); lines.push(`### ${index + 1}. ${item.nickname || item.sec_user_id || '未命名账号'}`); lines.push(`- sec_user_id:${item.sec_user_id || '待确认'}`); lines.push(`- 粉丝:${item.follower_count || 0}`); lines.push(`- 作品:${item.aweme_count || 0}`); if (item.signature) lines.push(`- 简介:${item.signature}`); if (item.source) lines.push(`- 来源:${item.source}`); if (item.evidence_desc) lines.push(`- 匹配样本:${item.evidence_desc}`); }); lines.push(''); lines.push('## 需要你确认'); lines.push(''); lines.push('请回复要纳入监听的账号序号,或直接提供确认后的 `sec_user_id`。确认后再运行 `douyin-speaking-daily-runner`,并把确认账号写入 `confirmedAccounts`。'); return lines.join('\n'); } function parseSecUserId(value) { if (!value) return ''; const text = typeof value === 'string' ? value.trim() : String(value.sec_user_id || value.sec_uid || value.user_id || value.id || '').trim(); if (!text) return ''; const urlMatch = text.match(/\/user\/([^/?#\s]+)/); if (urlMatch) return urlMatch[1]; if (/MS4w|MS4x|MS4z/.test(text) || text.length > 40) return text; return ''; } async function fetchKeywordVideos({ token, keywords, options, warnings, errors }) { const videos = []; for (const keyword of keywords.slice(0, options.maxKeywords)) { try { const response = await requestJson({ method: 'POST', pathUrl: '/douyin/search/fetch_general_search_v2', token, body: { keyword, cursor: 0, sort_type: String(options.sortType), publish_time: String(options.publishTime), filter_duration: String(options.filterDuration), content_type: String(options.contentType), search_id: '', backtrace: '' } }); const extracted = extractSearchVideos(response, keyword) .sort((a, b) => videoScore(b) - videoScore(a)) .slice(0, options.videosPerKeyword); if (!extracted.length) warnings.push(`关键词「${keyword}」未解析到视频样本。`); videos.push(...extracted); } catch (error) { errors.push(apiErrorRecord({ stage: 'keyword_search', keyword }, error)); } } return dedupeVideos(videos); } async function searchAccounts({ token, query, warnings, errors }) { try { const response = await requestJson({ method: 'POST', pathUrl: '/douyin/search/fetch_user_search_v2', token, body: { keyword: query, cursor: 0 } }); return extractAccountCandidates(response, query); } catch (error) { errors.push(apiErrorRecord({ stage: 'account_search', query }, error)); warnings.push(`账号方向「${query}」搜索失败:${error.message}`); return []; } } async function fetchAccountPosts({ token, accounts, options, warnings, errors }) { const accountVideos = []; const accountProfiles = []; for (const account of accounts.slice(0, options.accountsLimit)) { const sec = parseSecUserId(account); if (!sec) continue; const nickname = typeof account === 'object' ? account.nickname || account.nick_name || '' : ''; try { const profile = await requestJson({ method: 'GET', pathUrl: '/douyin/app/v3/handler_user_profile', token, query: { sec_user_id: sec } }); accountProfiles.push({ sec_user_id: sec, nickname, raw: profile }); } catch (error) { warnings.push(`账号 ${nickname || sec.slice(0, 12)} 主页信息获取失败:${error.message}`); } try { const response = await requestJson({ method: 'GET', pathUrl: '/douyin/app/v3/fetch_user_post_videos', token, query: { sec_user_id: sec, max_cursor: 0, count: options.postsPerAccount } }); const posts = asArray(response?.data?.aweme_list) .map(aweme => normalizeAweme(aweme, { sourceType: 'account', accountSource: nickname || sec, sec_user_id: sec, nickname })) .filter(item => item.aweme_id); if (!posts.length) warnings.push(`账号 ${nickname || sec.slice(0, 12)} 未返回近期作品。`); accountVideos.push(...posts); } catch (error) { errors.push(apiErrorRecord({ stage: 'account_posts', sec_user_id: sec }, error)); warnings.push(`账号 ${nickname || sec.slice(0, 12)} 近期作品监听失败:${error.message}`); } } return { accountVideos: dedupeVideos(accountVideos), accountProfiles }; } async function fetchCommentsAndReplies({ token, videos, options, warnings, errors }) { const comments = []; const replies = []; for (const video of videos.slice(0, options.maxVideosWithComments)) { let cursor = 0; const videoComments = []; for (let page = 0; page < options.maxCommentPages; page++) { try { const response = await requestJson({ method: 'GET', pathUrl: '/douyin/app/v3/fetch_video_comments', token, query: { aweme_id: video.aweme_id, cursor, count: options.commentsPerPage } }); const pageComments = asArray(response?.data?.comments).map(comment => ({ ...comment, aweme_id: video.aweme_id, item_id: video.aweme_id, keyword: video.keyword, sourceType: video.sourceType })); comments.push(...pageComments); videoComments.push(...pageComments); const hasMore = Number(response?.data?.has_more || 0) > 0; cursor = response?.data?.cursor || 0; if (!hasMore) break; } catch (error) { errors.push(apiErrorRecord({ stage: 'comments', aweme_id: video.aweme_id }, error)); warnings.push(`视频 ${video.aweme_id} 评论抓取失败:${error.message}`); break; } } if (!options.includeReplies) continue; const rootComments = [...videoComments] .filter(comment => Number(comment.reply_comment_total || 0) > 0 && comment.cid) .sort((a, b) => toNumber(b.digg_count) - toNumber(a.digg_count)) .slice(0, options.repliesPerVideo); for (const comment of rootComments) { try { const response = await requestJson({ method: 'GET', pathUrl: '/douyin/app/v3/fetch_video_comment_replies', token, query: { item_id: video.aweme_id, comment_id: comment.cid, cursor: 0, count: options.replyCount } }); asArray(response?.data?.comments).forEach(reply => replies.push({ ...reply, aweme_id: video.aweme_id, item_id: video.aweme_id, root_comment_id: comment.cid, keyword: video.keyword, sourceType: video.sourceType })); } catch (error) { errors.push(apiErrorRecord({ stage: 'comment_replies', aweme_id: video.aweme_id, comment_id: comment.cid }, error)); } } } return { comments, replies }; } function resolveDailyReportScript() { const candidates = [ path.join(__dirname, 'douyin-speaking-daily-report.js'), path.join(process.cwd(), 'scripts', 'tools', 'douyin-speaking-daily-report.js'), path.join(process.cwd(), 'douyin-speaking-daily', 'scripts', 'douyin-speaking-daily-report.js') ]; return candidates.find(filePath => fs.existsSync(filePath)); } function extractLastJson(stdout) { const text = String(stdout || '').trim(); for (let i = text.lastIndexOf('{'); i >= 0; i = text.lastIndexOf('{', i - 1)) { try { return JSON.parse(text.slice(i)); } catch { continue; } } return undefined; } function reportPassThroughArgs(args) { const out = {}; const selectedTopicIndex = firstConcreteArg(args.selectedTopicIndex, args['selected-topic-index'], args.topicIndex, args['topic-index']); const scriptSession = firstConcreteArg(args.scriptSession, args['script-session']); const feedback = firstConcreteArg(args.feedback, args.userFeedback, args['user-feedback']); const finalize = firstConcreteArg(args.finalize, args.final); const scriptMemory = firstConcreteArg(args.scriptMemory, args['script-memory']); const historyMemory = firstConcreteArg(args.historyMemory, args['history-memory']); const writeHistory = firstConcreteArg(args.writeHistory, args['write-history']); const message = firstConcreteArg(args.message, args.userMessage, args['user-message'], args.intent, args.command); if (selectedTopicIndex !== undefined) out.selectedTopicIndex = selectedTopicIndex; if (scriptSession !== undefined) out.scriptSession = scriptSession; if (feedback !== undefined) out.feedback = feedback; if (finalize !== undefined) out.finalize = finalize; if (scriptMemory !== undefined) out.scriptMemory = scriptMemory; if (historyMemory !== undefined) out.historyMemory = historyMemory; if (writeHistory !== undefined) out.writeHistory = writeHistory; if (message !== undefined) out.message = message; return out; } function appendOptionalCliArgs(target, mapping) { Object.entries(mapping).forEach(([key, value]) => { if (value === undefined || value === null || value === '') return; target.push(`--${key}`, String(value)); }); } function runDailyReport({ profile, rawPath, outputDir, args = {} }) { const reportScript = resolveDailyReportScript(); if (!reportScript) throw new Error('Cannot find douyin-speaking-daily-report.js'); const profilePath = path.join(outputDir, 'profile.resolved.json'); fs.writeFileSync(profilePath, JSON.stringify(profile, null, 2), 'utf8'); const reportArgs = reportPassThroughArgs(args); const reportModule = require(reportScript); if (typeof reportModule.runReportWithArgs === 'function') { return reportModule.runReportWithArgs({ profile: profilePath, input: rawPath, output: outputDir, ...reportArgs }); } const cliArgs = [ reportScript, '--profile', profilePath, '--input', rawPath, '--output', outputDir ]; appendOptionalCliArgs(cliArgs, { 'selected-topic-index': reportArgs.selectedTopicIndex, 'script-session': reportArgs.scriptSession, feedback: reportArgs.feedback, finalize: reportArgs.finalize, 'script-memory': reportArgs.scriptMemory, 'history-memory': reportArgs.historyMemory, 'write-history': reportArgs.writeHistory, message: reportArgs.message }); const child = spawnSync(process.execPath, cliArgs, { cwd: process.cwd(), encoding: 'utf8', maxBuffer: 1024 * 1024 * 100 }); if (child.error) throw child.error; if (child.stderr) process.stderr.write(child.stderr); if (child.status !== 0) { throw new Error(`daily report failed: ${child.stderr || child.stdout}`); } const parsed = extractLastJson(child.stdout); if (!parsed) throw new Error('daily report did not emit JSON'); return parsed; } function resolveVideoTranscriberScript() { const candidates = [ path.join(__dirname, 'douyin-video-transcriber.js'), path.join(process.cwd(), 'scripts', 'tools', 'douyin-video-transcriber.js'), path.join(process.cwd(), 'douyin-speaking-daily', 'scripts', 'douyin-video-transcriber.js') ]; return candidates.find(filePath => fs.existsSync(filePath)); } function videoId(video) { return cleanText(video?.aweme_id || video?.awemeId || video?.id || video?.raw?.aweme_id || video?.raw?.awemeId); } function videoShareUrl(video) { return cleanText(video?.share_url || video?.url || video?.raw?.share_url || video?.raw?.url); } function videoDurationMs(video) { return intValue(video?.duration || video?.durationMs || video?.raw?.duration || video?.raw?.duration_ms || video?.raw?.durationMs, 0) || 0; } function transcriptVideoId(transcript) { return cleanText(transcript?.awemeId || transcript?.aweme_id || transcript?.videoId || transcript?.itemId); } function transcriptHasText(transcript) { return Boolean(cleanText(transcript?.text)); } function dedupeTranscripts(transcripts) { const map = new Map(); asArray(transcripts).filter(Boolean).forEach((transcript, index) => { const id = transcriptVideoId(transcript); const key = id || `transcript_${index}`; const existing = map.get(key); if (!existing || (!transcriptHasText(existing) && transcriptHasText(transcript))) { map.set(key, transcript); } }); return [...map.values()]; } function simpleHash(value) { const text = cleanText(value); let hash = 0; for (let i = 0; i < text.length; i++) { hash = ((hash << 5) - hash + text.charCodeAt(i)) | 0; } return Math.abs(hash).toString(36); } function truncateText(text, limit) { const normalized = cleanText(text); return normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized; } function videoTitle(video) { return truncateText(video?.title || video?.desc || video?.content || video?.raw?.title || video?.raw?.desc, 80); } function videoAuthorName(video) { const author = video?.author || video?.raw?.author || video?.raw?.author_user_id; if (typeof author === 'string') return cleanText(author); return cleanText(author?.nickname || author?.name || video?.authorName || video?.raw?.authorName); } function resolveFilePathMaybe(filePath) { if (!hasConcreteArg(filePath)) return ''; return path.resolve(filePath); } function emptyTranscriptCache() { return { version: 1, updatedAt: '', items: {} }; } function normalizeTranscriptCache(raw) { const cache = emptyTranscriptCache(); if (!raw) return cache; const items = Array.isArray(raw) ? raw : Array.isArray(raw.items) ? raw.items : Object.entries(raw.items || {}).map(([awemeId, value]) => ({ awemeId, ...(value || {}) })); items.forEach(item => { const transcript = item?.transcript || item; const id = cleanText(item?.awemeId || item?.aweme_id || transcriptVideoId(transcript)); if (!id) return; cache.items[id] = { awemeId: id, provider: item?.provider || transcript?.provider || '', orderId: item?.orderId || transcript?.orderId || '', sourceUrl: item?.sourceUrl || transcript?.sourceUrl || '', mediaUrlHash: item?.mediaUrlHash || simpleHash(item?.sourceUrl || transcript?.sourceUrl || id), transcriptPath: item?.transcriptPath || '', textLength: intValue(item?.textLength || cleanText(transcript?.text).length, 0) || 0, generatedAt: item?.generatedAt || item?.createdAt || '', lastUsedAt: item?.lastUsedAt || '', transcript: transcriptHasText(transcript) ? transcript : undefined }; }); cache.updatedAt = raw.updatedAt || ''; return cache; } function readTranscriptCache(cachePath, warnings) { const resolved = resolveFilePathMaybe(cachePath); if (!resolved || !fs.existsSync(resolved)) return emptyTranscriptCache(); try { return normalizeTranscriptCache(readJson(resolved)); } catch (error) { warnings.push(`transcript cache 读取失败,已忽略:${error.message}`); return emptyTranscriptCache(); } } function transcriptFromCacheEntry(entry) { if (!entry) return null; if (transcriptHasText(entry.transcript)) { return { ...entry.transcript, awemeId: transcriptVideoId(entry.transcript) || entry.awemeId }; } if (hasConcreteArg(entry.transcriptPath) && fs.existsSync(entry.transcriptPath)) { try { const transcript = readJson(entry.transcriptPath); if (transcriptHasText(transcript)) return { ...transcript, awemeId: transcriptVideoId(transcript) || entry.awemeId }; } catch { return null; } } return null; } function makeTranscriptQueueEntry(video, rank, status, reason, options, extra = {}) { const id = videoId(video); return { rank, awemeId: id, title: videoTitle(video), author: videoAuthorName(video), sourceUrl: videoShareUrl(video), mediaUrlHash: simpleHash(videoShareUrl(video) || id), durationMs: videoDurationMs(video), score: Math.round(videoScore(video)), provider: options.autoTranscriptProvider, status, reason, transcriptPath: '', orderId: '', textLength: 0, updatedAt: new Date().toISOString(), ...extra }; } function updateQueueEntry(queue, awemeId, patch) { const entry = queue.find(item => item.awemeId === awemeId); if (!entry) return; Object.assign(entry, patch, { updatedAt: new Date().toISOString() }); } function selectAutoTranscriptTargets(rawInput, options, cache) { const existingIds = new Set(asArray(rawInput.transcripts).filter(transcriptHasText).map(transcriptVideoId).filter(Boolean)); const maxDurationMs = options.autoTranscriptMaxDurationMs; const topN = options.autoTranscriptTopN; const dailyBudget = Number.isFinite(options.transcriptDailyBudget) ? options.transcriptDailyBudget : topN; let selectedCount = 0; let newRemaining = dailyBudget; const queue = []; const cached = []; const targets = []; dedupeVideos([...asArray(rawInput.videos), ...asArray(rawInput.accountVideos)]) .sort((a, b) => videoScore(b) - videoScore(a)) .forEach((video, index) => { const id = videoId(video); const url = videoShareUrl(video); if (!id || !url) { queue.push(makeTranscriptQueueEntry(video, index + 1, 'skipped', 'missing_video_id_or_url', options)); return; } if (selectedCount >= topN) { queue.push(makeTranscriptQueueEntry(video, index + 1, 'skipped', 'outside_top_n', options)); return; } if (existingIds.has(id)) { selectedCount += 1; queue.push(makeTranscriptQueueEntry(video, index + 1, 'existing', 'transcript_already_in_input', options)); return; } const cacheEntry = options.transcriptReuseCache ? cache.items[id] : null; const cachedTranscript = transcriptFromCacheEntry(cacheEntry); if (cachedTranscript) { selectedCount += 1; cached.push({ video, transcript: cachedTranscript, entry: cacheEntry }); queue.push(makeTranscriptQueueEntry(video, index + 1, 'cached', 'transcript_cache_hit', options, { transcriptPath: cacheEntry.transcriptPath || '', orderId: cacheEntry.orderId || cachedTranscript.orderId || '', provider: cacheEntry.provider || cachedTranscript.provider || options.autoTranscriptProvider, textLength: cacheEntry.textLength || cleanText(cachedTranscript.text).length })); return; } if (maxDurationMs && videoDurationMs(video) && videoDurationMs(video) > maxDurationMs) { queue.push(makeTranscriptQueueEntry(video, index + 1, 'skipped', 'duration_over_limit', options)); return; } if (newRemaining <= 0) { queue.push(makeTranscriptQueueEntry(video, index + 1, 'skipped', 'daily_budget_exhausted', options)); return; } selectedCount += 1; newRemaining -= 1; targets.push(video); queue.push(makeTranscriptQueueEntry(video, index + 1, 'pending', 'selected_for_transcription', options)); }); return { targets, cached, queue }; } function updateTranscriptCache(cache, result, video) { if (result.status !== 'ok' || !transcriptHasText(result.transcript)) return false; const id = result.awemeId || videoId(video); cache.items[id] = { awemeId: id, provider: result.provider, orderId: result.orderId, sourceUrl: videoShareUrl(video), mediaUrlHash: simpleHash(videoShareUrl(video) || id), transcriptPath: result.transcriptPath, textLength: result.textLength || cleanText(result.transcript.text).length, generatedAt: new Date().toISOString(), lastUsedAt: new Date().toISOString(), transcript: result.transcript }; cache.updatedAt = new Date().toISOString(); return true; } function writeTranscriptCache(cachePath, cache, warnings) { const resolved = resolveFilePathMaybe(cachePath); if (!resolved) return ''; try { ensureDir(path.dirname(resolved)); fs.writeFileSync(resolved, JSON.stringify(cache, null, 2), 'utf8'); return resolved; } catch (error) { warnings.push(`transcript cache 写入失败:${error.message}`); return ''; } } function runVideoTranscriber({ script, video, outputDir, options }) { const id = videoId(video); const transcriptDir = path.join(outputDir, 'transcripts', id); ensureDir(transcriptDir); const args = [ script, '--provider', options.autoTranscriptProvider, '--douyin-url', videoShareUrl(video), '--aweme-id', id, '--output', transcriptDir, '--poll-interval-ms', String(options.transcriptPollIntervalMs), '--max-polls', String(options.transcriptMaxPolls), '--max-download-mb', String(options.transcriptMaxDownloadMb), '--download-timeout-ms', String(options.transcriptDownloadTimeoutMs), '--download-idle-timeout-ms', String(options.transcriptDownloadIdleTimeoutMs) ]; const durationMs = videoDurationMs(video); if (durationMs) args.push('--duration-ms', String(durationMs)); const child = spawnSync(process.execPath, args, { cwd: process.cwd(), encoding: 'utf8', maxBuffer: 1024 * 1024 * 100 }); const parsed = extractLastJson(child.stdout); const transcriptPath = path.join(transcriptDir, 'transcript.json'); let transcript; if (fs.existsSync(transcriptPath)) transcript = readJson(transcriptPath); return { status: child.status === 0 ? parsed?.status || transcript?.status || 'ok' : 'error', awemeId: id, outputDir: transcriptDir, transcriptPath: fs.existsSync(transcriptPath) ? transcriptPath : '', orderId: parsed?.orderId || transcript?.orderId || '', provider: parsed?.provider || transcript?.provider || options.autoTranscriptProvider, textLength: parsed?.textLength || cleanText(transcript?.text).length || 0, segmentCount: parsed?.segmentCount || asArray(transcript?.segments).length, message: child.status === 0 ? '' : cleanText(child.stderr || child.stdout), transcript }; } function autoGenerateTranscripts({ rawInput, outputDir, options, warnings, errors }) { const queuePath = path.join(outputDir, 'transcript-queue.json'); const cache = readTranscriptCache(options.transcriptCachePath, warnings); const empty = { transcripts: [], results: [], queue: [], queuePath: '', cachePath: resolveFilePathMaybe(options.transcriptCachePath), cachedCount: 0, generatedCount: 0 }; if (!options.autoTranscriptTopN) return empty; const selection = selectAutoTranscriptTargets(rawInput, options, cache); const results = []; const transcripts = []; let cacheChanged = false; selection.cached.forEach(({ video, transcript, entry }) => { const id = videoId(video); const usedAt = new Date().toISOString(); cache.items[id] = { ...(entry || {}), awemeId: id, lastUsedAt: usedAt, transcript }; cache.updatedAt = usedAt; cacheChanged = true; transcripts.push(transcript); results.push({ status: 'cached', awemeId: id, outputDir: '', transcriptPath: entry?.transcriptPath || '', orderId: entry?.orderId || transcript.orderId || '', provider: entry?.provider || transcript.provider || options.autoTranscriptProvider, textLength: entry?.textLength || cleanText(transcript.text).length, segmentCount: asArray(transcript.segments).length, message: 'transcript cache hit' }); }); let script = ''; if (selection.targets.length) { script = resolveVideoTranscriberScript(); if (!script) { warnings.push('autoTranscript 已开启,但未找到 douyin-video-transcriber.js,跳过逐字稿补跑。'); selection.targets.forEach(video => { const id = videoId(video); updateQueueEntry(selection.queue, id, { status: 'skipped', reason: 'missing_transcriber_script' }); errors.push({ stage: 'auto_transcript', aweme_id: id, status: 'missing_transcriber_script' }); }); } } if (script) { selection.targets.forEach(video => { const result = runVideoTranscriber({ script, video, outputDir, options }); const resultSummary = { status: result.status, awemeId: result.awemeId, outputDir: result.outputDir, transcriptPath: result.transcriptPath, orderId: result.orderId, provider: result.provider, textLength: result.textLength, segmentCount: result.segmentCount, message: result.message }; results.push(resultSummary); updateQueueEntry(selection.queue, result.awemeId, { status: result.status === 'ok' && transcriptHasText(result.transcript) ? 'generated' : 'failed', reason: result.status === 'ok' && transcriptHasText(result.transcript) ? 'transcription_finished' : 'transcription_failed', transcriptPath: result.transcriptPath, orderId: result.orderId, provider: result.provider, textLength: result.textLength, error: result.message || '' }); if (result.status === 'ok' && transcriptHasText(result.transcript)) { transcripts.push(result.transcript); cacheChanged = updateTranscriptCache(cache, result, video) || cacheChanged; } else { errors.push({ stage: 'auto_transcript', aweme_id: result.awemeId, status: result.status, message: result.message || 'transcript did not return text' }); warnings.push(`视频 ${result.awemeId} 自动转写未产出可用文本,日报将继续按结构推断。`); } }); } if (!selection.cached.length && !selection.targets.length) { warnings.push('autoTranscript 已开启,但没有找到可转写的视频链接,或候选已存在逐字稿。'); } const queueDoc = { version: 1, generatedAt: new Date().toISOString(), policy: { provider: options.autoTranscriptProvider, topN: options.autoTranscriptTopN, maxDurationMs: options.autoTranscriptMaxDurationMs, dailyBudget: options.transcriptDailyBudget, reuseCache: options.transcriptReuseCache }, cachePath: resolveFilePathMaybe(options.transcriptCachePath), items: selection.queue }; fs.writeFileSync(queuePath, JSON.stringify(queueDoc, null, 2), 'utf8'); const cachePath = cacheChanged ? writeTranscriptCache(options.transcriptCachePath, cache, warnings) : resolveFilePathMaybe(options.transcriptCachePath); return { transcripts, results, queue: selection.queue, queuePath, cachePath, cachedCount: selection.cached.length, generatedCount: results.filter(item => item.status === 'ok').length }; } async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { console.log(usage()); return; } const profile = loadProfile(args); const date = args.date || new Date().toISOString().slice(0, 10); const outputDir = path.resolve(args.output || path.join( process.cwd(), 'openclaw-voc-output', 'douyin-speaking-daily', slugify(profile.projectName), date )); ensureDir(outputDir); const transcriptPolicy = profile.transcriptPolicy || {}; const options = { maxKeywords: intValue(args.maxKeywords || args['max-keywords'], 5) || 5, videosPerKeyword: intValue(args.videosPerKeyword || args['videos-per-keyword'], 5) || 5, publishTime: args.publishTime || args['publish-time'] || '7', sortType: args.sortType || args['sort-type'] || '1', filterDuration: args.filterDuration || args['filter-duration'] || '0', contentType: args.contentType || args['content-type'] || '1', accountsLimit: intValue(args.accountsLimit || args['accounts-limit'], 5) || 5, postsPerAccount: intValue(args.postsPerAccount || args['posts-per-account'], 8) || 8, autoConfirmAccounts: bool(args.autoConfirmAccounts || args['auto-confirm-accounts'], false), maxVideosWithComments: intValue(args.maxVideosWithComments || args['max-videos-with-comments'], 8) || 8, maxCommentPages: intValue(args.maxCommentPages || args['max-comment-pages'], 1) || 1, commentsPerPage: intValue(args.commentsPerPage || args['comments-per-page'], 20) || 20, includeReplies: bool(args.includeReplies || args['include-replies'], true), repliesPerVideo: intValue(args.repliesPerVideo || args['replies-per-video'], 2) || 2, replyCount: intValue(args.replyCount || args['reply-count'], 20) || 20, autoTranscriptTopN: intValue(firstConcreteArg(args.autoTranscriptTopN, args['auto-transcript-top-n'], transcriptPolicy.topN), 0) || 0, autoTranscriptProvider: firstConcreteArg(args.autoTranscriptProvider, args['auto-transcript-provider'], transcriptPolicy.provider) || 'iflytek-gateway', autoTranscriptMaxDurationMs: intValue(firstConcreteArg(args.autoTranscriptMaxDurationMs, args['auto-transcript-max-duration-ms'], transcriptPolicy.maxDurationMs), 600000) || 600000, transcriptPollIntervalMs: intValue(args.transcriptPollIntervalMs || args['transcript-poll-interval-ms'], 4000) || 4000, transcriptMaxPolls: intValue(args.transcriptMaxPolls || args['transcript-max-polls'], 30) || 30, transcriptMaxDownloadMb: intValue(args.transcriptMaxDownloadMb || args['transcript-max-download-mb'], 80) || 80, transcriptDownloadTimeoutMs: intValue(args.transcriptDownloadTimeoutMs || args['transcript-download-timeout-ms'], 120000) || 120000, transcriptDownloadIdleTimeoutMs: intValue(args.transcriptDownloadIdleTimeoutMs || args['transcript-download-idle-timeout-ms'], 15000) || 15000, transcriptCachePath: firstConcreteArg(args.transcriptCache, args['transcript-cache'], transcriptPolicy.cachePath) || path.join('memory', 'douyin-speaking-transcript-cache.json'), transcriptReuseCache: bool(firstConcreteArg(args.transcriptReuseCache, args['transcript-reuse-cache'], transcriptPolicy.reuseCache), true), transcriptDailyBudget: intValue(firstConcreteArg(args.transcriptDailyBudget, args['transcript-daily-budget'], transcriptPolicy.dailyBudget), undefined) }; if (!Number.isFinite(options.transcriptDailyBudget)) options.transcriptDailyBudget = options.autoTranscriptTopN; const warnings = []; const errors = []; const messageText = naturalCommandText(args); const autoInputPath = !hasConcreteArg(args.input) && isContinuationMessage(messageText) ? findLatestFileByName('runner-raw-input.json') : ''; let rawInput = readJsonMaybe(firstConcreteArg(args.input, autoInputPath)); if (hasConcreteArg(args.input) && !rawInput) { throw new Error(`无法读取 --input:请传入存在的 JSON 文件路径,或传入合法 JSON 字符串。收到: ${args.input}`); } if (rawInput) { warnings.push(autoInputPath && !hasConcreteArg(args.input) ? `已根据自然话术自动续接最近一次 raw 数据:${autoInputPath}` : '使用 input 中的已有 raw 数据,跳过在线采集。'); } else if (bool(args.dryRun || args['dry-run'], false)) { rawInput = { videos: [], accountVideos: [], comments: [], replies: [], dryRun: true }; warnings.push('dry-run 模式未调用抖音网关。'); } else { const token = loadVocToken(); if (!token) { throw new Error('缺少 VOC Token:请设置 VOC_TOKEN,或运行 node ~/.openclaw/tools/set-voc-token.js '); } const keywordVideos = await fetchKeywordVideos({ token, keywords: profile.keywords, options, warnings, errors }); const accountCandidates = []; const confirmedAccounts = [...profile.confirmedAccounts]; const explicitAccountInputs = uniqueStrings([ ...splitList(args.accounts), ...profile.referenceAccounts ]); for (const account of explicitAccountInputs) { const sec = parseSecUserId(account); if (sec) { confirmedAccounts.push({ sec_user_id: sec, nickname: account }); } else if (account) { const candidates = await searchAccounts({ token, query: account, warnings, errors }); if (candidates.length) { accountCandidates.push(...candidates.map(candidate => ({ ...candidate, sourceInput: account }))); warnings.push(`账号「${account}」已搜索到候选账号,等待用户确认后再进入近期作品监听。`); } else { warnings.push(`账号「${account}」未解析为 sec_user_id,也未搜索到候选账号。`); } } } const dedupedConfirmedAccounts = dedupeAccounts(confirmedAccounts); if (profile.accountDiscoveryDirection && !dedupedConfirmedAccounts.length) { const generalSearchCandidates = await discoverAccountsFromGeneralSearch({ token, profile, seedVideos: keywordVideos, options, warnings, errors }); accountCandidates.push(...generalSearchCandidates); for (const query of discoveryTerms(profile).slice(0, Math.min(options.maxKeywords, 3))) { const candidates = await searchAccounts({ token, query, warnings, errors }); accountCandidates.push(...candidates); } const candidatePool = dedupeAccountCandidates(accountCandidates).slice(0, Math.max(options.accountsLimit * 5, 20)); accountCandidates.length = 0; accountCandidates.push(...candidatePool); if (options.autoConfirmAccounts) { dedupedConfirmedAccounts.push(...candidatePool.filter(item => item.sec_user_id).slice(0, options.accountsLimit)); warnings.push('已按 autoConfirmAccounts 使用候选账号 Top 结果进入作品监听;正式使用建议先让用户确认。'); } else if (candidatePool.length) { warnings.push('账号方向发现已产出候选账号,但未进入作品监听;需要用户确认 confirmedAccounts。'); } } const uniqueAccountCandidates = dedupeAccountCandidates(accountCandidates).slice(0, Math.max(options.accountsLimit * 5, 20)); accountCandidates.length = 0; accountCandidates.push(...uniqueAccountCandidates); const { accountVideos, accountProfiles } = await fetchAccountPosts({ token, accounts: dedupeAccounts(dedupedConfirmedAccounts), options, warnings, errors }); const commentTargets = dedupeVideos([...keywordVideos, ...accountVideos]) .sort((a, b) => videoScore(b) - videoScore(a)); const { comments, replies } = await fetchCommentsAndReplies({ token, videos: commentTargets, options, warnings, errors }); rawInput = { metadata: { projectName: profile.projectName, generatedAt: new Date().toISOString(), options, accountCandidateCount: accountCandidates.length, accountProfileCount: accountProfiles.length, errors }, videos: keywordVideos, accountVideos, comments, replies, accountCandidates, accountProfiles, transcripts: asArray(readJsonMaybe(args.transcripts) || []) }; } const extraTranscripts = asArray(readJsonMaybe(args.transcripts) || []); if (extraTranscripts.length) { rawInput.transcripts = dedupeTranscripts([...asArray(rawInput.transcripts), ...extraTranscripts]); } const autoTranscript = autoGenerateTranscripts({ rawInput, outputDir, options, warnings, errors }); rawInput.transcripts = dedupeTranscripts([...asArray(rawInput.transcripts), ...autoTranscript.transcripts]); rawInput.metadata = { ...(rawInput.metadata || {}), autoTranscript: { enabled: Boolean(options.autoTranscriptTopN), provider: options.autoTranscriptProvider, topN: options.autoTranscriptTopN, maxDurationMs: options.autoTranscriptMaxDurationMs, dailyBudget: options.transcriptDailyBudget, reuseCache: options.transcriptReuseCache, cachePath: autoTranscript.cachePath, queuePath: autoTranscript.queuePath, cachedCount: autoTranscript.cachedCount, generatedCount: autoTranscript.generatedCount, queuedCount: autoTranscript.queue.length, results: autoTranscript.results } }; const rawPath = path.join(outputDir, 'runner-raw-input.json'); fs.writeFileSync(rawPath, JSON.stringify(rawInput, null, 2), 'utf8'); const reportResult = runDailyReport({ profile, rawPath, outputDir, args }); const accountCandidatesPath = path.join(outputDir, 'account-candidates.json'); fs.writeFileSync(accountCandidatesPath, JSON.stringify(asArray(rawInput.accountCandidates), null, 2), 'utf8'); const candidateMarkdown = renderAccountCandidatesMarkdown(rawInput.accountCandidates); const accountCandidatesMdPath = path.join(outputDir, 'account-candidates.md'); if (candidateMarkdown) fs.writeFileSync(accountCandidatesMdPath, candidateMarkdown, 'utf8'); const hasAccountCandidates = asArray(rawInput.accountCandidates).length > 0; const hasAccountVideos = asArray(rawInput.accountVideos).length > 0; const accountPostErrors = errors.filter(error => error.stage === 'account_posts'); const gatewayAuthErrors = errors.filter(isGatewayAuthError); const hasAnyCollectedData = asArray(rawInput.videos).length || asArray(rawInput.accountVideos).length || asArray(rawInput.comments).length || asArray(rawInput.replies).length; if (gatewayAuthErrors.length && !hasAnyCollectedData) { warnings.unshift('VOC 社媒网关鉴权被拒绝:Token 有效但 APIGAuth 可能未绑定到当前 Company,或后端仍按 company-only 查询。请先修复授权绑定后重跑同一命令。'); } const status = gatewayAuthErrors.length && !hasAnyCollectedData ? 'gateway_auth_blocked' : accountPostErrors.length && !hasAccountVideos ? 'account_monitor_failed' : hasAccountCandidates && !hasAccountVideos && !options.autoConfirmAccounts ? 'needs_account_confirmation' : reportResult.status; const reportMarkdown = reportResult.assistantMessage || reportResult.contentMarkdown || reportResult.chatMarkdown || reportResult.markdown || ''; const fullMarkdown = candidateMarkdown && reportMarkdown ? `${candidateMarkdown}\n\n---\n\n${reportMarkdown}` : candidateMarkdown || reportMarkdown; const markdown = candidateMarkdown || reportMarkdown || fullMarkdown; const result = { status, assistantMessage: markdown, markdown, outputDir, files: [ ...asArray(reportResult.files), rawPath, accountCandidatesPath, ...(candidateMarkdown ? [accountCandidatesMdPath] : []), ...(autoTranscript.queuePath ? [autoTranscript.queuePath] : []), ...(autoTranscript.cachePath && fs.existsSync(autoTranscript.cachePath) ? [autoTranscript.cachePath] : []), ...autoTranscript.results.flatMap(result => { const files = []; if (result.transcriptPath) files.push(result.transcriptPath); if (result.outputDir) files.push(path.join(result.outputDir, 'transcript.txt')); return files; }) ], summary: { ...reportResult.summary, accountCandidateCount: asArray(rawInput.accountCandidates).length, accountVideoCount: asArray(rawInput.accountVideos).length, autoTranscriptCachedCount: autoTranscript.cachedCount, autoTranscriptGeneratedCount: autoTranscript.generatedCount, autoTranscriptQueueCount: autoTranscript.queue.length, errorCount: errors.length, accountPostErrorCount: accountPostErrors.length }, qualityGate: reportResult.qualityGate || {}, scriptMemorySignals: reportResult.scriptMemorySignals || {}, transcriptResults: autoTranscript.results, transcriptQueuePath: autoTranscript.queuePath, transcriptCachePath: autoTranscript.cachePath, accountCandidates: asArray(rawInput.accountCandidates).map(summarizeAccountCandidate).slice(0, 20), warnings, errors, nextAction: status === 'gateway_auth_blocked' ? 'repair_apigauth_company_binding_then_rerun' : undefined, oneLineJudgement: reportResult.oneLineJudgement, topicIdeaCount: reportResult.topicIdeaCount, topicIdeasPreview: asArray(reportResult.topicIdeasPreview), selectedTopicIndex: reportResult.selectedTopicIndex, selectedTopic: reportResult.selectedTopic, selectedScriptPath: reportResult.selectedScriptPath || '', scriptSessionPath: reportResult.scriptSessionPath || '', scriptSessionStatus: reportResult.scriptSessionStatus || '', scriptVersion: reportResult.scriptVersion || '', scriptMemoryPath: reportResult.scriptMemoryPath || '', historyMemoryPath: reportResult.historyMemoryPath || '', naturalCommand: reportResult.naturalCommand || {}, topicPreferenceMemoryPath: reportResult.topicPreferenceMemoryPath || '', autoResolvedInputPath: reportResult.autoResolvedInputPath || autoInputPath || '', autoResolvedScriptSessionPath: reportResult.autoResolvedScriptSessionPath || '', calibrationPrompt: reportResult.calibrationPrompt || '', calibrationQuestions: asArray(reportResult.calibrationQuestions), fullReportPath: reportResult.fullReportPath || asArray(reportResult.files).find(file => /daily-report\.md$/i.test(String(file))) || '' }; const profileWrite = maybeWriteProfileAfterRun({ args, profile, rawInput, result, reportResult, outputDir }); if (profileWrite) { result.files.push(profileWrite.path); result.profileState = profileWrite.profile.state; result.readyForDailyReport = profileWrite.profile.readyForDailyReport; } emitResult(args, result); } main().catch(error => { console.error(error.message); process.exit(1); });