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.
- package/README.md +13 -6
- package/dist/analysis/parsers/flash-alert-decision.parser.d.ts +8 -0
- package/dist/analysis/parsers/flash-alert-decision.parser.js +34 -0
- package/dist/analysis/types/composite-analysis.d.ts +11 -0
- package/dist/background/jin10-flash.worker.d.ts +8 -0
- package/dist/background/jin10-flash.worker.js +24 -0
- package/dist/bootstrap.d.ts +4 -0
- package/dist/bootstrap.js +18 -1
- package/dist/config/normalize.js +16 -6
- package/dist/config/schema.d.ts +6 -1
- package/dist/config/schema.js +4 -0
- package/dist/dev/run-monitor-loop.js +6 -2
- package/dist/dev/tickflow-assist-cli.js +70 -0
- package/dist/plugin-commands.js +7 -0
- package/dist/prompts/analysis/flash-monitor-alert-prompt.d.ts +11 -0
- package/dist/prompts/analysis/flash-monitor-alert-prompt.js +44 -0
- package/dist/prompts/analysis/index.d.ts +1 -0
- package/dist/prompts/analysis/index.js +1 -0
- package/dist/prompts/analysis/post-close-review-user-prompt.js +18 -0
- package/dist/services/alert-service.d.ts +1 -0
- package/dist/services/alert-service.js +21 -3
- package/dist/services/jin10-flash-monitor-service.d.ts +33 -0
- package/dist/services/jin10-flash-monitor-service.js +587 -0
- package/dist/services/jin10-mcp-service.d.ts +29 -0
- package/dist/services/jin10-mcp-service.js +242 -0
- package/dist/services/post-close-review-service.d.ts +6 -1
- package/dist/services/post-close-review-service.js +35 -1
- package/dist/storage/repositories/jin10-flash-delivery-repo.d.ts +11 -0
- package/dist/storage/repositories/jin10-flash-delivery-repo.js +93 -0
- package/dist/storage/repositories/jin10-flash-repo.d.ts +16 -0
- package/dist/storage/repositories/jin10-flash-repo.js +144 -0
- package/dist/storage/schemas.d.ts +2 -0
- package/dist/storage/schemas.js +19 -0
- package/dist/tools/flash-monitor-status.tool.d.ts +6 -0
- package/dist/tools/flash-monitor-status.tool.js +9 -0
- package/dist/types/flash-monitor.d.ts +17 -0
- package/dist/types/flash-monitor.js +1 -0
- package/dist/types/jin10.d.ts +30 -0
- package/dist/types/jin10.js +1 -0
- package/dist/utils/china-time.d.ts +1 -0
- package/dist/utils/china-time.js +5 -0
- package/openclaw.plugin.json +53 -1
- package/package.json +14 -6
|
@@ -153,20 +153,20 @@ export class AlertService {
|
|
|
153
153
|
try {
|
|
154
154
|
switch (this.channel) {
|
|
155
155
|
case "discord":
|
|
156
|
-
await runtimeContext.runtime.channel
|
|
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
|
|
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
|
|
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
|
+
}
|