tickflow-assist 0.2.19 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +13 -6
  2. package/dist/analysis/parsers/flash-alert-decision.parser.d.ts +8 -0
  3. package/dist/analysis/parsers/flash-alert-decision.parser.js +34 -0
  4. package/dist/analysis/types/composite-analysis.d.ts +11 -0
  5. package/dist/background/jin10-flash.worker.d.ts +8 -0
  6. package/dist/background/jin10-flash.worker.js +24 -0
  7. package/dist/bootstrap.d.ts +4 -0
  8. package/dist/bootstrap.js +18 -1
  9. package/dist/config/normalize.js +16 -6
  10. package/dist/config/schema.d.ts +6 -1
  11. package/dist/config/schema.js +4 -0
  12. package/dist/dev/run-monitor-loop.js +6 -2
  13. package/dist/dev/tickflow-assist-cli.js +70 -0
  14. package/dist/plugin-commands.js +7 -0
  15. package/dist/prompts/analysis/flash-monitor-alert-prompt.d.ts +11 -0
  16. package/dist/prompts/analysis/flash-monitor-alert-prompt.js +44 -0
  17. package/dist/prompts/analysis/index.d.ts +1 -0
  18. package/dist/prompts/analysis/index.js +1 -0
  19. package/dist/prompts/analysis/post-close-review-user-prompt.js +18 -0
  20. package/dist/services/alert-service.d.ts +1 -0
  21. package/dist/services/alert-service.js +21 -3
  22. package/dist/services/jin10-flash-monitor-service.d.ts +33 -0
  23. package/dist/services/jin10-flash-monitor-service.js +587 -0
  24. package/dist/services/jin10-mcp-service.d.ts +29 -0
  25. package/dist/services/jin10-mcp-service.js +242 -0
  26. package/dist/services/post-close-review-service.d.ts +6 -1
  27. package/dist/services/post-close-review-service.js +35 -1
  28. package/dist/storage/repositories/jin10-flash-delivery-repo.d.ts +11 -0
  29. package/dist/storage/repositories/jin10-flash-delivery-repo.js +93 -0
  30. package/dist/storage/repositories/jin10-flash-repo.d.ts +16 -0
  31. package/dist/storage/repositories/jin10-flash-repo.js +144 -0
  32. package/dist/storage/schemas.d.ts +2 -0
  33. package/dist/storage/schemas.js +19 -0
  34. package/dist/tools/flash-monitor-status.tool.d.ts +6 -0
  35. package/dist/tools/flash-monitor-status.tool.js +9 -0
  36. package/dist/types/flash-monitor.d.ts +17 -0
  37. package/dist/types/flash-monitor.js +1 -0
  38. package/dist/types/jin10.d.ts +30 -0
  39. package/dist/types/jin10.js +1 -0
  40. package/dist/utils/china-time.d.ts +1 -0
  41. package/dist/utils/china-time.js +5 -0
  42. package/openclaw.plugin.json +53 -1
  43. package/package.json +14 -6
@@ -60,6 +60,7 @@ export declare class AlertService {
60
60
  private combineErrors;
61
61
  private trySendPayload;
62
62
  private trySendViaRuntime;
63
+ private invokeRuntimeChannelSend;
63
64
  private trySendViaCommand;
64
65
  private buildCliArgs;
65
66
  }
@@ -153,20 +153,20 @@ export class AlertService {
153
153
  try {
154
154
  switch (this.channel) {
155
155
  case "discord":
156
- await runtimeContext.runtime.channel.discord.sendMessageDiscord(this.options.target, payload.message, {
156
+ await this.invokeRuntimeChannelSend(runtimeContext.runtime.channel, "discord", "sendMessageDiscord", this.options.target, payload.message, {
157
157
  ...baseOptions,
158
158
  filename: payload.filename,
159
159
  });
160
160
  return null;
161
161
  case "slack":
162
- await runtimeContext.runtime.channel.slack.sendMessageSlack(this.options.target, payload.message, {
162
+ await this.invokeRuntimeChannelSend(runtimeContext.runtime.channel, "slack", "sendMessageSlack", this.options.target, payload.message, {
163
163
  ...baseOptions,
164
164
  uploadFileName: payload.filename,
165
165
  uploadTitle: payload.filename,
166
166
  });
167
167
  return null;
168
168
  case "signal":
169
- await runtimeContext.runtime.channel.signal.sendMessageSignal(this.options.target, payload.message, baseOptions);
169
+ await this.invokeRuntimeChannelSend(runtimeContext.runtime.channel, "signal", "sendMessageSignal", this.options.target, payload.message, baseOptions);
170
170
  return null;
171
171
  default:
172
172
  // OpenClaw 2026.3.31 narrows the typed runtime channel surface.
@@ -178,6 +178,14 @@ export class AlertService {
178
178
  return `runtime delivery failed: ${formatErrorMessage(error)}`;
179
179
  }
180
180
  }
181
+ async invokeRuntimeChannelSend(runtimeChannel, channelName, methodName, target, message, options) {
182
+ const channelApi = getRuntimeChannelApi(runtimeChannel, channelName);
183
+ const method = channelApi?.[methodName];
184
+ if (typeof method !== "function") {
185
+ throw new Error(`runtime channel ${channelName}.${methodName} unavailable`);
186
+ }
187
+ await method.call(channelApi, target, message, options);
188
+ }
181
189
  async trySendViaCommand(payload) {
182
190
  try {
183
191
  const result = await this.runCommandWithTimeout(this.buildCliArgs(payload), { timeoutMs: 15_000 });
@@ -234,6 +242,16 @@ function normalizeSendInput(input) {
234
242
  ? { message: input }
235
243
  : input;
236
244
  }
245
+ function getRuntimeChannelApi(runtimeChannel, channelName) {
246
+ if (!isRecord(runtimeChannel)) {
247
+ return null;
248
+ }
249
+ const channelApi = runtimeChannel[channelName];
250
+ return isRecord(channelApi) ? channelApi : null;
251
+ }
252
+ function isRecord(value) {
253
+ return typeof value === "object" && value !== null && !Array.isArray(value);
254
+ }
237
255
  function getAlertStyle(ruleCode, fallbackTitle) {
238
256
  switch (ruleCode) {
239
257
  case "stop_loss_hit":
@@ -0,0 +1,33 @@
1
+ import type { FlashMonitorState } from "../types/flash-monitor.js";
2
+ import { AnalysisService } from "./analysis-service.js";
3
+ import { AlertService } from "./alert-service.js";
4
+ import { Jin10McpService } from "./jin10-mcp-service.js";
5
+ import { WatchlistService } from "./watchlist-service.js";
6
+ import { Jin10FlashDeliveryRepository } from "../storage/repositories/jin10-flash-delivery-repo.js";
7
+ import { Jin10FlashRepository } from "../storage/repositories/jin10-flash-repo.js";
8
+ export declare class Jin10FlashMonitorService {
9
+ private readonly baseDir;
10
+ private readonly pollIntervalSeconds;
11
+ private readonly retentionDays;
12
+ private readonly nightAlertEnabled;
13
+ private readonly watchlistService;
14
+ private readonly jin10McpService;
15
+ private readonly analysisService;
16
+ private readonly alertService;
17
+ private readonly flashRepository;
18
+ private readonly flashDeliveryRepository;
19
+ constructor(baseDir: string, pollIntervalSeconds: number, retentionDays: number, nightAlertEnabled: boolean, watchlistService: WatchlistService, jin10McpService: Jin10McpService, analysisService: AnalysisService, alertService: AlertService, flashRepository: Jin10FlashRepository, flashDeliveryRepository: Jin10FlashDeliveryRepository);
20
+ runMonitorOnce(): Promise<number>;
21
+ getStatusReport(): Promise<string>;
22
+ getState(): Promise<FlashMonitorState>;
23
+ recordHeartbeat(runtimeHost?: "plugin_service" | "fallback_process"): Promise<void>;
24
+ recordLoopError(error: unknown): Promise<void>;
25
+ private handleCandidate;
26
+ private decideAlert;
27
+ private fetchLatestFlashes;
28
+ private fetchFlashesByCursor;
29
+ private maybePruneExpired;
30
+ private readState;
31
+ private writeState;
32
+ private getStateFilePath;
33
+ }
@@ -0,0 +1,587 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { parseFlashAlertDecision } from "../analysis/parsers/flash-alert-decision.parser.js";
5
+ import { FLASH_MONITOR_ALERT_SYSTEM_PROMPT, buildFlashMonitorAlertUserPrompt, } from "../prompts/analysis/index.js";
6
+ import { chinaHour, chinaToday, formatChinaDateTime } from "../utils/china-time.js";
7
+ const DEFAULT_STATE = {
8
+ initialized: false,
9
+ lastSeenKey: null,
10
+ lastSeenPublishedAt: null,
11
+ lastSeenUrl: null,
12
+ backfillCursor: null,
13
+ runtimeHost: null,
14
+ runtimeObservedAt: null,
15
+ lastHeartbeatAt: null,
16
+ lastPollAt: null,
17
+ lastPollStored: 0,
18
+ lastPollCandidates: 0,
19
+ lastPollAlerts: 0,
20
+ lastPrunedAt: null,
21
+ lastLoopError: null,
22
+ lastLoopErrorAt: null,
23
+ };
24
+ const MAX_FLASH_PAGES_PER_POLL = 5;
25
+ const INITIAL_SEED_PAGES = 3;
26
+ const PRUNE_INTERVAL_MS = 6 * 60 * 60 * 1000;
27
+ const NOISE_PATTERNS = [
28
+ /^金十图示[::]/,
29
+ /交易学院正在直播中/,
30
+ ];
31
+ const HIGH_IMPORTANCE_KEYWORDS = [
32
+ "重组",
33
+ "减持",
34
+ "增持",
35
+ "业绩预告",
36
+ "业绩快报",
37
+ "中标",
38
+ "签署",
39
+ "订单",
40
+ "停牌",
41
+ "复牌",
42
+ "监管",
43
+ "问询",
44
+ "处罚",
45
+ "回购",
46
+ ];
47
+ export class Jin10FlashMonitorService {
48
+ baseDir;
49
+ pollIntervalSeconds;
50
+ retentionDays;
51
+ nightAlertEnabled;
52
+ watchlistService;
53
+ jin10McpService;
54
+ analysisService;
55
+ alertService;
56
+ flashRepository;
57
+ flashDeliveryRepository;
58
+ constructor(baseDir, pollIntervalSeconds, retentionDays, nightAlertEnabled, watchlistService, jin10McpService, analysisService, alertService, flashRepository, flashDeliveryRepository) {
59
+ this.baseDir = baseDir;
60
+ this.pollIntervalSeconds = pollIntervalSeconds;
61
+ this.retentionDays = retentionDays;
62
+ this.nightAlertEnabled = nightAlertEnabled;
63
+ this.watchlistService = watchlistService;
64
+ this.jin10McpService = jin10McpService;
65
+ this.analysisService = analysisService;
66
+ this.alertService = alertService;
67
+ this.flashRepository = flashRepository;
68
+ this.flashDeliveryRepository = flashDeliveryRepository;
69
+ }
70
+ async runMonitorOnce() {
71
+ const now = formatChinaDateTime();
72
+ const state = await this.readState();
73
+ const latestStored = state.lastSeenKey ? null : await this.flashRepository.getLatest();
74
+ const anchorKey = state.lastSeenKey ?? latestStored?.flash_key ?? null;
75
+ const anchorPublishedAt = state.lastSeenPublishedAt ?? latestStored?.published_at ?? null;
76
+ const anchorUrl = state.lastSeenUrl ?? latestStored?.url ?? null;
77
+ if (!this.jin10McpService.isConfigured()) {
78
+ await this.writeState({
79
+ ...state,
80
+ initialized: state.initialized || Boolean(anchorKey),
81
+ lastSeenKey: anchorKey,
82
+ lastSeenPublishedAt: anchorPublishedAt,
83
+ lastSeenUrl: anchorUrl,
84
+ backfillCursor: state.backfillCursor,
85
+ lastPollAt: now,
86
+ lastPollStored: 0,
87
+ lastPollCandidates: 0,
88
+ lastPollAlerts: 0,
89
+ lastLoopError: null,
90
+ lastLoopErrorAt: null,
91
+ });
92
+ return 0;
93
+ }
94
+ if (!anchorKey && !state.initialized) {
95
+ const seed = await this.fetchLatestFlashes(INITIAL_SEED_PAGES, null);
96
+ const saveResult = await this.flashRepository.saveAll(seed.items);
97
+ const nextState = {
98
+ ...state,
99
+ initialized: true,
100
+ lastSeenKey: seed.latest?.flash_key ?? null,
101
+ lastSeenPublishedAt: seed.latest?.published_at ?? null,
102
+ lastSeenUrl: seed.latest?.url ?? null,
103
+ backfillCursor: null,
104
+ lastPollAt: now,
105
+ lastPollStored: saveResult.added,
106
+ lastPollCandidates: 0,
107
+ lastPollAlerts: 0,
108
+ lastLoopError: null,
109
+ lastLoopErrorAt: null,
110
+ };
111
+ await this.writeState(nextState);
112
+ await this.maybePruneExpired(nextState);
113
+ return 0;
114
+ }
115
+ const fetchResult = await this.fetchLatestFlashes(MAX_FLASH_PAGES_PER_POLL, anchorKey);
116
+ const backfillCursor = state.backfillCursor ?? fetchResult.nextCursor;
117
+ const backfillResult = backfillCursor
118
+ ? await this.fetchFlashesByCursor(MAX_FLASH_PAGES_PER_POLL, backfillCursor)
119
+ : null;
120
+ const allFetchedItems = mergeFlashRecords(fetchResult.items, backfillResult?.items ?? []);
121
+ const saveResult = await this.flashRepository.saveAll(allFetchedItems);
122
+ const newItemKeys = new Set(saveResult.addedKeys);
123
+ const newItems = allFetchedItems.filter((item) => newItemKeys.has(item.flash_key));
124
+ const watchlist = await this.watchlistService.list();
125
+ const candidates = watchlist.length > 0
126
+ ? buildStageOneCandidates(newItems, watchlist)
127
+ : [];
128
+ let alertCount = 0;
129
+ for (const candidate of candidates) {
130
+ alertCount += await this.handleCandidate(candidate);
131
+ }
132
+ const nextState = {
133
+ ...state,
134
+ initialized: true,
135
+ lastSeenKey: fetchResult.latest?.flash_key ?? anchorKey,
136
+ lastSeenPublishedAt: fetchResult.latest?.published_at ?? anchorPublishedAt,
137
+ lastSeenUrl: fetchResult.latest?.url ?? anchorUrl,
138
+ backfillCursor: backfillResult?.nextCursor ?? backfillCursor ?? null,
139
+ lastPollAt: now,
140
+ lastPollStored: saveResult.added,
141
+ lastPollCandidates: candidates.length,
142
+ lastPollAlerts: alertCount,
143
+ lastLoopError: null,
144
+ lastLoopErrorAt: null,
145
+ };
146
+ await this.writeState(nextState);
147
+ await this.maybePruneExpired(nextState);
148
+ return alertCount;
149
+ }
150
+ async getStatusReport() {
151
+ const [state, latest] = await Promise.all([
152
+ this.readState(),
153
+ this.flashRepository.getLatest(),
154
+ ]);
155
+ const configError = this.jin10McpService.getConfigurationError();
156
+ const dayStart = `${chinaToday()} 00:00:00`;
157
+ const dayStartTs = toChinaTimeTimestamp(dayStart);
158
+ const [storedToday, alertsToday, watchlist] = await Promise.all([
159
+ this.flashRepository.countSincePublishedTs(dayStartTs),
160
+ this.flashDeliveryRepository.countSinceDeliveredAt(dayStart),
161
+ this.watchlistService.list(),
162
+ ]);
163
+ const lines = [
164
+ "📰 Jin10 快讯监控状态",
165
+ `状态: ${configError ? `未配置(${configError})` : "后台轮询中"}`,
166
+ `轮询间隔: ${this.pollIntervalSeconds} 秒`,
167
+ `保留天数: ${this.retentionDays} 天`,
168
+ `关注列表: ${watchlist.length}只`,
169
+ `最近心跳: ${state.lastHeartbeatAt ?? "暂无"}`,
170
+ `最近轮询: ${state.lastPollAt ?? "暂无"}`,
171
+ `最近一轮: 入库 ${state.lastPollStored} 条 | 候选 ${state.lastPollCandidates} 条 | 告警 ${state.lastPollAlerts} 条`,
172
+ `今日统计: 入库 ${storedToday} 条 | 告警 ${alertsToday} 条`,
173
+ `续页补齐: ${state.backfillCursor ? "进行中" : "空闲"}`,
174
+ `最近清理: ${state.lastPrunedAt ?? "暂无"}`,
175
+ ];
176
+ if (state.lastLoopError) {
177
+ lines.push(`最近异常: ${state.lastLoopErrorAt ?? "未知时间"} | ${state.lastLoopError}`);
178
+ }
179
+ if (latest) {
180
+ lines.push("", "最新快讯:", `• 时间: ${latest.published_at}`, `• 链接: ${latest.url}`, `• 正文: ${truncate(latest.content, 140)}`);
181
+ }
182
+ return lines.join("\n");
183
+ }
184
+ async getState() {
185
+ return this.readState();
186
+ }
187
+ async recordHeartbeat(runtimeHost) {
188
+ const state = await this.readState();
189
+ const now = formatChinaDateTime();
190
+ await this.writeState({
191
+ ...state,
192
+ lastHeartbeatAt: now,
193
+ runtimeHost: runtimeHost ?? state.runtimeHost,
194
+ runtimeObservedAt: now,
195
+ });
196
+ }
197
+ async recordLoopError(error) {
198
+ const state = await this.readState();
199
+ const message = error instanceof Error ? error.message : String(error);
200
+ await this.writeState({
201
+ ...state,
202
+ lastLoopError: message,
203
+ lastLoopErrorAt: formatChinaDateTime(),
204
+ });
205
+ }
206
+ async handleCandidate(candidate) {
207
+ if (await this.flashDeliveryRepository.hasDelivered(candidate.flash.flash_key)) {
208
+ return 0;
209
+ }
210
+ if (!this.nightAlertEnabled && isNightQuietHour()) {
211
+ return 0;
212
+ }
213
+ const decision = await this.decideAlert(candidate);
214
+ if (!decision.alert) {
215
+ return 0;
216
+ }
217
+ const symbols = resolveRelevantSymbols(decision.relevantSymbols, candidate.matches);
218
+ if (symbols.length === 0) {
219
+ return 0;
220
+ }
221
+ const message = buildAlertMessage(candidate.flash, candidate.matches, decision, symbols, this.alertService);
222
+ const result = await this.alertService.sendWithResult(message);
223
+ if (!result.ok) {
224
+ return 0;
225
+ }
226
+ const entry = {
227
+ flash_key: candidate.flash.flash_key,
228
+ published_at: candidate.flash.published_at,
229
+ symbols,
230
+ headline: decision.headline || "Jin10快讯命中自选",
231
+ reason: decision.reason || "快讯与当前关注标的相关,建议尽快核实。",
232
+ importance: decision.importance,
233
+ message,
234
+ delivered_at: formatChinaDateTime(),
235
+ };
236
+ await this.flashDeliveryRepository.append(entry);
237
+ return 1;
238
+ }
239
+ async decideAlert(candidate) {
240
+ if (!this.analysisService.isConfigured()) {
241
+ return buildFallbackDecision(candidate);
242
+ }
243
+ try {
244
+ const responseText = await this.analysisService.generateText(FLASH_MONITOR_ALERT_SYSTEM_PROMPT, buildFlashMonitorAlertUserPrompt({
245
+ flash: candidate.flash,
246
+ candidates: candidate.matches,
247
+ }), {
248
+ maxTokens: 600,
249
+ temperature: 0.1,
250
+ });
251
+ const parsed = parseFlashAlertDecision(responseText);
252
+ return {
253
+ alert: parsed.alert,
254
+ importance: parsed.importance,
255
+ relevantSymbols: parsed.relevantSymbols,
256
+ headline: parsed.headline,
257
+ reason: parsed.reason,
258
+ };
259
+ }
260
+ catch {
261
+ return buildFallbackDecision(candidate);
262
+ }
263
+ }
264
+ async fetchLatestFlashes(maxPages, anchorKey) {
265
+ const collected = [];
266
+ let latest = null;
267
+ let cursor;
268
+ for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) {
269
+ const page = await this.jin10McpService.listFlash(cursor);
270
+ const pageEntries = page.items
271
+ .map((item) => toFlashRecord(item))
272
+ .filter((item) => item != null);
273
+ if (pageEntries.length === 0) {
274
+ break;
275
+ }
276
+ if (!latest) {
277
+ latest = pageEntries[0];
278
+ }
279
+ if (anchorKey) {
280
+ const anchorIndex = pageEntries.findIndex((entry) => entry.flash_key === anchorKey);
281
+ if (anchorIndex >= 0) {
282
+ collected.push(...pageEntries.slice(0, anchorIndex));
283
+ return {
284
+ items: sortFlashRecords(collected),
285
+ latest,
286
+ nextCursor: null,
287
+ };
288
+ }
289
+ }
290
+ collected.push(...pageEntries);
291
+ if (!page.hasMore || !page.nextCursor) {
292
+ return {
293
+ items: sortFlashRecords(collected),
294
+ latest,
295
+ nextCursor: null,
296
+ };
297
+ }
298
+ if (pageIndex === maxPages - 1) {
299
+ return {
300
+ items: sortFlashRecords(collected),
301
+ latest,
302
+ nextCursor: page.nextCursor,
303
+ };
304
+ }
305
+ cursor = page.nextCursor;
306
+ }
307
+ return {
308
+ items: sortFlashRecords(collected),
309
+ latest,
310
+ nextCursor: null,
311
+ };
312
+ }
313
+ async fetchFlashesByCursor(maxPages, initialCursor) {
314
+ const collected = [];
315
+ let cursor = initialCursor;
316
+ for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) {
317
+ if (!cursor) {
318
+ break;
319
+ }
320
+ const page = await this.jin10McpService.listFlash(cursor);
321
+ const pageEntries = page.items
322
+ .map((item) => toFlashRecord(item))
323
+ .filter((item) => item != null);
324
+ if (pageEntries.length > 0) {
325
+ collected.push(...pageEntries);
326
+ }
327
+ if (!page.hasMore || !page.nextCursor) {
328
+ return {
329
+ items: sortFlashRecords(collected),
330
+ latest: null,
331
+ nextCursor: null,
332
+ };
333
+ }
334
+ if (pageIndex === maxPages - 1) {
335
+ return {
336
+ items: sortFlashRecords(collected),
337
+ latest: null,
338
+ nextCursor: page.nextCursor,
339
+ };
340
+ }
341
+ cursor = page.nextCursor;
342
+ }
343
+ return {
344
+ items: sortFlashRecords(collected),
345
+ latest: null,
346
+ nextCursor: cursor ?? null,
347
+ };
348
+ }
349
+ async maybePruneExpired(state) {
350
+ const now = Date.now();
351
+ const lastPrunedAt = parseChinaTime(state.lastPrunedAt)?.getTime() ?? 0;
352
+ if (now - lastPrunedAt < PRUNE_INTERVAL_MS) {
353
+ return;
354
+ }
355
+ const cutoffTs = now - this.retentionDays * 24 * 60 * 60 * 1000;
356
+ const cutoffLabel = formatChinaDateTime(new Date(cutoffTs));
357
+ await Promise.all([
358
+ this.flashRepository.pruneOlderThanPublishedTs(cutoffTs),
359
+ this.flashDeliveryRepository.pruneOlderThanDeliveredAt(cutoffLabel),
360
+ ]);
361
+ await this.writeState({
362
+ ...state,
363
+ lastPrunedAt: formatChinaDateTime(),
364
+ });
365
+ }
366
+ async readState() {
367
+ const file = this.getStateFilePath();
368
+ try {
369
+ const raw = await readFile(file, "utf-8");
370
+ return { ...DEFAULT_STATE, ...JSON.parse(raw) };
371
+ }
372
+ catch (error) {
373
+ if (error.code === "ENOENT") {
374
+ return { ...DEFAULT_STATE };
375
+ }
376
+ throw error;
377
+ }
378
+ }
379
+ async writeState(state) {
380
+ const file = this.getStateFilePath();
381
+ await mkdir(path.dirname(file), { recursive: true });
382
+ await writeFile(file, JSON.stringify(state, null, 2), "utf-8");
383
+ }
384
+ getStateFilePath() {
385
+ return path.join(this.baseDir, "jin10-flash-monitor-state.json");
386
+ }
387
+ }
388
+ function buildStageOneCandidates(flashes, watchlist) {
389
+ return flashes
390
+ .filter((flash) => !shouldIgnoreFlash(flash.content))
391
+ .map((flash) => buildStageOneCandidate(flash, watchlist))
392
+ .filter((candidate) => candidate != null);
393
+ }
394
+ function mergeFlashRecords(...groups) {
395
+ const merged = [];
396
+ const seen = new Set();
397
+ for (const group of groups) {
398
+ for (const item of group) {
399
+ if (!item.flash_key || seen.has(item.flash_key)) {
400
+ continue;
401
+ }
402
+ seen.add(item.flash_key);
403
+ merged.push(item);
404
+ }
405
+ }
406
+ return sortFlashRecords(merged);
407
+ }
408
+ function sortFlashRecords(entries) {
409
+ return [...entries].sort((left, right) => left.published_ts - right.published_ts);
410
+ }
411
+ function buildStageOneCandidate(flash, watchlist) {
412
+ const normalizedContent = normalizeText(flash.content);
413
+ const matches = [];
414
+ for (const item of watchlist) {
415
+ const directKeywords = buildDirectKeywords(item)
416
+ .filter((keyword) => normalizedContent.includes(normalizeText(keyword)));
417
+ const boardKeywords = buildBoardKeywords(item)
418
+ .filter((keyword) => normalizedContent.includes(normalizeText(keyword)));
419
+ if (directKeywords.length === 0 && boardKeywords.length === 0) {
420
+ continue;
421
+ }
422
+ matches.push({
423
+ item,
424
+ directKeywords,
425
+ boardKeywords,
426
+ });
427
+ }
428
+ if (matches.length === 0) {
429
+ return null;
430
+ }
431
+ return {
432
+ flash,
433
+ matches,
434
+ };
435
+ }
436
+ function buildDirectKeywords(item) {
437
+ const keywords = [item.symbol, item.name];
438
+ return uniqueStrings(keywords);
439
+ }
440
+ function buildBoardKeywords(item) {
441
+ return uniqueStrings([
442
+ item.sector ?? "",
443
+ ...item.themes,
444
+ ]).filter((keyword) => isUsefulBoardKeyword(keyword));
445
+ }
446
+ function isUsefulBoardKeyword(value) {
447
+ const normalized = value.replace(/\s+/g, "").trim();
448
+ if (normalized.length < 2) {
449
+ return false;
450
+ }
451
+ return !/(行业|板块|题材|概念|个股|公司|市场|资讯|公告|快讯|新闻|政策)$/.test(normalized);
452
+ }
453
+ function shouldIgnoreFlash(content) {
454
+ const text = content.trim();
455
+ return NOISE_PATTERNS.some((pattern) => pattern.test(text));
456
+ }
457
+ function buildFallbackDecision(candidate) {
458
+ const directSymbols = candidate.matches
459
+ .filter((match) => match.directKeywords.length > 0)
460
+ .map((match) => match.item.symbol);
461
+ if (directSymbols.length === 0) {
462
+ return {
463
+ alert: false,
464
+ importance: "low",
465
+ relevantSymbols: [],
466
+ headline: "",
467
+ reason: "",
468
+ };
469
+ }
470
+ return {
471
+ alert: true,
472
+ importance: inferImportance(candidate.flash.content),
473
+ relevantSymbols: uniqueStrings(directSymbols),
474
+ headline: "Jin10快讯直接命中自选股",
475
+ reason: "快讯直接提及关注股票/公司,建议尽快核实公告、消息来源与盘面反馈。",
476
+ };
477
+ }
478
+ function resolveRelevantSymbols(llmSymbols, matches) {
479
+ const available = new Set(matches.map((match) => match.item.symbol));
480
+ const directSymbols = matches
481
+ .filter((match) => match.directKeywords.length > 0)
482
+ .map((match) => match.item.symbol);
483
+ const normalized = uniqueStrings(llmSymbols).filter((symbol) => available.has(symbol));
484
+ if (normalized.length > 0) {
485
+ return normalized;
486
+ }
487
+ if (directSymbols.length > 0) {
488
+ return uniqueStrings(directSymbols);
489
+ }
490
+ return uniqueStrings(matches.map((match) => match.item.symbol));
491
+ }
492
+ function buildAlertMessage(flash, matches, decision, symbols, alertService) {
493
+ const symbolLabels = symbols.map((symbol) => {
494
+ const matched = matches.find((entry) => entry.item.symbol === symbol);
495
+ return matched ? `${matched.item.name}(${matched.item.symbol})` : symbol;
496
+ });
497
+ return alertService.formatSystemNotification(`📰 ${decision.headline || "Jin10快讯命中自选"}`, [
498
+ `时间: ${flash.published_at}`,
499
+ `级别: ${formatImportance(decision.importance)}`,
500
+ `关联: ${symbolLabels.join("、")}`,
501
+ `判断: ${decision.reason || "快讯与当前关注标的相关,建议尽快核实。"}`,
502
+ `快讯: ${truncate(flash.content, 260)}`,
503
+ `来源: ${flash.url}`,
504
+ ]);
505
+ }
506
+ function toFlashRecord(item) {
507
+ const published = new Date(item.time);
508
+ if (Number.isNaN(published.getTime())) {
509
+ return null;
510
+ }
511
+ const publishedAt = formatChinaDateTime(published);
512
+ return {
513
+ flash_key: buildFlashKey(item.url, item.time, item.content),
514
+ published_at: publishedAt,
515
+ published_ts: published.getTime(),
516
+ content: item.content.trim(),
517
+ url: item.url.trim(),
518
+ ingested_at: formatChinaDateTime(),
519
+ raw: item.raw,
520
+ };
521
+ }
522
+ function buildFlashKey(url, time, content) {
523
+ if (url.trim()) {
524
+ return url.trim();
525
+ }
526
+ return createHash("sha1")
527
+ .update(`${time}\n${content}`)
528
+ .digest("hex");
529
+ }
530
+ function inferImportance(content) {
531
+ return HIGH_IMPORTANCE_KEYWORDS.some((keyword) => content.includes(keyword))
532
+ ? "high"
533
+ : "medium";
534
+ }
535
+ function uniqueStrings(values) {
536
+ const seen = new Set();
537
+ const result = [];
538
+ for (const value of values) {
539
+ const normalized = value.replace(/\s+/g, "").trim();
540
+ if (!normalized || seen.has(normalized)) {
541
+ continue;
542
+ }
543
+ seen.add(normalized);
544
+ result.push(normalized);
545
+ }
546
+ return result;
547
+ }
548
+ function normalizeText(value) {
549
+ return value.toLowerCase().replace(/\s+/g, "");
550
+ }
551
+ function truncate(value, maxLength) {
552
+ if (value.length <= maxLength) {
553
+ return value;
554
+ }
555
+ return `${value.slice(0, maxLength)}...`;
556
+ }
557
+ function formatImportance(value) {
558
+ switch (value) {
559
+ case "high":
560
+ return "高";
561
+ case "low":
562
+ return "低";
563
+ default:
564
+ return "中";
565
+ }
566
+ }
567
+ function parseChinaTime(value) {
568
+ if (!value) {
569
+ return null;
570
+ }
571
+ const normalized = value.trim();
572
+ if (!normalized) {
573
+ return null;
574
+ }
575
+ const candidate = /([+-]\d{2}:\d{2}|Z)$/.test(normalized)
576
+ ? normalized.replace(" ", "T")
577
+ : `${normalized.replace(" ", "T")}+08:00`;
578
+ const date = new Date(candidate);
579
+ return Number.isNaN(date.getTime()) ? null : date;
580
+ }
581
+ function toChinaTimeTimestamp(value) {
582
+ return parseChinaTime(value)?.getTime() ?? Date.now();
583
+ }
584
+ function isNightQuietHour() {
585
+ const hour = chinaHour();
586
+ return hour >= 22 || hour < 6;
587
+ }