| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577 |
- #!/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 <profile.json> --output <out-dir>',
- ' node scripts/tools/douyin-speaking-daily-runner.js --project <name> --industry <name> --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 <token>');
- }
- 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);
- });
|