dsh-xueqiu 1.3.0 → 1.4.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/dynamic/host.js CHANGED
@@ -1,4 +1,5 @@
1
1
  return {
2
+ inject: ['timer'],
2
3
  apply(ctx) {
3
4
  const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
4
5
  const BASE = 'https://stock.xueqiu.com'
@@ -9,6 +10,34 @@ return {
9
10
  let watchlist = null
10
11
  let uiState = null
11
12
 
13
+ // ---- 请求调度:串行队列 + 最小间隔闸门(雪球安全区:间隔 ≥2s 邻域,并发 1)----
14
+ const MIN_GAP_MS = 800
15
+ let queueTail = Promise.resolve()
16
+ let lastStart = 0
17
+
18
+ function delay(ms) { return new Promise(function (r) { ctx.timeout(r, ms) }) }
19
+
20
+ function gate(fn) {
21
+ const run = queueTail.then(async function () {
22
+ const wait = lastStart + MIN_GAP_MS - Date.now()
23
+ if (wait > 0) await delay(wait)
24
+ lastStart = Date.now()
25
+ return fn()
26
+ })
27
+ queueTail = run.then(function () { }, function () { })
28
+ return run
29
+ }
30
+
31
+ // ---- TTL 缓存 + in-flight 去重:同一 URL 在途请求共享同一个 Promise ----
32
+ const cache = new Map() // key -> { at, value }
33
+ const inflight = new Map() // key -> Promise
34
+
35
+ function cacheGet(key, ttl) {
36
+ const e = cache.get(key)
37
+ if (e && ttl > 0 && Date.now() - e.at < ttl) return e.value
38
+ return undefined
39
+ }
40
+
12
41
  function enc(s) {
13
42
  try { return encodeURIComponent(String(s)) } catch (e) { return String(s) }
14
43
  }
@@ -25,10 +54,13 @@ return {
25
54
 
26
55
  function getShell() { return ctx.get('shell') }
27
56
 
57
+ let cookieSeeding = null
28
58
  async function ensureCookie(force) {
29
59
  if (state.cookie && !force) return state.cookie
60
+ if (cookieSeeding) return cookieSeeding // 并发去重:同一时刻只播种一次
30
61
  const shell = getShell()
31
62
  if (!shell) return ''
63
+ cookieSeeding = (async function () {
32
64
  // 雪球主域 302 → www.xueqiu.com,必须 -L 跟随才能拿到 Set-Cookie
33
65
  const cmd = "curl -s -L --max-time 12 -D - -o /dev/null 'https://www.xueqiu.com/' -H 'User-Agent: " + UA + "'"
34
66
  const spec = shell.resolve({ command: cmd, timeoutMs: 15000, stdoutMaxBytes: 65536 })
@@ -41,10 +73,14 @@ return {
41
73
  const m = /^set-cookie:\s*([^=;\s]+)=([^;]*)/i.exec(lines[i].trim())
42
74
  if (m && !seen[m[1]]) { seen[m[1]] = true; pairs.push(m[1] + '=' + m[2]) }
43
75
  }
44
- // kline 端点要求 cookie 里存在 u=<id>(值任意);无登录态时补一个随机 u
76
+ // kline 端点要求 cookie 里存在 u=<id>;优先用种子响应里的真实 u,缺失才补随机值
45
77
  if (!seen.u) pairs.push('u=' + String(Date.now()) + String(Math.floor(Math.random() * 1e6)))
46
78
  state.cookie = pairs.join('; ')
47
79
  return state.cookie
80
+ })()
81
+ const r = cookieSeeding
82
+ r.then(function () { cookieSeeding = null }, function () { cookieSeeding = null })
83
+ return r
48
84
  }
49
85
 
50
86
  async function curl(url) {
@@ -65,26 +101,63 @@ return {
65
101
  return String(result.stdout.text || '')
66
102
  }
67
103
 
68
- async function getJSON(path, params, base, depth) {
69
- const url = (base || BASE) + path + toQuery(params || {})
70
- let text = await curl(url)
71
- if (!text && state.cookie) {
72
- await ensureCookie(true)
73
- text = await curl(url)
74
- }
75
- if (!text) throw new Error('雪球返回空响应(可能被风控,稍后再试)')
76
- let data
77
- try { data = JSON.parse(text) } catch (e) { throw new Error('雪球响应解析失败: ' + text.slice(0, 120)) }
78
- if (data && data.error_code && data.error_code !== 0) {
79
- const code = String(data.error_code)
80
- const desc = (data.error_description || ('雪球错误 ' + code)).slice(0, 120)
81
- if (code === '400016' && depth < 2) {
82
- await ensureCookie(true)
83
- return getJSON(path, params, base, (depth || 0) + 1)
104
+ // ---- 错误分类:cookie_expired / rate_limited / parse / network / empty ----
105
+ function classifyError(data) {
106
+ if (!data || !data.error_code || data.error_code === 0) return null
107
+ const code = String(data.error_code)
108
+ const desc = String(data.error_description || '')
109
+ if (code === '400016') return { kind: 'cookie_expired', code: code, desc: desc || 'Cookie 失效', retryable: true }
110
+ if (/频繁|频率|too many|429/i.test(desc) || code === '400017') return { kind: 'rate_limited', code: code, desc: desc || '请求过于频繁', retryable: true }
111
+ return { kind: 'api', code: code, desc: desc || ('雪球错误 ' + code), retryable: false }
112
+ }
113
+
114
+ async function getJSON(path, params, opts) {
115
+ opts = opts || {}
116
+ const url = (opts.base || BASE) + path + toQuery(params || {})
117
+ const ttl = opts.ttl || 0
118
+
119
+ async function attempt(depth) {
120
+ const err = await (async function () {
121
+ let text
122
+ try { text = await curl(url) } catch (e) { return { kind: 'network', message: e.message, retryable: depth < 1 } }
123
+ if (!text) return { kind: 'empty', message: '雪球返回空响应(可能被风控)', retryable: true }
124
+ let data
125
+ try { data = JSON.parse(text) } catch (e) { return { kind: 'parse', message: '响应解析失败: ' + text.slice(0, 120), retryable: false } }
126
+ const cls = classifyError(data)
127
+ if (cls) return { kind: cls.kind, message: cls.desc, code: cls.code, retryable: cls.retryable }
128
+ // kline 陷阱:缺 u cookie 时返回 200 + 空 items 且无 column(静默失败)
129
+ const d = data && data.data
130
+ if (path.indexOf('/chart/kline') !== -1 && d && typeof d === 'object'
131
+ && !Array.isArray(d.column) && String(d.items_size || '0') === '0') {
132
+ return { kind: 'empty_kline', message: 'kline 空数据(cookie 种子可能不完整)', retryable: true }
133
+ }
134
+ return { data: data }
135
+ })()
136
+
137
+ if (!err.kind) return err.data
138
+ if (!err.retryable || depth >= 2) throw new Error('[' + err.kind + '] ' + err.message)
139
+
140
+ if (err.kind === 'cookie_expired' || err.kind === 'empty' || err.kind === 'empty_kline') {
141
+ await ensureCookie(true) // 重新播种 cookie 后再试
142
+ } else if (err.kind === 'rate_limited') {
143
+ await delay(2000 * (depth + 1)) // 指数退避:2s → 4s
144
+ } else if (err.kind === 'network') {
145
+ await delay(500)
84
146
  }
85
- throw new Error(desc)
147
+ return attempt(depth + 1)
86
148
  }
87
- return data
149
+
150
+ const hit = cacheGet(url, ttl)
151
+ if (hit !== undefined) return Promise.resolve(hit)
152
+ const pending = inflight.get(url)
153
+ if (pending) return pending
154
+ const p = gate(function () { return attempt(0) }).then(function (data) {
155
+ if (ttl > 0) cache.set(url, { at: Date.now(), value: data })
156
+ return data
157
+ })
158
+ p.then(function () { inflight.delete(url) }, function () { inflight.delete(url) })
159
+ inflight.set(url, p)
160
+ return p
88
161
  }
89
162
 
90
163
  // ---- data normalizers ----
@@ -184,7 +257,7 @@ return {
184
257
  const symbols = Array.isArray(args.symbols) ? args.symbols : [String(args.symbols || '')]
185
258
  const list = symbols.filter(function (s) { return /^[A-Za-z0-9_.]+$/.test(s) })
186
259
  if (!list.length) return { list: [], status: null }
187
- const data = await getJSON('/v5/stock/batch/quote.json', { symbol: list.join(',') })
260
+ const data = await getJSON('/v5/stock/batch/quote.json', { symbol: list.join(',') }, { ttl: 10000 })
188
261
  const items = (data && data.data && data.data.items) || []
189
262
  const first = items[0]
190
263
  const status = first && first.market ? first.market.status_id : null
@@ -196,7 +269,7 @@ return {
196
269
 
197
270
  async function actQuoteDetail(args) {
198
271
  const symbol = String(args.symbol || '')
199
- const data = await getJSON('/v5/stock/quote.json', { symbol: symbol, extend: 'detail' })
272
+ const data = await getJSON('/v5/stock/quote.json', { symbol: symbol, extend: 'detail' }, { ttl: 15000 })
200
273
  return { quote: pickQuote(data.data && data.data.quote), market: data.data && data.data.market }
201
274
  }
202
275
 
@@ -204,16 +277,18 @@ return {
204
277
  const period = String(args.period || 'day')
205
278
  const count = Math.min(Math.max(parseInt(args.count, 10) || 120, 5), 500)
206
279
  const symbol = String(args.symbol || '')
280
+ // begin 按分钟取整,保证 TTL 窗口内缓存 key 稳定(count 为负,返回的是最近 N 根)
281
+ const begin = Math.floor(Date.now() / 60000) * 60000
207
282
  const data = await getJSON('/v5/stock/chart/kline.json', {
208
- symbol: symbol, period: period, type: 'before', begin: Date.now(),
283
+ symbol: symbol, period: period, type: 'before', begin: begin,
209
284
  count: -count, indicator: 'kline,pe,pb,ps,pcf,market_capital'
210
- })
285
+ }, { ttl: period === 'day' ? 300000 : 30000 })
211
286
  return mapKline(data)
212
287
  }
213
288
 
214
289
  async function actMinute(args) {
215
290
  const symbol = String(args.symbol || '')
216
- const data = await getJSON('/v5/stock/chart/minute.json', { symbol: symbol, period: '1d' })
291
+ const data = await getJSON('/v5/stock/chart/minute.json', { symbol: symbol, period: '1d' }, { ttl: 10000 })
217
292
  const d = data && data.data ? data.data : null
218
293
  if (!d || !d.items) return { last_close: null, items: [] }
219
294
  return {
@@ -228,7 +303,7 @@ return {
228
303
  const market = String(args.market || 'cn')
229
304
  const typeMap = { global: 10, us: 11, cn: 12, hk: 13 }
230
305
  const size = Math.min(Math.max(parseInt(args.size, 10) || 10, 1), 30)
231
- const data = await getJSON('/v5/stock/hot_stock/list.json', { type: typeMap[market] || 12, size: size })
306
+ const data = await getJSON('/v5/stock/hot_stock/list.json', { type: typeMap[market] || 12, size: size }, { ttl: 300000 })
232
307
  const items = (data && data.data && data.data.items) || []
233
308
  return {
234
309
  list: items.map(function (it) {
@@ -240,7 +315,7 @@ return {
240
315
  async function actSearch(args) {
241
316
  const q = String(args.q || '')
242
317
  if (!q) return { list: [] }
243
- const data = await getJSON('/query/v1/suggest_stock.json', { q: q, count: Math.min(parseInt(args.count, 10) || 8, 20) }, SITE)
318
+ const data = await getJSON('/query/v1/suggest_stock.json', { q: q, count: Math.min(parseInt(args.count, 10) || 8, 20) }, { base: SITE, ttl: 60000 })
244
319
  const list = (data && data.data) || []
245
320
  return {
246
321
  list: list.map(function (it) {
@@ -252,13 +327,13 @@ return {
252
327
  async function actSearchPosts(args) {
253
328
  const q = String(args.q || '')
254
329
  if (!q) return { list: [] }
255
- const data = await getJSON('/query/v1/search/status.json', { q: q, count: Math.min(parseInt(args.count, 10) || 10, 30) }, SITE)
330
+ const data = await getJSON('/query/v1/search/status.json', { q: q, count: Math.min(parseInt(args.count, 10) || 10, 30) }, { base: SITE, ttl: 120000 })
256
331
  return { list: mapPosts(data && data.list) }
257
332
  }
258
333
 
259
334
  async function actNews(args) {
260
335
  const count = Math.min(Math.max(parseInt(args.count, 10) || 20, 1), 50)
261
- const data = await getJSON('/statuses/livenews/list.json', { since_id: -1, max_id: -1, count: count }, SITE)
336
+ const data = await getJSON('/statuses/livenews/list.json', { since_id: -1, max_id: -1, count: count }, { base: SITE, ttl: 120000 })
262
337
  const items = (data && data.items) || []
263
338
  return {
264
339
  items: items.map(function (it) {
@@ -270,14 +345,14 @@ return {
270
345
  async function actKOL(args) {
271
346
  const symbol = String(args.symbol || '')
272
347
  const count = Math.min(Math.max(parseInt(args.count, 10) || 8, 1), 20)
273
- const data = await getJSON('/recommend/user/stock_hot_user.json', { symbol: symbol, start: 0, count: count }, SITE)
348
+ const data = await getJSON('/recommend/user/stock_hot_user.json', { symbol: symbol, start: 0, count: count }, { base: SITE, ttl: 3600000 })
274
349
  return { list: mapKOL(data) }
275
350
  }
276
351
 
277
352
  async function actUser(args) {
278
353
  const userId = String(args.userId || args.id || '')
279
354
  if (!userId) return { user: null, posts: [] }
280
- const data = await getJSON('/statuses/user_timeline.json', { user_id: userId, page: 1, count: Math.min(parseInt(args.count, 10) || 10, 30) }, SITE)
355
+ const data = await getJSON('/statuses/user_timeline.json', { user_id: userId, page: 1, count: Math.min(parseInt(args.count, 10) || 10, 30) }, { base: SITE, ttl: 300000 })
281
356
  const statuses = (data && data.statuses) || []
282
357
  const first = statuses[0]
283
358
  return {
@@ -292,7 +367,7 @@ return {
292
367
 
293
368
  async function actFinance(args) {
294
369
  const symbol = String(args.symbol || '')
295
- const data = await getJSON('/v5/stock/finance/cn/indicator.json', { symbol: symbol, type: 'all', is_detail: 'true', count: 4 })
370
+ const data = await getJSON('/v5/stock/finance/cn/indicator.json', { symbol: symbol, type: 'all', is_detail: 'true', count: 4 }, { ttl: 86400000 })
296
371
  const d = data && data.data ? data.data : null
297
372
  return {
298
373
  quote_name: d ? d.quote_name : null, last_report_name: d ? d.last_report_name : null,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-xueqiu",
3
- "version": "1.3.0",
4
- "description": "雪球 mini 行情面板 — DeepSeek Harness 免登录 A股/港美股实时行情、K线(蜡烛/成交量/均线)、分时图、热榜、搜索、7x24 快讯、热议用户。可拖拽悬浮面板,交易时段智能刷新。",
3
+ "version": "1.4.0",
4
+ "description": "雪球 mini 行情面板 — DeepSeek Harness 免登录 A股/港美股实时行情、K线(蜡烛/成交量/均线)、分时图、热榜、搜索、7x24 快讯、热议用户。嵌入式面板 + 可拖拽行情徽标,内置请求节流、TTL 缓存与 Cookie 自动续期。",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "dsh-plugin",
@@ -1,6 +1,5 @@
1
- export default {
2
-
3
- inject: ['timer', 'connection'],
1
+ return {
2
+ inject: ['timer'],
4
3
  apply(ctx) {
5
4
  styles.insert('\n' +
6
5
  '.xq-dock{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;overflow:hidden;font-size:13px;line-height:1.45;color:var(--dsw-alias-label-primary);margin-bottom:6px;}\n' +
@@ -103,9 +102,9 @@ export default {
103
102
  function el(type, props, children) { return React.createElement(type, props, children) }
104
103
 
105
104
  async function call(action, args) {
106
- const res = await ctx.connection.rpc.call('/xueqiu', 'call', { action: action, args: args || {} })
107
- if (!res || !res.ok) throw new Error((res && (res.error && (res.error.message || res.error.code) || res.error)) || '调用失败')
108
- return res.value
105
+ const r = await host.call('xq.call', { action: action, args: args || {} })
106
+ if (!r || !r.ok) throw new Error((r && r.error) || '调用失败')
107
+ return r.data
109
108
  }
110
109
 
111
110
  function fmt(v, d) {
@@ -1005,4 +1004,4 @@ export default {
1005
1004
  })
1006
1005
 
1007
1006
  }
1008
- }
1007
+ }
package/src/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export default {
2
-
2
+ inject: ['timer'],
3
3
  apply(ctx) {
4
4
  const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
5
5
  const BASE = 'https://stock.xueqiu.com'
@@ -10,6 +10,34 @@ export default {
10
10
  let watchlist = null
11
11
  let uiState = null
12
12
 
13
+ // ---- 请求调度:串行队列 + 最小间隔闸门(雪球安全区:间隔 ≥2s 邻域,并发 1)----
14
+ const MIN_GAP_MS = 800
15
+ let queueTail = Promise.resolve()
16
+ let lastStart = 0
17
+
18
+ function delay(ms) { return new Promise(function (r) { ctx.timeout(r, ms) }) }
19
+
20
+ function gate(fn) {
21
+ const run = queueTail.then(async function () {
22
+ const wait = lastStart + MIN_GAP_MS - Date.now()
23
+ if (wait > 0) await delay(wait)
24
+ lastStart = Date.now()
25
+ return fn()
26
+ })
27
+ queueTail = run.then(function () { }, function () { })
28
+ return run
29
+ }
30
+
31
+ // ---- TTL 缓存 + in-flight 去重:同一 URL 在途请求共享同一个 Promise ----
32
+ const cache = new Map() // key -> { at, value }
33
+ const inflight = new Map() // key -> Promise
34
+
35
+ function cacheGet(key, ttl) {
36
+ const e = cache.get(key)
37
+ if (e && ttl > 0 && Date.now() - e.at < ttl) return e.value
38
+ return undefined
39
+ }
40
+
13
41
  function enc(s) {
14
42
  try { return encodeURIComponent(String(s)) } catch (e) { return String(s) }
15
43
  }
@@ -26,10 +54,13 @@ export default {
26
54
 
27
55
  function getShell() { return ctx.get('shell') }
28
56
 
57
+ let cookieSeeding = null
29
58
  async function ensureCookie(force) {
30
59
  if (state.cookie && !force) return state.cookie
60
+ if (cookieSeeding) return cookieSeeding // 并发去重:同一时刻只播种一次
31
61
  const shell = getShell()
32
62
  if (!shell) return ''
63
+ cookieSeeding = (async function () {
33
64
  // 雪球主域 302 → www.xueqiu.com,必须 -L 跟随才能拿到 Set-Cookie
34
65
  const cmd = "curl -s -L --max-time 12 -D - -o /dev/null 'https://www.xueqiu.com/' -H 'User-Agent: " + UA + "'"
35
66
  const spec = shell.resolve({ command: cmd, timeoutMs: 15000, stdoutMaxBytes: 65536 })
@@ -42,10 +73,14 @@ export default {
42
73
  const m = /^set-cookie:\s*([^=;\s]+)=([^;]*)/i.exec(lines[i].trim())
43
74
  if (m && !seen[m[1]]) { seen[m[1]] = true; pairs.push(m[1] + '=' + m[2]) }
44
75
  }
45
- // kline 端点要求 cookie 里存在 u=<id>(值任意);无登录态时补一个随机 u
76
+ // kline 端点要求 cookie 里存在 u=<id>;优先用种子响应里的真实 u,缺失才补随机值
46
77
  if (!seen.u) pairs.push('u=' + String(Date.now()) + String(Math.floor(Math.random() * 1e6)))
47
78
  state.cookie = pairs.join('; ')
48
79
  return state.cookie
80
+ })()
81
+ const r = cookieSeeding
82
+ r.then(function () { cookieSeeding = null }, function () { cookieSeeding = null })
83
+ return r
49
84
  }
50
85
 
51
86
  async function curl(url) {
@@ -66,26 +101,63 @@ export default {
66
101
  return String(result.stdout.text || '')
67
102
  }
68
103
 
69
- async function getJSON(path, params, base, depth) {
70
- const url = (base || BASE) + path + toQuery(params || {})
71
- let text = await curl(url)
72
- if (!text && state.cookie) {
73
- await ensureCookie(true)
74
- text = await curl(url)
75
- }
76
- if (!text) throw new Error('雪球返回空响应(可能被风控,稍后再试)')
77
- let data
78
- try { data = JSON.parse(text) } catch (e) { throw new Error('雪球响应解析失败: ' + text.slice(0, 120)) }
79
- if (data && data.error_code && data.error_code !== 0) {
80
- const code = String(data.error_code)
81
- const desc = (data.error_description || ('雪球错误 ' + code)).slice(0, 120)
82
- if (code === '400016' && depth < 2) {
83
- await ensureCookie(true)
84
- return getJSON(path, params, base, (depth || 0) + 1)
104
+ // ---- 错误分类:cookie_expired / rate_limited / parse / network / empty ----
105
+ function classifyError(data) {
106
+ if (!data || !data.error_code || data.error_code === 0) return null
107
+ const code = String(data.error_code)
108
+ const desc = String(data.error_description || '')
109
+ if (code === '400016') return { kind: 'cookie_expired', code: code, desc: desc || 'Cookie 失效', retryable: true }
110
+ if (/频繁|频率|too many|429/i.test(desc) || code === '400017') return { kind: 'rate_limited', code: code, desc: desc || '请求过于频繁', retryable: true }
111
+ return { kind: 'api', code: code, desc: desc || ('雪球错误 ' + code), retryable: false }
112
+ }
113
+
114
+ async function getJSON(path, params, opts) {
115
+ opts = opts || {}
116
+ const url = (opts.base || BASE) + path + toQuery(params || {})
117
+ const ttl = opts.ttl || 0
118
+
119
+ async function attempt(depth) {
120
+ const err = await (async function () {
121
+ let text
122
+ try { text = await curl(url) } catch (e) { return { kind: 'network', message: e.message, retryable: depth < 1 } }
123
+ if (!text) return { kind: 'empty', message: '雪球返回空响应(可能被风控)', retryable: true }
124
+ let data
125
+ try { data = JSON.parse(text) } catch (e) { return { kind: 'parse', message: '响应解析失败: ' + text.slice(0, 120), retryable: false } }
126
+ const cls = classifyError(data)
127
+ if (cls) return { kind: cls.kind, message: cls.desc, code: cls.code, retryable: cls.retryable }
128
+ // kline 陷阱:缺 u cookie 时返回 200 + 空 items 且无 column(静默失败)
129
+ const d = data && data.data
130
+ if (path.indexOf('/chart/kline') !== -1 && d && typeof d === 'object'
131
+ && !Array.isArray(d.column) && String(d.items_size || '0') === '0') {
132
+ return { kind: 'empty_kline', message: 'kline 空数据(cookie 种子可能不完整)', retryable: true }
133
+ }
134
+ return { data: data }
135
+ })()
136
+
137
+ if (!err.kind) return err.data
138
+ if (!err.retryable || depth >= 2) throw new Error('[' + err.kind + '] ' + err.message)
139
+
140
+ if (err.kind === 'cookie_expired' || err.kind === 'empty' || err.kind === 'empty_kline') {
141
+ await ensureCookie(true) // 重新播种 cookie 后再试
142
+ } else if (err.kind === 'rate_limited') {
143
+ await delay(2000 * (depth + 1)) // 指数退避:2s → 4s
144
+ } else if (err.kind === 'network') {
145
+ await delay(500)
85
146
  }
86
- throw new Error(desc)
147
+ return attempt(depth + 1)
87
148
  }
88
- return data
149
+
150
+ const hit = cacheGet(url, ttl)
151
+ if (hit !== undefined) return Promise.resolve(hit)
152
+ const pending = inflight.get(url)
153
+ if (pending) return pending
154
+ const p = gate(function () { return attempt(0) }).then(function (data) {
155
+ if (ttl > 0) cache.set(url, { at: Date.now(), value: data })
156
+ return data
157
+ })
158
+ p.then(function () { inflight.delete(url) }, function () { inflight.delete(url) })
159
+ inflight.set(url, p)
160
+ return p
89
161
  }
90
162
 
91
163
  // ---- data normalizers ----
@@ -185,7 +257,7 @@ export default {
185
257
  const symbols = Array.isArray(args.symbols) ? args.symbols : [String(args.symbols || '')]
186
258
  const list = symbols.filter(function (s) { return /^[A-Za-z0-9_.]+$/.test(s) })
187
259
  if (!list.length) return { list: [], status: null }
188
- const data = await getJSON('/v5/stock/batch/quote.json', { symbol: list.join(',') })
260
+ const data = await getJSON('/v5/stock/batch/quote.json', { symbol: list.join(',') }, { ttl: 10000 })
189
261
  const items = (data && data.data && data.data.items) || []
190
262
  const first = items[0]
191
263
  const status = first && first.market ? first.market.status_id : null
@@ -197,7 +269,7 @@ export default {
197
269
 
198
270
  async function actQuoteDetail(args) {
199
271
  const symbol = String(args.symbol || '')
200
- const data = await getJSON('/v5/stock/quote.json', { symbol: symbol, extend: 'detail' })
272
+ const data = await getJSON('/v5/stock/quote.json', { symbol: symbol, extend: 'detail' }, { ttl: 15000 })
201
273
  return { quote: pickQuote(data.data && data.data.quote), market: data.data && data.data.market }
202
274
  }
203
275
 
@@ -205,16 +277,18 @@ export default {
205
277
  const period = String(args.period || 'day')
206
278
  const count = Math.min(Math.max(parseInt(args.count, 10) || 120, 5), 500)
207
279
  const symbol = String(args.symbol || '')
280
+ // begin 按分钟取整,保证 TTL 窗口内缓存 key 稳定(count 为负,返回的是最近 N 根)
281
+ const begin = Math.floor(Date.now() / 60000) * 60000
208
282
  const data = await getJSON('/v5/stock/chart/kline.json', {
209
- symbol: symbol, period: period, type: 'before', begin: Date.now(),
283
+ symbol: symbol, period: period, type: 'before', begin: begin,
210
284
  count: -count, indicator: 'kline,pe,pb,ps,pcf,market_capital'
211
- })
285
+ }, { ttl: period === 'day' ? 300000 : 30000 })
212
286
  return mapKline(data)
213
287
  }
214
288
 
215
289
  async function actMinute(args) {
216
290
  const symbol = String(args.symbol || '')
217
- const data = await getJSON('/v5/stock/chart/minute.json', { symbol: symbol, period: '1d' })
291
+ const data = await getJSON('/v5/stock/chart/minute.json', { symbol: symbol, period: '1d' }, { ttl: 10000 })
218
292
  const d = data && data.data ? data.data : null
219
293
  if (!d || !d.items) return { last_close: null, items: [] }
220
294
  return {
@@ -229,7 +303,7 @@ export default {
229
303
  const market = String(args.market || 'cn')
230
304
  const typeMap = { global: 10, us: 11, cn: 12, hk: 13 }
231
305
  const size = Math.min(Math.max(parseInt(args.size, 10) || 10, 1), 30)
232
- const data = await getJSON('/v5/stock/hot_stock/list.json', { type: typeMap[market] || 12, size: size })
306
+ const data = await getJSON('/v5/stock/hot_stock/list.json', { type: typeMap[market] || 12, size: size }, { ttl: 300000 })
233
307
  const items = (data && data.data && data.data.items) || []
234
308
  return {
235
309
  list: items.map(function (it) {
@@ -241,7 +315,7 @@ export default {
241
315
  async function actSearch(args) {
242
316
  const q = String(args.q || '')
243
317
  if (!q) return { list: [] }
244
- const data = await getJSON('/query/v1/suggest_stock.json', { q: q, count: Math.min(parseInt(args.count, 10) || 8, 20) }, SITE)
318
+ const data = await getJSON('/query/v1/suggest_stock.json', { q: q, count: Math.min(parseInt(args.count, 10) || 8, 20) }, { base: SITE, ttl: 60000 })
245
319
  const list = (data && data.data) || []
246
320
  return {
247
321
  list: list.map(function (it) {
@@ -253,13 +327,13 @@ export default {
253
327
  async function actSearchPosts(args) {
254
328
  const q = String(args.q || '')
255
329
  if (!q) return { list: [] }
256
- const data = await getJSON('/query/v1/search/status.json', { q: q, count: Math.min(parseInt(args.count, 10) || 10, 30) }, SITE)
330
+ const data = await getJSON('/query/v1/search/status.json', { q: q, count: Math.min(parseInt(args.count, 10) || 10, 30) }, { base: SITE, ttl: 120000 })
257
331
  return { list: mapPosts(data && data.list) }
258
332
  }
259
333
 
260
334
  async function actNews(args) {
261
335
  const count = Math.min(Math.max(parseInt(args.count, 10) || 20, 1), 50)
262
- const data = await getJSON('/statuses/livenews/list.json', { since_id: -1, max_id: -1, count: count }, SITE)
336
+ const data = await getJSON('/statuses/livenews/list.json', { since_id: -1, max_id: -1, count: count }, { base: SITE, ttl: 120000 })
263
337
  const items = (data && data.items) || []
264
338
  return {
265
339
  items: items.map(function (it) {
@@ -271,14 +345,14 @@ export default {
271
345
  async function actKOL(args) {
272
346
  const symbol = String(args.symbol || '')
273
347
  const count = Math.min(Math.max(parseInt(args.count, 10) || 8, 1), 20)
274
- const data = await getJSON('/recommend/user/stock_hot_user.json', { symbol: symbol, start: 0, count: count }, SITE)
348
+ const data = await getJSON('/recommend/user/stock_hot_user.json', { symbol: symbol, start: 0, count: count }, { base: SITE, ttl: 3600000 })
275
349
  return { list: mapKOL(data) }
276
350
  }
277
351
 
278
352
  async function actUser(args) {
279
353
  const userId = String(args.userId || args.id || '')
280
354
  if (!userId) return { user: null, posts: [] }
281
- const data = await getJSON('/statuses/user_timeline.json', { user_id: userId, page: 1, count: Math.min(parseInt(args.count, 10) || 10, 30) }, SITE)
355
+ const data = await getJSON('/statuses/user_timeline.json', { user_id: userId, page: 1, count: Math.min(parseInt(args.count, 10) || 10, 30) }, { base: SITE, ttl: 300000 })
282
356
  const statuses = (data && data.statuses) || []
283
357
  const first = statuses[0]
284
358
  return {
@@ -293,7 +367,7 @@ export default {
293
367
 
294
368
  async function actFinance(args) {
295
369
  const symbol = String(args.symbol || '')
296
- const data = await getJSON('/v5/stock/finance/cn/indicator.json', { symbol: symbol, type: 'all', is_detail: 'true', count: 4 })
370
+ const data = await getJSON('/v5/stock/finance/cn/indicator.json', { symbol: symbol, type: 'all', is_detail: 'true', count: 4 }, { ttl: 86400000 })
297
371
  const d = data && data.data ? data.data : null
298
372
  return {
299
373
  quote_name: d ? d.quote_name : null, last_report_name: d ? d.last_report_name : null,