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