koishi-plugin-bns-rate 1.1.0 → 1.2.0

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 ADDED
@@ -0,0 +1,71 @@
1
+ # koishi-plugin-bns-rate
2
+
3
+ 从 [dd373.com](https://www.dd373.com/s-d5gqt8.html) 获取剑灵怀旧服**神石**实时汇率,输出当前时段最低比例前 N 条。
4
+
5
+ 支持**官方 QQ 机器人 Markdown 卡片**回复:标题 + 横幅图 + 红绿报价 + 跳转按钮,全部可在插件设置页配置。
6
+
7
+ ## 命令
8
+
9
+ | 命令 | 说明 |
10
+ | --- | --- |
11
+ | `bnsrate` / `神石` / `shenshi` | 查询当前时段最低神石比例 |
12
+ | `bnsrate -f` | 忽略缓存,强制刷新 |
13
+ | `bnsrate.preview` | 只输出卡片 Markdown 源码(不发送卡片),调试用 |
14
+
15
+ ## 卡片效果
16
+
17
+ 官方 QQ 机器人的**群聊 / 私聊**下,命令回复会变成 Markdown 卡片,形如:
18
+
19
+ ```
20
+ # 神石比例实时播报
21
+
22
+ ![DD373 DATA #560px #120px](<横幅图片直链>)
23
+
24
+ 大家关注的 DD373 最低神石比例前5条来啦!当前时段(2026/9/10 10:46)最优价格如下:
25
+
26
+ 1元 = <font color="#E60012">151.51</font>神石 | 1神石 = <font color="#00A650">0.0066</font>元
27
+ ...
28
+ ***
29
+ 如需查看完整详情或进行交易,请点击下方按钮直接跳转至 DD373 官网。
30
+ [ 点此直达DD373网站 ]
31
+ ```
32
+
33
+ 其它平台(OneBot / Telegram 等)、QQ 频道(`qqguild`)、以及卡片发送失败时,都会**自动回退为纯文本**,不会发不出消息。
34
+
35
+ ## 配置项
36
+
37
+ | 配置 | 默认值 | 说明 |
38
+ | --- | --- | --- |
39
+ | `url` | dd373 剑灵怀旧服页面 | 目标区服页面 URL |
40
+ | `cacheSeconds` | `60` | 缓存时间(秒),缓存期内重复查询不再请求页面 |
41
+ | `count` | `5` | 显示的条目数量(1–10) |
42
+ | `markdown` | `true` | 用 QQ 官方 Markdown 卡片回复 |
43
+ | `title` | `神石比例实时播报` | 卡片标题 |
44
+ | `bannerUrl` | 空 | **卡片横幅图片直链**,必须是公网可访问的 URL;留空则不显示图片 |
45
+ | `bannerAlt` | `DD373 DATA` | 横幅图片替代文字 |
46
+ | `bannerWidth` / `bannerHeight` | `560` / `120` | 横幅显示尺寸(px),`0` = 交给 QQ 自动缩放。建议与图片原始宽高比一致 |
47
+ | `intro` | 见插件 | 标题下方引导语,可用 `{count}` `{time}` `{date}` 变量 |
48
+ | `footer` | 见插件 | 报价列表下方说明文字(仅在开启按钮时显示) |
49
+ | `button` | `true` | 是否显示底部跳转按钮 |
50
+ | `buttonText` | `点此直达DD373网站` | 按钮文字 |
51
+ | `buttonUrl` | `https://www.dd373.com/s-d5gqt8.html` | 按钮跳转链接 |
52
+ | `colorForward` | `#E60012` | 「1元=xx神石」数字颜色,留空不染色 |
53
+ | `colorReverse` | `#00A650` | 「1神石=xx元」数字颜色,留空不染色 |
54
+ | `decimalsForward` | `2` | 「1元=xx神石」保留小数位(**截断**,不四舍五入),`0` = 原始值 |
55
+ | `decimalsReverse` | `4` | 「1神石=xx元」保留小数位(截断),`0` = 原始值 |
56
+
57
+ ## ⚠️ 注意事项
58
+
59
+ 1. **颜色只在桌面端生效**。QQ 官方 Markdown 的[支持格式](https://bot.qq.com/wiki/develop/api-v2/server-inter/message/type/markdown.html)中没有字体颜色,颜色依赖 HTML 标签,**只有桌面端 QQNT 会渲染**;安卓 / iOS 会显示为普通文字(卡片本身不会出错)。
60
+ 2. **横幅图片必须是公网直链**,QQ 会下载并转存该资源。请勿使用官方的 `server-temp`(首次访问后即删除文件,群里多人同时查看会出现白图)。
61
+ 3. 群聊/单聊自定义 Markdown 自 2026/04/23 起已开放给所有机器人,**无需申请 Markdown 模板**;QQ 频道(`qqguild`)场景仍需内邀开通,本插件在频道下自动回退纯文本。
62
+ 4. 命令回复属被动消息,不占用主动消息配额。
63
+
64
+ ## 依赖
65
+
66
+ - `koishi` ^4.18.0
67
+ - 需要 `http` 服务(`ctx.http`)
68
+
69
+ ## License
70
+
71
+ MIT
package/index.js CHANGED
@@ -1,26 +1,93 @@
1
- const { Schema } = require('koishi')
1
+ const { Schema, h } = require('koishi')
2
2
 
3
3
  const DEFAULT_URL = 'https://www.dd373.com/s-d5gqt8-0-0-0-0-0-0-0-0-0-0-0-1-0-5-0.html'
4
+ const DEFAULT_BUTTON_URL = 'https://www.dd373.com/s-d5gqt8.html'
4
5
 
5
6
  const FORWARD_RE = /1元\s*=\s*(\d+\.?\d*)神石/g
7
+ const REVERSE_RE = /(?<!\d)1神石\s*=\s*(\d+\.\d+)元/
8
+
9
+ // ===== 配置 Schema =====
6
10
 
7
11
  const Config = Schema.object({
8
12
  url: Schema.string()
9
13
  .default(DEFAULT_URL)
10
- .description('dd373 page URL for the target game server'),
14
+ .description('dd373 目标区服页面 URL'),
11
15
  cacheSeconds: Schema.number()
12
16
  .default(60)
13
17
  .min(10)
14
18
  .max(300)
15
- .description('Cache TTL in seconds'),
19
+ .description('缓存时间(秒),缓存内重复查询不再请求页面'),
16
20
  count: Schema.number()
17
21
  .default(5)
18
22
  .min(1)
19
23
  .max(10)
20
24
  .description('显示的条目数量'),
25
+
26
+ markdown: Schema.boolean()
27
+ .default(true)
28
+ .description('💬 用 QQ官方 Markdown 卡片回复(仅官方QQ机器人「群/私聊」生效;频道机器人、OneBot 等其它平台自动回退纯文本,发送失败也会回退)'),
29
+ title: Schema.string()
30
+ .default('神石比例实时播报')
31
+ .description('卡片顶部标题(# 一级标题)'),
32
+
33
+ bannerUrl: Schema.string()
34
+ .default('')
35
+ .description('🖼️ 卡片横幅图片 URL:必须是公网可直接访问的直链(QQ 会下载转存)。留空则不显示图片'),
36
+ bannerAlt: Schema.string()
37
+ .default('DD373 DATA')
38
+ .description('横幅图片的替代文字(图片加载失败时显示)'),
39
+ bannerWidth: Schema.number()
40
+ .default(560)
41
+ .min(0)
42
+ .max(2000)
43
+ .description('横幅宽度(px)。0 = 不指定,交给 QQ 自动缩放'),
44
+ bannerHeight: Schema.number()
45
+ .default(120)
46
+ .min(0)
47
+ .max(2000)
48
+ .description('横幅高度(px)。建议与图片原始宽高比一致,否则会被拉伸。0 = 不指定'),
49
+
50
+ intro: Schema.string()
51
+ .role('textarea', { rows: [2, 4] })
52
+ .default('大家关注的 DD373 最低神石比例前{count}条来啦!当前时段({time})最优价格如下:')
53
+ .description('📝 标题下方的引导语。可用变量:{count} 条数、{time} 日期时间、{date} 日期'),
54
+ footer: Schema.string()
55
+ .role('textarea', { rows: [2, 4] })
56
+ .default('如需查看完整详情或进行交易,请点击下方按钮直接跳转至 DD373 官网。')
57
+ .description('📝 报价列表下方的说明文字(仅在开启按钮时显示)'),
58
+
59
+ button: Schema.boolean()
60
+ .default(true)
61
+ .description('🔘 在卡片底部显示跳转按钮'),
62
+ buttonText: Schema.string()
63
+ .default('点此直达DD373网站')
64
+ .description('按钮文字'),
65
+ buttonUrl: Schema.string()
66
+ .default(DEFAULT_BUTTON_URL)
67
+ .description('按钮跳转的链接'),
68
+
69
+ colorForward: Schema.string()
70
+ .default('#E60012')
71
+ .description('「1元=xx神石」数字的颜色,如 #E60012(红)。留空 = 不染色。⚠️ 颜色仅在桌面端 QQ 显示,安卓/iOS 会显示为普通文字'),
72
+ colorReverse: Schema.string()
73
+ .default('#00A650')
74
+ .description('「1神石=xx元」数字的颜色,如 #00A650(绿)。留空 = 不染色'),
75
+ decimalsForward: Schema.number()
76
+ .default(2)
77
+ .min(0)
78
+ .max(8)
79
+ .description('「1元=xx神石」保留几位小数(直接截断,不四舍五入)。0 = 显示原始值'),
80
+ decimalsReverse: Schema.number()
81
+ .default(4)
82
+ .min(0)
83
+ .max(8)
84
+ .description('「1神石=xx元」保留几位小数(直接截断)。0 = 显示原始值'),
21
85
  })
22
86
 
87
+ // ===== 抓取与解析 =====
88
+
23
89
  async function fetchRates(ctx, url, count) {
90
+ const limit = Math.max(1, Math.min(10, Number(count) || 5))
24
91
  const response = await ctx.http.get(url, {
25
92
  responseType: 'text',
26
93
  headers: {
@@ -38,12 +105,14 @@ async function fetchRates(ctx, url, count) {
38
105
  const forwardMatches = [...html.matchAll(FORWARD_RE)]
39
106
  const rates = []
40
107
  for (const m of forwardMatches) {
41
- const ctx = html.substring(Math.max(0, m.index - 50), m.index + 100)
42
- if (ctx.includes('1神石=')) {
108
+ const context = html.substring(Math.max(0, m.index - 50), m.index + 100)
109
+ if (context.includes('1神石=')) {
43
110
  const forward = parseFloat(m[1])
44
- const reverse = Math.round((1 / forward) * 10000) / 10000
111
+ // 从同一个上下文中提取反向汇率,避免浮点计算误差
112
+ const revMatch = context.match(REVERSE_RE)
113
+ const reverse = revMatch ? parseFloat(revMatch[1]) : Math.round((1 / forward) * 10000) / 10000
45
114
  rates.push({ forward, reverse })
46
- if (rates.length >= count) break
115
+ if (rates.length >= limit) break
47
116
  }
48
117
  }
49
118
 
@@ -54,47 +123,179 @@ async function fetchRates(ctx, url, count) {
54
123
  return rates
55
124
  }
56
125
 
57
- function formatMessage(rates, timestamp) {
58
- const time = new Date(timestamp).toLocaleString('zh-CN', {
59
- timeZone: 'Asia/Shanghai',
60
- })
126
+ // ===== 格式化 =====
127
+
128
+ function pad2(n) {
129
+ return String(n).padStart(2, '0')
130
+ }
131
+
132
+ // 固定按东八区(UTC+8)显示,避免依赖运行环境的时区设置
133
+ function formatTime(timestamp, withSeconds) {
134
+ const d = new Date(timestamp + 8 * 3600 * 1000)
135
+ const date = `${d.getUTCFullYear()}/${d.getUTCMonth() + 1}/${d.getUTCDate()}`
136
+ const time = `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`
137
+ return withSeconds ? `${date} ${time}:${pad2(d.getUTCSeconds())}` : `${date} ${time}`
138
+ }
139
+
140
+ function colorize(text, color) {
141
+ const c = String(color || '').trim()
142
+ return c ? `<font color="${c}">${text}</font>` : String(text)
143
+ }
144
+
145
+ // 按位截断(不四舍五入),并补足小数位;decimals 为 0 时返回原始值
146
+ // 先定点到 d+2 位消除浮点误差(否则 0.0066 * 10000 会得到 65.9999 这种值)
147
+ function truncate(value, decimals) {
148
+ const d = Math.max(0, Math.min(8, Math.floor(Number(decimals) || 0)))
149
+ if (!d) return String(value)
150
+ const fixed = Number(value).toFixed(d + 2)
151
+ const [int, frac = ''] = fixed.split('.')
152
+ return `${int}.${frac.slice(0, d)}`
153
+ }
154
+
155
+ function fillTemplate(tpl, vars) {
156
+ let out = String(tpl || '')
157
+ for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v))
158
+ return out
159
+ }
160
+
161
+ // 纯文本格式(非官方QQ平台 / 卡片发送失败时的回退,也是历史版本的表现)
162
+ function formatText(rates, timestamp, opts = {}) {
61
163
  const lines = [`DD373当前时段最低神石比例前${rates.length}条如下:`]
62
164
  for (const r of rates) {
63
165
  lines.push(`1元 = ${r.forward}神石 1神石 = ${r.reverse}元`)
64
166
  }
65
- lines.push(`查询时间: ${time}`)
66
- lines.push('详情: https://www.dd373.com/s-d5gqt8.html')
167
+ lines.push(`查询时间: ${formatTime(timestamp, true)}`)
168
+ lines.push(`详情: ${DEFAULT_BUTTON_URL}`)
169
+ if (opts.stale) {
170
+ lines.push(`[警告] 汇率数据已过期,刷新失败: ${opts.error || '未知错误'}`)
171
+ }
67
172
  return lines.join('\n')
68
173
  }
69
174
 
175
+ // QQ官方 Markdown 卡片源码
176
+ function buildCardMarkdown(rates, timestamp, config) {
177
+ const time = formatTime(timestamp, false)
178
+ const lines = [`# ${config.title}`]
179
+
180
+ const banner = String(config.bannerUrl || '').trim()
181
+ if (banner) {
182
+ const w = Math.max(0, Math.round(Number(config.bannerWidth) || 0))
183
+ const ht = Math.max(0, Math.round(Number(config.bannerHeight) || 0))
184
+ lines.push('', `![${config.bannerAlt} #${w}px #${ht}px](${banner})`)
185
+ }
186
+
187
+ const intro = fillTemplate(config.intro, {
188
+ count: rates.length,
189
+ time,
190
+ date: time.split(' ')[0],
191
+ }).trim()
192
+ if (intro) lines.push('', intro)
193
+
194
+ lines.push('', ...rates.map(r => (
195
+ `1元 = ${colorize(truncate(r.forward, config.decimalsForward), config.colorForward)}神石 | `
196
+ + `1神石 = ${colorize(truncate(r.reverse, config.decimalsReverse), config.colorReverse)}元`
197
+ )))
198
+
199
+ const footer = String(config.footer || '').trim()
200
+ if (config.button && footer) {
201
+ lines.push('', '***', '', footer)
202
+ }
203
+
204
+ return lines.join('\n')
205
+ }
206
+
207
+ // 组装成 koishi 元素:qq:markdown = 原生 markdown 正文,button-group/button = 卡片底部按钮
208
+ // 注意:必须用 qq:markdown 元素包裹,否则适配器会把 # * - > 等符号全部转义成纯文本
209
+ function buildCardElements(markdown, config) {
210
+ const elements = [h('qq:markdown', {}, markdown)]
211
+ if (config.button && String(config.buttonUrl || '').trim()) {
212
+ elements.push(h('button-group', {},
213
+ h('button', {
214
+ type: 'link',
215
+ href: String(config.buttonUrl).trim(),
216
+ class: 'primary',
217
+ }, config.buttonText || '点此查看详情')))
218
+ }
219
+ return elements
220
+ }
221
+
222
+ // 只有官方QQ机器人的「群聊 / 私聊」支持自定义 markdown + 自定义按钮
223
+ // 频道机器人 platform 是 qqguild(且 markdown 需内邀开通),其它适配器一律走纯文本
224
+ function supportsCard(session) {
225
+ const bot = (session && session.bot) || {}
226
+ return bot.platform === 'qq'
227
+ && !!bot.internal
228
+ && typeof bot.internal.sendMessage === 'function'
229
+ }
230
+
231
+ // ===== 插件主体 =====
232
+
70
233
  function apply(ctx, config) {
234
+ const logger = ctx.logger('bns-rate')
71
235
  let cache = null
72
236
 
237
+ // 取数据:优先缓存,失败时回退到过期缓存
238
+ async function resolveRates(force) {
239
+ const ttl = config.cacheSeconds * 1000
240
+ if (!force && cache && Date.now() - cache.timestamp < ttl) {
241
+ return { rates: cache.rates, timestamp: cache.timestamp, stale: false }
242
+ }
243
+ try {
244
+ const rates = await fetchRates(ctx, config.url, config.count)
245
+ cache = { rates, timestamp: Date.now() }
246
+ return { rates, timestamp: cache.timestamp, stale: false }
247
+ } catch (e) {
248
+ const message = e instanceof Error ? e.message : String(e)
249
+ if (cache) {
250
+ logger.warn(`刷新失败,使用过期缓存: ${message}`)
251
+ return { rates: cache.rates, timestamp: cache.timestamp, stale: true, error: message }
252
+ }
253
+ throw e
254
+ }
255
+ }
256
+
73
257
  ctx.command('bnsrate', '查询剑灵怀旧服神石汇率')
74
258
  .alias('神石')
75
259
  .alias('shenshi')
76
- .action(async () => {
77
- const ttl = config.cacheSeconds * 1000
78
-
79
- if (cache && Date.now() - cache.timestamp < ttl) {
80
- return formatMessage(cache.rates, cache.timestamp)
81
- }
82
-
260
+ .option('force', '-f 忽略缓存,强制刷新')
261
+ .action(async ({ session, options }) => {
262
+ let data
83
263
  try {
84
- const rates = await fetchRates(ctx, config.url, config.count)
85
- cache = { rates, timestamp: Date.now() }
86
- return formatMessage(rates, cache.timestamp)
264
+ data = await resolveRates(!!(options && options.force))
87
265
  } catch (e) {
88
266
  const message = e instanceof Error ? e.message : String(e)
267
+ return '获取汇率失败: ' + message
268
+ }
89
269
 
90
- if (cache) {
91
- return formatMessage(cache.rates, cache.timestamp)
92
- + '\n[警告] 汇率数据已过期,刷新失败: ' + message
270
+ // 官方QQ机器人的群聊/私聊:发 Markdown 卡片
271
+ if (config.markdown && session && supportsCard(session) && typeof session.send === 'function') {
272
+ const markdown = buildCardMarkdown(data.rates, data.timestamp, config)
273
+ try {
274
+ await session.send(buildCardElements(markdown, config))
275
+ return
276
+ } catch (e) {
277
+ logger.warn('Markdown 卡片发送失败,回退纯文本:', (e && e.message) || e)
93
278
  }
279
+ }
94
280
 
281
+ return formatText(data.rates, data.timestamp, { stale: data.stale, error: data.error })
282
+ })
283
+
284
+ // 调试用:只看卡片源码,不发送卡片(在控制台执行时也能看)
285
+ ctx.command('bnsrate.preview', '预览神石 Markdown 卡片源码(不发送卡片)')
286
+ .action(async () => {
287
+ try {
288
+ const data = await resolveRates(false)
289
+ return buildCardMarkdown(data.rates, data.timestamp, config)
290
+ } catch (e) {
291
+ const message = e instanceof Error ? e.message : String(e)
95
292
  return '获取汇率失败: ' + message
96
293
  }
97
294
  })
98
295
  }
99
296
 
100
- module.exports = { Config, apply }
297
+ module.exports = {
298
+ Config,
299
+ apply,
300
+ _test: { fetchRates, formatText, buildCardMarkdown, buildCardElements, supportsCard, fillTemplate, truncate },
301
+ }
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "koishi-plugin-bns-rate",
3
- "version": "1.1.0",
4
- "description": "Fetch BNS Classic Divine Stone exchange rate from dd373.com",
3
+ "version": "1.2.0",
4
+ "description": "Fetch BNS Classic Divine Stone exchange rate from dd373.com, with QQ official Markdown card support",
5
5
  "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "README.md"
9
+ ],
6
10
  "license": "MIT",
7
- "keywords": ["koishi", "koishi-plugin", "bns", "dd373", "exchange-rate", "神石", "剑灵"],
11
+ "keywords": ["koishi", "koishi-plugin", "bns", "dd373", "exchange-rate", "神石", "剑灵", "markdown"],
8
12
  "peerDependencies": {
9
13
  "koishi": "^4.18.0"
10
14
  },
@@ -13,8 +17,8 @@
13
17
  },
14
18
  "koishi": {
15
19
  "description": {
16
- "zh": "从 dd373.com 获取剑灵怀旧服神石实时汇率,输入命令即可查询最低比例。支持 神石/bnsrate/shenshi 命令。",
17
- "en": "Fetch BNS Classic Divine Stone exchange rate from dd373.com"
20
+ "zh": "从 dd373.com 获取剑灵怀旧服神石实时汇率,输入 神石/bnsrate/shenshi 即可查询最低比例。支持官方QQ机器人 Markdown 卡片(标题+横幅图+红绿报价+跳转按钮),横幅图片与配色均可在插件设置页配置。",
21
+ "en": "Fetch BNS Classic Divine Stone exchange rate from dd373.com. Supports QQ official bot Markdown cards (title, banner image, colored rates, jump button), all configurable."
18
22
  },
19
23
  "service": {
20
24
  "required": ["http"]
@@ -1,18 +0,0 @@
1
- name: Publish to npm
2
-
3
- on:
4
- push:
5
- branches: [master]
6
-
7
- jobs:
8
- publish:
9
- runs-on: ubuntu-latest
10
- steps:
11
- - uses: actions/checkout@v4
12
- - uses: actions/setup-node@v4
13
- with:
14
- node-version: '22'
15
- registry-url: 'https://registry.npmjs.org'
16
- - run: npm publish
17
- env:
18
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/.gitignore DELETED
@@ -1,6 +0,0 @@
1
- # Ignore everything except plugin files
2
- /*
3
- !.gitignore
4
- !/.github
5
- !/index.js
6
- !/package.json