-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathreport.js
More file actions
388 lines (330 loc) · 16.6 KB
/
Copy pathreport.js
File metadata and controls
388 lines (330 loc) · 16.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
'use strict';
/**
* report.js — 日报 / 周报生成(重构版)
*
* 核心改进:
* 1. 周报生成前先批量补充未分类条目(batchClassify)
* 2. 双层噪声过滤(filter.js isReportNoise)
* 3. 周报 AI 先精选 top-30 再生成,输出接近手工整理水平
* 4. 统一从 config.js 读配置
*/
const { db } = require('./db');
const { isReportNoise } = require('./filter');
const { batchClassify, generateDailySummary, generateWeeklySummary } = require('./ai');
const { callAI } = require('./ai-provider');
const { insightDAO } = require('./dao');
const { sendReportToWeCom } = require('./wecom');
const { fetchMacroPanel, fetchMacroContext } = require('./macro-market');
const { sendWeeklyReportEmail } = require('./email-report');
const { getWikiContext } = require('./wiki-context');
const { createClient } = require('@supabase/supabase-js');
const { REPORT } = require('./config');
require('dotenv').config();
const USE_SUPABASE = process.env.USE_SUPABASE === 'true';
let supabase = null;
if (USE_SUPABASE && process.env.SUPABASE_URL && process.env.SUPABASE_KEY) {
supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_KEY);
}
// ── 时间工具 ──────────────────────────────────────────────────────────────────
function getTodayMidnightBJ() {
const bj = new Date(Date.now() + 8 * 3600000);
return Date.UTC(bj.getUTCFullYear(), bj.getUTCMonth(), bj.getUTCDate(), -8, 0, 0, 0);
}
function getWeekStartBJ() {
const bj = new Date(Date.now() + 8 * 3600000);
const day = bj.getUTCDay();
const diff = day === 0 ? 6 : day - 1;
return Date.UTC(bj.getUTCFullYear(), bj.getUTCMonth(), bj.getUTCDate() - diff, -8, 0, 0, 0);
}
function formatDateBJ(ts) {
return new Date(ts + 8 * 3600000).toISOString().split('T')[0];
}
// ── 数据获取 ──────────────────────────────────────────────────────────────────
async function fetchNewsForReport(since, limit = 500) {
if (USE_SUPABASE && supabase) {
try {
// 先尝试按 alpha_score 排序(如果列存在)
const query = supabase
.from('news')
.select('*')
.gte('timestamp', since)
.order('timestamp', { ascending: false })
.limit(limit);
const { data, error } = await query;
if (error) {
// 如果失败,可能是 alpha_score 列不存在,尝试只用 timestamp 排序
console.log('[Report] Retrying without alpha_score sort...');
const { data: data2, error: error2 } = await supabase
.from('news')
.select('*')
.gte('timestamp', since)
.order('timestamp', { ascending: false })
.limit(limit);
if (error2) { console.error('[Report] Supabase error:', error2.message); return []; }
return data2 || [];
}
return data || [];
} catch (e) {
console.error('[Report] Supabase fetch error:', e.message);
return [];
}
}
if (!db) return [];
// SQLite 使用 alpha_score(本地数据库有该列)
return db.prepare(
'SELECT * FROM news WHERE timestamp > ? ORDER BY alpha_score DESC, timestamp DESC LIMIT ?',
).all(since, limit);
}
// ── 批量补充分类(报告前预处理)───────────────────────────────────────────────
/**
* 找出未分类的条目(business_category 为空/其他),批量调用 AI 填充。
* 限制处理数量(MAX_ITEMS_FOR_AI),避免 token 超支。
*/
async function fillMissingCategories(rows) {
const unclassified = rows.filter(r =>
!r.business_category || r.business_category === '' || r.business_category === '其他',
).slice(0, REPORT.MAX_ITEMS_FOR_AI);
if (unclassified.length === 0) return rows;
console.log(`[Report] Batch classifying ${unclassified.length} unclassified items…`);
const resultMap = await batchClassify(unclassified);
// 把 AI 结果写回 rows(仅内存,不再写 DB 以减少干扰)
const unclassifiedMap = new Map(unclassified.map((r, i) => [i, r]));
resultMap.forEach((aiResult, idx) => {
const item = unclassifiedMap.get(idx);
if (item && aiResult) {
Object.assign(item, {
business_category: aiResult.business_category || item.business_category,
competitor_category: aiResult.competitor_category || item.competitor_category,
detail: aiResult.detail || item.detail,
alpha_score: aiResult.alpha_score || item.alpha_score,
impact: aiResult.impact || item.impact,
bitv_action: aiResult.bitv_action || item.bitv_action,
is_important: aiResult.is_important ?? item.is_important,
});
}
});
return rows;
}
// ── 竞品动态矩阵 ───────────────────────────────────────────────────────────────
/**
* 按数据源(交易所/香港合规所)分组,生成本周各竞品主要动作的矩阵视图。
* 只展示有实质动态(alpha_score >= 65 或 is_important)的来源。
*/
function buildCompetitorMatrix(rows) {
// 香港合规所(优先展示)
const HK_SOURCES = ['HashKeyExchange', 'HashKeyGroup', 'OSL', 'Exio', 'TechubNews'];
// 头部离岸所
const OFFSHORE_SOURCES = ['Binance', 'OKX', 'Bybit', 'Gate', 'MEXC', 'Bitget', 'HTX', 'KuCoin'];
const ALL_COMPETITOR_SOURCES = [...HK_SOURCES, ...OFFSHORE_SOURCES];
const SOURCE_LABEL = {
'HashKeyExchange': 'HashKey Exchange',
'HashKeyGroup': 'HashKey Group',
'TechubNews': 'TechubNews(HashKey媒体)',
};
// 按来源分桶,只保留有价值的条目
const bySource = {};
rows
.filter(r =>
ALL_COMPETITOR_SOURCES.includes(r.source) &&
(r.alpha_score >= 65 || r.is_important === 1) &&
(r.detail?.length > 5 || r.title?.length > 8),
)
.forEach(r => {
if (!bySource[r.source]) bySource[r.source] = [];
bySource[r.source].push(r);
});
// 每个来源内部按 alpha_score 降序
Object.values(bySource).forEach(items =>
items.sort((a, b) => (b.alpha_score || 0) - (a.alpha_score || 0)),
);
// 排序:有动态的 HK 来源在前,离岸所在后;各组内按条目数降序
const sortedSources = [
...HK_SOURCES.filter(s => bySource[s]).sort((a, b) => bySource[b].length - bySource[a].length),
...OFFSHORE_SOURCES.filter(s => bySource[s]).sort((a, b) => bySource[b].length - bySource[a].length),
];
if (sortedSources.length === 0) return '';
let matrix = '';
sortedSources.forEach(source => {
const items = bySource[source];
const name = SOURCE_LABEL[source] || source;
const tag = HK_SOURCES.includes(source) ? '🏛️' : '🌐';
matrix += `\n${tag} **${name}** · ${items.length}条重要动态\n`;
items.slice(0, 3).forEach(item => {
const emoji = item.alpha_score >= 90 ? '🔥' : item.alpha_score >= 70 ? '⭐️' : '📡';
const cat = item.business_category ? ` \`${item.business_category}\`` : '';
const score = item.alpha_score ? ` \`${item.alpha_score}\`` : '';
matrix += `${emoji} ${item.title}${cat}${score}\n`;
if (item.detail) matrix += ` > ${item.detail}\n`;
if (item.bitv_action) matrix += ` > 💡 ${item.bitv_action}\n`;
});
});
return matrix;
}
// ── 统计面板 ──────────────────────────────────────────────────────────────────
function buildStatsPanel(items, period = '今日') {
const total = items.length;
const important = items.filter(i => i.is_important === 1).length;
const sources = new Set(items.map(i => i.source)).size;
const withDetail = items.filter(i => i.detail?.length > 5).length;
return `📊 **${period}数据概览**\n> 抓取 **${total}** 条 | 重要 **${important}** 条 | AI 摘要 **${withDetail}** 条 | 来源 **${sources}** 个`;
}
// ═══════════════════════════════════════════════════════════════════════
// 日报
// ═══════════════════════════════════════════════════════════════════════
async function runDailyReport(dryRun = false) {
console.log('[DailyReport] Start…');
console.log(`[DailyReport] dryRun=${dryRun}, Time: ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`);
const since = getTodayMidnightBJ();
const dateStr = formatDateBJ(Date.now());
console.log(`[DailyReport] Fetching news since: ${new Date(since).toISOString()}`);
let rawRows = [];
try {
rawRows = await fetchNewsForReport(since);
console.log(`[DailyReport] Fetched ${rawRows.length} raw rows`);
} catch (err) {
console.error('[DailyReport] Error fetching news:', err.message);
return null;
}
// 噪声过滤:仅去除报告噪声源(不重新跑完整的 filterNewsItems,
// 因为 DB 中的数据已经过滤过,重跑 validateTimestamp 会误删有效条目)
let rows = rawRows.filter(r => !isReportNoise(r));
console.log(`[DailyReport] After filtering: ${rows.length} rows`);
if (rows.length === 0) {
console.log('[DailyReport] No qualifying news today.');
// 即使没有新闻,也发送一个空报告通知,避免用户以为系统故障
if (!dryRun) {
const { sendReportToWeCom } = require('./wecom');
await sendReportToWeCom(`📋 **Web3Watch HK 行业日报 | ${dateStr}**\n\n今日暂无符合条件的行业动态。\n\n---\n*Web3Watch HK 战略分析引擎*`, '日报');
}
return null;
}
// 并行:补充分类 + 拉取宏观数据 + 读取历史趋势记忆
[rows] = await Promise.all([
fillMissingCategories(rows),
]);
const [macroPanel, macroContext, recentTrends] = await Promise.all([
fetchMacroPanel(),
fetchMacroContext(),
insightDAO.getRecent(4).catch(() => []),
]);
// Pattern A: 注入行研知识库战略上下文
const wikiContext = getWikiContext(rows);
if (wikiContext) console.log('[DailyReport] Wiki context injected:', wikiContext.length, 'chars');
// AI 总结(注入宏观上下文 + 历史趋势记忆 + 行研知识库)
const aiInput = rows.filter(r => r.detail || r.alpha_score >= 70);
const aiSummary = await generateDailySummary(
aiInput.length ? aiInput : rows.slice(0, 30),
macroContext || '',
recentTrends,
wikiContext,
);
// 组装报告:头部 → 宏观背景 → AI总结
let report = `📋 **Web3Watch HK 行业日报 | ${dateStr}**\n\n`;
if (macroPanel) report += `${macroPanel}\n\n`;
if (aiSummary) report += `---\n\n${aiSummary}\n\n`;
report += `---\n*Web3Watch HK | ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}*`;
if (dryRun) {
console.log('\n=== DAILY REPORT (DRY RUN) ===\n', report, '\n=== END ===\n');
return report;
}
await sendReportToWeCom(report, '日报');
console.log('[DailyReport] Done.');
return report;
}
// ── 趋势提炼(记忆系统核心) ───────────────────────────────────────────────
/**
* 从周报数据中提炼 1-3 条长期趋势/共识,存入记忆系统
*/
async function extractAndSaveTrends(rows) {
if (!rows || rows.length < 5) return;
const topItems = rows
.filter(r => r.alpha_score >= 75)
.slice(0, 30)
.map(r => `- ${r.title}: ${r.detail || ''}`)
.join('\n');
const prompt = `你是一个行业研究主管。请根据以下本周的重点情报,提炼出 1-3 条“行业共识”或“重要长期趋势”。
这些趋势将作为系统的“长期记忆”,用于指导未来的分析。
本周重点:
${topItems}
请输出 JSON 数组,每个对象包含:
- trend_key: 趋势简称(如 "RWA合规化加速")
- summary: 一句话深度总结趋势背后的逻辑
- evidence_count: 基于以上情报,该趋势被引证的次数
注意:只提炼具有长期影响(>1个月)的趋势,不要记录短期新闻。
示例:[{"trend_key":"稳定币监管落地","summary":"SFC 明确了法币稳定币发行商牌照框架,标志着香港进入合规支付新阶段。","evidence_count":3}]`;
try {
const text = await callAI([{ role: 'user', content: prompt }], { json: true, temperature: 0.3 });
if (text) {
let cleanJson = text;
if (text.includes('```')) {
const match = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
if (match) cleanJson = match[1];
}
const trends = JSON.parse(cleanJson);
if (Array.isArray(trends)) {
for (const trend of trends) {
await insightDAO.saveInsight(trend);
}
console.log(`[Insight] Successfully recorded ${trends.length} new trends.`);
}
}
} catch (e) {
console.error('[Insight] Trend extraction failed:', e.message);
}
}
// ═══════════════════════════════════════════════════════════════════════
// 周报(精选模式)
// ═══════════════════════════════════════════════════════════════════════
async function runWeeklyReport(dryRun = false) {
console.log('[WeeklyReport] Start…');
const weekStart = getWeekStartBJ();
const startDate = formatDateBJ(weekStart);
const endDate = formatDateBJ(Date.now());
const rawRows = await fetchNewsForReport(weekStart);
// 噪声过滤:仅去除报告噪声源
let rows = rawRows.filter(r => !isReportNoise(r));
if (rows.length === 0) {
console.log('[WeeklyReport] No qualifying news this week.');
return null;
}
console.log(`[WeeklyReport] ${rawRows.length} raw → ${rows.length} after filter`);
// 补充分类
rows = await fillMissingCategories(rows);
// 统计
const stats = {
total: rows.length,
important: rows.filter(r => r.alpha_score >= 70).length,
sources: new Set(rows.map(r => r.source)).size,
categories: new Set(rows.filter(r => r.business_category).map(r => r.business_category)).size,
};
// AI 周报
const aiInput = rows.filter(r => r.alpha_score >= 70 || r.detail?.length > 5);
const aiSummary = await generateWeeklySummary(aiInput.length >= 5 ? aiInput : rows.slice(0, 80), stats);
// 竞品矩阵(详细版,仅用于邮件)
const competitorMatrix = buildCompetitorMatrix(rows);
// ── 企微版:只发 AI 总结,严格控制长度 ──────────────────────────────────────
const wecomHeader = `📰 **Web3Watch HK 行业周报 | ${startDate} ~ ${endDate}**\n`;
const wecomReport = wecomHeader + '\n' + (aiSummary || '本周暂无 AI 总结。');
// ── 邮件版:完整内容(总结 + 竞品矩阵详情)────────────────────────────────
let emailReport = `📰 **Web3Watch HK 行业周报 | ${startDate} ~ ${endDate}**\n\n`;
if (aiSummary) emailReport += aiSummary + '\n\n';
if (competitorMatrix.trim()) {
emailReport += `---\n\n🏢 **竞品动态矩阵** | 本周各主要玩家行动汇总\n${competitorMatrix}\n`;
}
emailReport += `\n---\n*Web3Watch HK 战略分析引擎 | ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}*`;
if (dryRun) {
console.log('\n=== WEEKLY REPORT WeCom (DRY RUN) ===\n', wecomReport, '\n=== END ===\n');
console.log('\n=== WEEKLY REPORT Email (DRY RUN) ===\n', emailReport, '\n=== END ===\n');
return emailReport;
}
await sendReportToWeCom(wecomReport, '周报');
// 邮件发送给领导层(SMTP 未配置时自动跳过)
await sendWeeklyReportEmail(emailReport, startDate, endDate);
console.log('[WeeklyReport] Done.');
// 4. 提炼并保存行业趋势(记忆系统)
if (!dryRun) {
extractAndSaveTrends(rows).catch(e => console.error('[Insight] background task error:', e));
}
return emailReport;
}
module.exports = { runDailyReport, runWeeklyReport };