industry-trend-runner.js 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const {
  5. buildReport,
  6. keywordMatrixFromProfile,
  7. parseList
  8. } = require('./industry-trend-report');
  9. const { collectXiaohongshuDataset } = require('./xiaohongshu-trend-collector');
  10. const DEFAULT_RESULT_PREFIX = 'INDUSTRY_TREND_RUNNER_RESULT';
  11. function parseArgs(argv) {
  12. const args = {};
  13. for (let i = 0; i < argv.length; i++) {
  14. const token = argv[i];
  15. if (!token.startsWith('--')) continue;
  16. const eq = token.indexOf('=');
  17. if (eq >= 0) {
  18. args[token.slice(2, eq)] = token.slice(eq + 1);
  19. } else {
  20. const key = token.slice(2);
  21. const next = argv[i + 1];
  22. if (next && !next.startsWith('--')) {
  23. args[key] = next;
  24. i++;
  25. } else {
  26. args[key] = true;
  27. }
  28. }
  29. }
  30. return args;
  31. }
  32. function ensureDir(dirPath) {
  33. fs.mkdirSync(dirPath, { recursive: true });
  34. }
  35. function readJson(filePath) {
  36. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  37. }
  38. function writeJson(filePath, data) {
  39. ensureDir(path.dirname(filePath));
  40. fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
  41. }
  42. function toBool(value, fallback = false) {
  43. if (typeof value === 'boolean') return value;
  44. if (value === undefined || value === null || value === '') return fallback;
  45. return ['1', 'true', 'yes', 'y', 'on'].includes(String(value).toLowerCase());
  46. }
  47. function cleanPreferenceItems(value) {
  48. return parseList(value)
  49. .map(item => item
  50. .replace(/^(这些|这类|这种|方向是|方向:|方向:)/, '')
  51. .replace(/(这些方向|这些内容|这类方向|这类内容|这种方向|这种内容|方向|内容)$/g, '')
  52. .trim())
  53. .filter(Boolean);
  54. }
  55. function defaultProfilePath() {
  56. return path.resolve('industry-trend-intelligence/memory-templates/industry-trend-profile.json');
  57. }
  58. function hasInlineProfileArgs(args) {
  59. return Boolean(
  60. args.project ||
  61. args.industry ||
  62. args['business-type'] ||
  63. args.businessType ||
  64. args.keywords ||
  65. args.platforms ||
  66. args['target-audience'] ||
  67. args.targetAudience ||
  68. args['trend-questions'] ||
  69. args.trendQuestions ||
  70. args['blocked-directions'] ||
  71. args.blockedDirections
  72. );
  73. }
  74. function loadProfile(args) {
  75. const explicit = args.profile ? path.resolve(args.profile) : undefined;
  76. const profilePath = explicit && fs.existsSync(explicit) ? explicit : defaultProfilePath();
  77. const profile = explicit && fs.existsSync(explicit)
  78. ? readJson(profilePath)
  79. : (hasInlineProfileArgs(args) ? {} : (fs.existsSync(profilePath) ? readJson(profilePath) : {}));
  80. if (args.project) profile.project = args.project;
  81. if (args.industry) profile.industry = args.industry;
  82. if (args['business-type'] || args.businessType) profile.businessType = args['business-type'] || args.businessType;
  83. if (args.keywords) profile.keywords = parseList(args.keywords);
  84. if (args.platforms) profile.platforms = parseList(args.platforms);
  85. if (args['target-audience'] || args.targetAudience) profile.targetAudience = parseList(args['target-audience'] || args.targetAudience);
  86. if (args['trend-questions'] || args.trendQuestions) profile.trendQuestions = parseList(args['trend-questions'] || args.trendQuestions);
  87. if (args['blocked-directions'] || args.blockedDirections) profile.blockedDirections = parseList(args['blocked-directions'] || args.blockedDirections);
  88. return { profile, profilePath };
  89. }
  90. function inferProfileFromMessage(message, profile) {
  91. const text = String(message || '');
  92. if (!text) return profile;
  93. const next = { ...profile };
  94. if (text.includes('家装') || text.includes('全屋定制') || text.includes('装修')) {
  95. next.industry = next.industry || '家装全屋定制';
  96. next.businessType = next.businessType || '门店/设计服务/全屋定制服务商';
  97. next.platforms = next.platforms && next.platforms.length ? next.platforms : ['xiaohongshu'];
  98. next.keywords = next.keywords && next.keywords.length ? next.keywords : [
  99. '全屋定制',
  100. '奶油风装修',
  101. '小户型收纳',
  102. '衣柜设计避坑',
  103. '厨房橱柜设计',
  104. '装修翻车'
  105. ];
  106. next.mustTrackSignals = next.mustTrackSignals && next.mustTrackSignals.length ? next.mustTrackSignals : [
  107. '装修风格',
  108. '设计元素',
  109. '收纳需求',
  110. '环保顾虑',
  111. '预算焦虑',
  112. '翻车风险'
  113. ];
  114. }
  115. return next;
  116. }
  117. function updateMemoryFromMessage(message, memoryPath) {
  118. const text = String(message || '').trim();
  119. if (!text) return undefined;
  120. const memory = fs.existsSync(memoryPath) ? readJson(memoryPath) : {
  121. preferredSignals: [],
  122. blockedDirections: [],
  123. preferredOutput: [],
  124. updatedAt: ''
  125. };
  126. const keepMatch = text.match(/保留(.+?)(?:。|;|;|$)/);
  127. const dropMatch = text.match(/(?:不要|少给|降权)(.+?)(?:。|;|;|$)/);
  128. const outputMatch = text.match(/(?:更偏|偏)(.+?)(?:。|;|;|$)/);
  129. if (keepMatch) memory.preferredSignals = [...new Set([...(memory.preferredSignals || []), ...cleanPreferenceItems(keepMatch[1])])];
  130. if (dropMatch) memory.blockedDirections = [...new Set([...(memory.blockedDirections || []), ...cleanPreferenceItems(dropMatch[1])])];
  131. if (outputMatch) memory.preferredOutput = [...new Set([...(memory.preferredOutput || []), outputMatch[1].trim()])];
  132. memory.updatedAt = new Date().toISOString();
  133. writeJson(memoryPath, memory);
  134. return memory;
  135. }
  136. function isPreferenceMessage(message) {
  137. const text = String(message || '');
  138. return /保留|不要|少给|降权|更偏/.test(text) && !/日报|生成|启动/.test(text);
  139. }
  140. async function main() {
  141. const args = parseArgs(process.argv.slice(2));
  142. const outputDir = path.resolve(args.output || path.join('outputs', 'industry-trend-intelligence', new Date().toISOString().slice(0, 10)));
  143. ensureDir(outputDir);
  144. const message = args.message || '';
  145. const memoryPath = path.resolve(args.memory || 'memory/industry-trend-memory.json');
  146. if (isPreferenceMessage(message)) {
  147. const memory = updateMemoryFromMessage(message, memoryPath);
  148. const result = {
  149. status: 'ok',
  150. action: 'memory_updated',
  151. memoryPath,
  152. memory,
  153. assistantMessage: [
  154. '✅ 趋势偏好已保存。',
  155. '',
  156. '后续行业趋势情报日报会优先参考你保留的方向,并降低你不想看的方向。',
  157. '',
  158. '你可以继续说“重新生成日报”或“下次更偏门店转化/设计师话术”。'
  159. ].join('\n')
  160. };
  161. const prefix = args['result-prefix'] || args.resultPrefix || DEFAULT_RESULT_PREFIX;
  162. console.log(`${prefix}=${JSON.stringify(result)}`);
  163. return;
  164. }
  165. const { profile: loadedProfile, profilePath } = loadProfile(args);
  166. const profile = inferProfileFromMessage(message, loadedProfile);
  167. const keywordMatrix = args['keyword-matrix']
  168. ? readJson(path.resolve(args['keyword-matrix']))
  169. : keywordMatrixFromProfile(profile);
  170. const inputPath = args.input ? path.resolve(args.input) : undefined;
  171. const collectionMode = args['collection-mode'] || args.collectionMode || (toBool(args.live) ? 'live' : 'sample');
  172. const collectorOutputPath = path.join(outputDir, 'raw-input.json');
  173. let collectionResult;
  174. const dataset = inputPath && fs.existsSync(inputPath)
  175. ? readJson(inputPath)
  176. : (collectionResult = await collectXiaohongshuDataset({
  177. profile,
  178. keywordMatrix,
  179. mode: collectionMode,
  180. output: collectorOutputPath,
  181. fallbackSample: !toBool(args['no-fallback-sample']),
  182. keywordLimit: args['keyword-limit'] || args.keywordLimit,
  183. searchPages: args['search-pages'] || args.searchPages,
  184. notesPerKeyword: args['notes-per-keyword'] || args.notesPerKeyword,
  185. maxCommentPages: args['max-comment-pages'] || args.maxCommentPages,
  186. delayMs: args['delay-ms'] || args.delayMs,
  187. sort: args.sort,
  188. noteType: args['note-type'] || args.noteType
  189. })).dataset;
  190. writeJson(path.join(outputDir, 'profile-used.json'), profile);
  191. writeJson(path.join(outputDir, 'keyword-matrix.json'), keywordMatrix);
  192. if (inputPath) writeJson(path.join(outputDir, 'raw-input.json'), dataset);
  193. const report = buildReport({
  194. profile,
  195. dataset,
  196. keywordMatrix,
  197. outputDir
  198. });
  199. const result = {
  200. status: 'ok',
  201. profilePath,
  202. outputDir,
  203. files: [
  204. path.join(outputDir, 'profile-used.json'),
  205. path.join(outputDir, 'keyword-matrix.json'),
  206. path.join(outputDir, 'raw-input.json'),
  207. ...report.files
  208. ],
  209. summary: report.sample,
  210. oneLineJudgement: report.oneLineJudgement,
  211. assistantMessage: report.assistantMessage,
  212. markdown: report.markdown,
  213. keywordMatrix,
  214. collection: collectionResult ? {
  215. status: collectionResult.status,
  216. mode: collectionResult.mode,
  217. noteCount: collectionResult.noteCount,
  218. commentCount: collectionResult.commentCount,
  219. warningCount: (collectionResult.warnings || []).length,
  220. errorCount: (collectionResult.errors || []).length
  221. } : {
  222. status: 'provided_input',
  223. mode: 'input'
  224. },
  225. warnings: inputPath ? [] : (collectionResult?.warnings || []),
  226. errors: collectionResult?.errors || []
  227. };
  228. const prefix = args['result-prefix'] || args.resultPrefix || DEFAULT_RESULT_PREFIX;
  229. console.log(`${prefix}=${JSON.stringify(result)}`);
  230. }
  231. if (require.main === module) {
  232. main().catch(error => {
  233. console.error(error && error.stack ? error.stack : String(error));
  234. process.exit(1);
  235. });
  236. }