koishi-plugin-stock 2.0.5 → 2.0.7
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 -0
- package/lib/index.js +151 -107
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,6 +57,19 @@ npm install koishi-plugin-stock
|
|
|
57
57
|
|
|
58
58
|
## 更新日志
|
|
59
59
|
|
|
60
|
+
### v2.0.7
|
|
61
|
+
- 新增定时广播任务失败重试机制:当网络中断导致消息发送失败时,自动开启 3 次重试,每次间隔 1 分钟
|
|
62
|
+
- 引入任务时效性检查:重试过程中若达到该任务的下一个执行时间点,则自动取消当前重试,防止消息堆积
|
|
63
|
+
|
|
64
|
+
### v2.0.6
|
|
65
|
+
- 优化定时任务触发逻辑:改用 `Intl` API 获取确切的上海时区时间,解决部分环境下时区偏移导致的触发失败问题
|
|
66
|
+
- 增强时间格式容错:自动处理 `9:30` vs `09:30` 以及全角冒号等配置问题
|
|
67
|
+
- 完善节假日 API:增加对 Cloudflare 拦截页面的防御逻辑,请求失败时自动回退到基础周末检查,确保任务触发稳定性
|
|
68
|
+
- 改进日志输出:在 `info` 级别增加关键任务触发和跳过的日志,方便诊断问题
|
|
69
|
+
|
|
70
|
+
### v2.0.5
|
|
71
|
+
- 使用标准 Koishi 配置注释、每个配置分类上壳有中文注释,更清晰地区分配置组
|
|
72
|
+
|
|
60
73
|
### v2.0.4
|
|
61
74
|
- 实现 Koishi 业界标准的配置分组,每个配置项打上对应的 role 标签,在插件设置页中形成可折叠的分组
|
|
62
75
|
|
package/lib/index.js
CHANGED
|
@@ -40,6 +40,7 @@ exports.Config = koishi_1.Schema.object({
|
|
|
40
40
|
});
|
|
41
41
|
function apply(ctx, config) {
|
|
42
42
|
const logger = ctx.logger('stock');
|
|
43
|
+
const retryQueue = [];
|
|
43
44
|
// 插件启动日志
|
|
44
45
|
logger.info('stock 插件已加载');
|
|
45
46
|
logger.info(`当前配置: broadcastTasks=${config.broadcastTasks?.length || 0} 个任务`);
|
|
@@ -48,142 +49,185 @@ function apply(ctx, config) {
|
|
|
48
49
|
logger.info(`任务 ${idx + 1}: times=${task.times}, type=${task.type}, targetIds=${task.targetIds}, content=${task.content}`);
|
|
49
50
|
});
|
|
50
51
|
}
|
|
52
|
+
// 核心广播执行函数
|
|
53
|
+
async function performBroadcast(task, isRetry = false) {
|
|
54
|
+
const label = isRetry ? '[重试广播]' : '[初始广播]';
|
|
55
|
+
try {
|
|
56
|
+
if (!task.targetIds || typeof task.targetIds !== 'string') {
|
|
57
|
+
logger.warn(`${label} 任务配置不完整: targetIds 字段无效`);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
const targetIds = task.targetIds.split(/[,,]/).map(id => id.trim()).filter(id => id);
|
|
61
|
+
if (targetIds.length === 0) {
|
|
62
|
+
logger.warn(`${label} 任务目标列表为空: ${task.content}`);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
logger.info(`${label} 正在执行: ${task.content} -> ${targetIds.join(',')} (${task.type})`);
|
|
66
|
+
let message = '';
|
|
67
|
+
// 1. 获取内容
|
|
68
|
+
if (task.content === '活跃市值') {
|
|
69
|
+
try {
|
|
70
|
+
const responseText = await ctx.http.get('http://stock.svip886.com/api/indexes', { responseType: 'text' });
|
|
71
|
+
message = `📊 定时广播 - 指数看板:\n\n${responseText}`;
|
|
72
|
+
}
|
|
73
|
+
catch (apiErr) {
|
|
74
|
+
logger.error(`${label} 获取活跃市值 API 失败`, apiErr);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
else if (task.content === '涨停看板' || task.content === '跌停看板') {
|
|
79
|
+
try {
|
|
80
|
+
const apiType = task.content === '涨停看板' ? 'limit_up' : 'limit_down';
|
|
81
|
+
const imageUrl = `http://stock.svip886.com/api/${apiType}.png`;
|
|
82
|
+
const imageBuffer = await ctx.http.get(imageUrl, { responseType: 'arraybuffer' });
|
|
83
|
+
const base64Image = Buffer.from(imageBuffer).toString('base64');
|
|
84
|
+
message = `🔔 定时广播 - ${task.content}:\n<img src="data:image/png;base64,${base64Image}" />`;
|
|
85
|
+
}
|
|
86
|
+
catch (apiErr) {
|
|
87
|
+
logger.error(`${label} 获取${task.content}图片 API 失败`, apiErr);
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!message)
|
|
92
|
+
return false;
|
|
93
|
+
// 2. 发送消息
|
|
94
|
+
const bot = ctx.bots.find(b => b.status === 'online' || b.status === 1) || ctx.bots[0];
|
|
95
|
+
if (!bot) {
|
|
96
|
+
logger.error(`${label} 无可用机器人实例`);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
let allSuccess = true;
|
|
100
|
+
for (const targetId of targetIds) {
|
|
101
|
+
try {
|
|
102
|
+
if (task.type === 'private') {
|
|
103
|
+
await bot.sendPrivateMessage(targetId, message);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
await bot.sendMessage(targetId, message);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
logger.error(`${label} 发送失败 (${targetId}): ${err.message}`);
|
|
111
|
+
allSuccess = false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return allSuccess;
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
logger.error(`${label} 执行过程发生严重错误`, error);
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// 获取任务的下一个执行时间点
|
|
122
|
+
function getNextScheduledTime(task, currentTime) {
|
|
123
|
+
const times = task.times.split(/[,,]/).map(s => {
|
|
124
|
+
let time = s.trim();
|
|
125
|
+
if (/^\d:\d{2}$/.test(time))
|
|
126
|
+
time = '0' + time;
|
|
127
|
+
return time;
|
|
128
|
+
}).sort();
|
|
129
|
+
const currentIndex = times.indexOf(currentTime);
|
|
130
|
+
if (currentIndex === -1)
|
|
131
|
+
return times[0]; // 如果当前时间不在列表里,假设下一个是第一个
|
|
132
|
+
return times[(currentIndex + 1) % times.length];
|
|
133
|
+
}
|
|
51
134
|
// 定时任务逻辑
|
|
52
135
|
let lastCheckedMinute = '';
|
|
53
136
|
ctx.setInterval(async () => {
|
|
54
137
|
const now = new Date();
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
138
|
+
const nowTimestamp = now.getTime();
|
|
139
|
+
const chinaTimeStr = now.toLocaleTimeString('zh-CN', {
|
|
140
|
+
timeZone: 'Asia/Shanghai',
|
|
141
|
+
hour12: false,
|
|
142
|
+
hour: '2-digit',
|
|
143
|
+
minute: '2-digit'
|
|
144
|
+
});
|
|
145
|
+
const currentTime = chinaTimeStr.replace(':', ':');
|
|
61
146
|
if (config.enableDebugLog && currentTime !== lastCheckedMinute) {
|
|
62
|
-
logger.info(`[定时任务检查] 当前时间: ${currentTime}`);
|
|
147
|
+
logger.info(`[定时任务检查] 当前时间: ${currentTime} (上海时区)`);
|
|
148
|
+
}
|
|
149
|
+
// --- 1. 处理重试队列 ---
|
|
150
|
+
for (let i = retryQueue.length - 1; i >= 0; i--) {
|
|
151
|
+
const item = retryQueue[i];
|
|
152
|
+
// 检查是否达到1分钟间隔
|
|
153
|
+
if (nowTimestamp - item.lastAttemptTimestamp < 60000)
|
|
154
|
+
continue;
|
|
155
|
+
// 检查是否超过了下次执行时间
|
|
156
|
+
const nextTime = getNextScheduledTime(item.task, item.originalTime);
|
|
157
|
+
// 如果当前时间已经达到了该任务的下一个预定时间,或者已经跨天了,则取消重试
|
|
158
|
+
if (nextTime && (currentTime === nextTime || (currentTime > nextTime && item.originalTime < nextTime))) {
|
|
159
|
+
logger.warn(`[重试取消] 任务 ${item.task.content} (${item.originalTime}) 已过期或达到下个周期`);
|
|
160
|
+
retryQueue.splice(i, 1);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
item.retryCount++;
|
|
164
|
+
item.lastAttemptTimestamp = nowTimestamp;
|
|
165
|
+
logger.info(`[开始重试] 任务 ${item.task.content} (原定于 ${item.originalTime}), 第 ${item.retryCount}/3 次重试`);
|
|
166
|
+
const success = await performBroadcast(item.task, true);
|
|
167
|
+
if (success) {
|
|
168
|
+
logger.info(`[重试成功] 任务 ${item.task.content} 已成功补发`);
|
|
169
|
+
retryQueue.splice(i, 1);
|
|
170
|
+
}
|
|
171
|
+
else if (item.retryCount >= 3) {
|
|
172
|
+
logger.error(`[重试失败] 任务 ${item.task.content} 已达到最大重试次数,放弃`);
|
|
173
|
+
retryQueue.splice(i, 1);
|
|
174
|
+
}
|
|
63
175
|
}
|
|
176
|
+
// --- 2. 处理初始任务 ---
|
|
64
177
|
if (currentTime === lastCheckedMinute)
|
|
65
178
|
return;
|
|
66
179
|
if (!config.broadcastTasks || config.broadcastTasks.length === 0)
|
|
67
180
|
return;
|
|
68
|
-
// 检查当前时间是否有任务
|
|
69
181
|
const activeTasks = config.broadcastTasks.filter(t => {
|
|
70
|
-
if (!t.times || typeof t.times !== 'string')
|
|
71
|
-
logger.warn(`跳过配置不正确的任务: times 字段无效`);
|
|
182
|
+
if (!t.times || typeof t.times !== 'string')
|
|
72
183
|
return false;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
return
|
|
184
|
+
const times = t.times.split(/[,,]/).map(s => {
|
|
185
|
+
let time = s.trim();
|
|
186
|
+
if (/^\d:\d{2}$/.test(time))
|
|
187
|
+
time = '0' + time;
|
|
188
|
+
return time;
|
|
189
|
+
}).filter(s => s);
|
|
190
|
+
return times.includes(currentTime);
|
|
80
191
|
});
|
|
81
|
-
if (activeTasks.length === 0)
|
|
192
|
+
if (activeTasks.length === 0) {
|
|
193
|
+
if (currentTime !== lastCheckedMinute)
|
|
194
|
+
lastCheckedMinute = currentTime;
|
|
82
195
|
return;
|
|
83
|
-
lastCheckedMinute = currentTime;
|
|
84
|
-
if (config.enableDebugLog) {
|
|
85
|
-
logger.info(`[任务触发] 检测到定时任务: ${currentTime}, 共有 ${activeTasks.length} 个待执行任务`);
|
|
86
196
|
}
|
|
197
|
+
lastCheckedMinute = currentTime;
|
|
198
|
+
logger.info(`[任务触发] 命中 ${activeTasks.length} 个广播任务 (时间: ${currentTime})`);
|
|
87
199
|
try {
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
const dateStr = `${year}-${month}-${date}`;
|
|
200
|
+
const shanghaiDate = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Shanghai' }));
|
|
201
|
+
const isWeekend = (shanghaiDate.getDay() === 0 || shanghaiDate.getDay() === 6);
|
|
202
|
+
const year = shanghaiDate.getFullYear();
|
|
203
|
+
const month = (shanghaiDate.getMonth() + 1).toString().padStart(2, '0');
|
|
204
|
+
const day = shanghaiDate.getDate().toString().padStart(2, '0');
|
|
205
|
+
const dateStr = `${year}-${month}-${day}`;
|
|
95
206
|
let tradingDay = !isWeekend;
|
|
96
207
|
try {
|
|
97
|
-
logger.debug(`正在检查交易日状态: ${dateStr}`);
|
|
98
208
|
const holidayData = await ctx.http.get(`https://timor.tech/api/holiday/info/${dateStr}`);
|
|
99
|
-
if (holidayData && holidayData.type) {
|
|
100
|
-
// type: 0 工作日, 1 周末, 2 节日, 3 调休
|
|
209
|
+
if (holidayData && typeof holidayData === 'object' && holidayData.type) {
|
|
101
210
|
const typeCode = holidayData.type.type;
|
|
102
211
|
tradingDay = (typeCode === 0 || typeCode === 3);
|
|
103
|
-
logger.info(`节假日API返回类型: ${typeCode} (${holidayData.type.name}), 交易日状态: ${tradingDay}`);
|
|
104
212
|
}
|
|
105
213
|
}
|
|
106
214
|
catch (e) {
|
|
107
|
-
logger.warn(`节假日API
|
|
215
|
+
logger.warn(`节假日API请求失败: ${e.message}`);
|
|
108
216
|
}
|
|
109
217
|
if (!tradingDay) {
|
|
110
|
-
logger.info(
|
|
218
|
+
logger.info(`[定时任务跳过] ${dateStr} 非交易日`);
|
|
111
219
|
return;
|
|
112
220
|
}
|
|
113
221
|
for (const task of activeTasks) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
logger.info(`正在执行广播任务: ${task.content} -> ${targetIds.join(',')} (${task.type})`);
|
|
126
|
-
let message = '';
|
|
127
|
-
if (task.content === '活跃市值') {
|
|
128
|
-
try {
|
|
129
|
-
logger.info(`开始获取活跃市值数据...`);
|
|
130
|
-
const responseText = await ctx.http.get('http://stock.svip886.com/api/indexes', { responseType: 'text' });
|
|
131
|
-
message = `📊 定时广播 - 指数看板:\n\n${responseText}`;
|
|
132
|
-
logger.info(`活跃市值数据获取成功, 信息长度: ${message.length}`);
|
|
133
|
-
}
|
|
134
|
-
catch (apiErr) {
|
|
135
|
-
logger.error('获取活跃市值 API 失败', apiErr);
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
else if (task.content === '涨停看板' || task.content === '跌停看板') {
|
|
140
|
-
try {
|
|
141
|
-
const apiType = task.content === '涨停看板' ? 'limit_up' : 'limit_down';
|
|
142
|
-
const imageUrl = `http://stock.svip886.com/api/${apiType}.png`;
|
|
143
|
-
logger.info(`开始下载图片: ${imageUrl}`);
|
|
144
|
-
const imageBuffer = await ctx.http.get(imageUrl, { responseType: 'arraybuffer' });
|
|
145
|
-
logger.info(`图片下载成功, 大小: ${imageBuffer.byteLength} bytes`);
|
|
146
|
-
const base64Image = Buffer.from(imageBuffer).toString('base64');
|
|
147
|
-
message = `🔔 定时广播 - ${task.content}:\n<img src="data:image/png;base64,${base64Image}" />`;
|
|
148
|
-
logger.info(`图片base64编码成功, 信息长度: ${message.length}`);
|
|
149
|
-
}
|
|
150
|
-
catch (apiErr) {
|
|
151
|
-
logger.error(`获取${task.content}图片 API 失败`, apiErr);
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
else {
|
|
156
|
-
logger.warn(`不支持的广播内容类型: ${task.content}`);
|
|
157
|
-
continue;
|
|
158
|
-
}
|
|
159
|
-
if (!message) {
|
|
160
|
-
logger.warn(`未能生成消息内容: ${task.content}`);
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
const bot = ctx.bots.find(b => b.status === 'online' || b.status === 1) || ctx.bots[0];
|
|
164
|
-
if (!bot) {
|
|
165
|
-
logger.error(`无可用机器人实例,总有 ${ctx.bots.length} 个机器人`);
|
|
166
|
-
continue;
|
|
167
|
-
}
|
|
168
|
-
logger.info(`找到机器人: ${bot.platform}, 开始向 ${targetIds.length} 个目标发送`);
|
|
169
|
-
for (const targetId of targetIds) {
|
|
170
|
-
try {
|
|
171
|
-
logger.info(`将发送给 ${targetId}, 类型: ${task.type}`);
|
|
172
|
-
if (task.type === 'private') {
|
|
173
|
-
await bot.sendPrivateMessage(targetId, message);
|
|
174
|
-
}
|
|
175
|
-
else {
|
|
176
|
-
await bot.sendMessage(targetId, message);
|
|
177
|
-
}
|
|
178
|
-
logger.info(`广播任务发送成功: ${task.content} -> ${targetId}`);
|
|
179
|
-
}
|
|
180
|
-
catch (err) {
|
|
181
|
-
logger.error(`广播任务发送失败 (${targetId}): ${task.content}`, err);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
catch (error) {
|
|
186
|
-
logger.error(`定时广播任务执行失败: ${task.content}`, error);
|
|
222
|
+
const success = await performBroadcast(task);
|
|
223
|
+
if (!success) {
|
|
224
|
+
logger.warn(`[任务入队] 任务 ${task.content} 执行失败,加入重试队列`);
|
|
225
|
+
retryQueue.push({
|
|
226
|
+
task,
|
|
227
|
+
originalTime: currentTime,
|
|
228
|
+
retryCount: 0,
|
|
229
|
+
lastAttemptTimestamp: nowTimestamp
|
|
230
|
+
});
|
|
187
231
|
}
|
|
188
232
|
}
|
|
189
233
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-stock",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.7",
|
|
4
4
|
"description": "A Koishi plugin that fetches stock data and provides market analysis, including active market cap, stock alerts, limit-up board, and stock selection features with configurable blacklists for each command.",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"typings": "lib/index.d.ts",
|