dsh-xueqiu 1.3.0 → 1.5.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/client.js CHANGED
@@ -5,7 +5,9 @@ return {
5
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' +
6
6
  '.xq-dock *{box-sizing:border-box;}\n' +
7
7
  '.xq-dock-head{display:flex;align-items:center;gap:8px;padding:7px 12px;border-bottom:1px solid var(--dsw-alias-border-l1);}\n' +
8
- '.xq-dock-body{max-height:56vh;overflow-y:auto;padding:8px 12px 10px;}\n' +
8
+ '.xq-dock-body{height:52vh;max-height:80vh;overflow-y:auto;padding:8px 12px 10px;}\n' +
9
+ '.xq-dock-resize{display:flex;align-items:center;justify-content:center;height:12px;cursor:ns-resize;user-select:none;border-top:1px solid var(--dsw-alias-border-l1);}\n' +
10
+ '.xq-dock-resize span{display:block;width:36px;height:3px;border-radius:2px;background:var(--dsw-alias-border-l2);}\n' +
9
11
  '.xq-badge{position:fixed;right:16px;bottom:64px;display:flex;align-items:center;gap:8px;padding:5px 12px;border-radius:999px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);box-shadow:0 6px 24px rgba(0,0,0,.25);font-size:11.5px;line-height:1.4;color:var(--dsw-alias-label-primary);cursor:grab;user-select:none;pointer-events:auto;z-index:1200;}\n' +
10
12
  '.xq-badge:active{cursor:grabbing;}\n' +
11
13
  '.xq-badge b{color:var(--dsw-alias-brand-primary);}\n' +
@@ -171,7 +173,7 @@ return {
171
173
 
172
174
  // ---------- 共享 UI 状态:面板开合 / 当前标签 / 徽章位置 ----------
173
175
  const ui = {
174
- open: false, tab: 'market', badgePos: null, hydrated: false,
176
+ open: false, tab: 'market', badgePos: null, dockH: null, hydrated: false,
175
177
  _fns: [],
176
178
  subscribe: function (fn) {
177
179
  this._fns.push(fn)
@@ -195,13 +197,16 @@ return {
195
197
  }
196
198
 
197
199
  const saveUi = ctx.debounce(function () {
198
- call('ui.set', { tab: ui.tab, open: ui.open, badgePos: ui.badgePos }).catch(function () { /* 忽略 */ })
200
+ call('ui.set', { tab: ui.tab, open: ui.open, badgePos: ui.badgePos, dockH: ui.dockH }).catch(function () { /* 忽略 */ })
199
201
  }, 800)
200
202
 
201
203
  // 徽章拖拽引用(实例唯一)
202
204
  let badgeDrag = null
203
205
  let badgeMoved = false
204
206
 
207
+ // 面板高度拖拽引用(实例唯一,避免组件重渲染丢失状态)
208
+ const dockResize = { active: false, startY: 0, startH: 0 }
209
+
205
210
  // ---------- K线蜡烛图(成交量 + 均线 + 十字光标/悬浮详情) ----------
206
211
  function KlineChart(props) {
207
212
  const rows = props.rows || []
@@ -856,6 +861,9 @@ return {
856
861
  if (d && d.badgePos && isFinite(Number(d.badgePos.x)) && isFinite(Number(d.badgePos.y))) {
857
862
  ui.set({ badgePos: { x: Number(d.badgePos.x), y: Number(d.badgePos.y) } })
858
863
  }
864
+ if (d && typeof d.dockH === 'number' && isFinite(d.dockH)) {
865
+ ui.set({ dockH: d.dockH })
866
+ }
859
867
  ui.set({ hydrated: true })
860
868
  }).catch(function () { ui.set({ hydrated: true }) })
861
869
  const off = ui.subscribe(function () { force(function (x) { return x + 1 }) })
@@ -866,10 +874,35 @@ return {
866
874
  }
867
875
 
868
876
  function DockPanel() {
877
+ const [, force] = React.useState(0)
869
878
  React.useEffect(function () {
870
- const off = ui.subscribe(function () { if (ui.hydrated) saveUi() })
879
+ const off = ui.subscribe(function () { if (ui.hydrated) saveUi(); force(function (x) { return x + 1 }) })
871
880
  return off
872
881
  }, [])
882
+ function onDown(e) {
883
+ if (e.pointerType === 'mouse' && e.button !== 0) return
884
+ dockResize.active = true
885
+ dockResize.startY = e.clientY
886
+ dockResize.startH = ui.dockH || defaultDockH()
887
+ try { e.currentTarget.setPointerCapture(e.pointerId) } catch (err) { /* ignore */ }
888
+ }
889
+ function onMove(e) {
890
+ if (!dockResize.active) return
891
+ const vp = viewport()
892
+ const maxH = Math.round((vp ? vp.h : 800) * 0.85)
893
+ let h = dockResize.startH + (e.clientY - dockResize.startY)
894
+ h = Math.min(Math.max(160, h), Math.min(maxH, 1200))
895
+ ui.set({ dockH: h })
896
+ }
897
+ function onUp(e) {
898
+ dockResize.active = false
899
+ try { if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId) } catch (err) { /* ignore */ }
900
+ }
901
+ function defaultDockH() {
902
+ try { if (typeof window !== 'undefined' && window.innerHeight) return Math.round(window.innerHeight * 0.52) } catch (e) { /* ignore */ }
903
+ return 420
904
+ }
905
+ const bodyStyle = ui.dockH ? { height: ui.dockH + 'px' } : null
873
906
  return el('div', { className: 'xq-dock' }, [
874
907
  el('div', { key: 'head', className: 'xq-dock-head' }, [
875
908
  el('span', { key: 'logo', className: 'xq-logo' }, [el('b', { key: 'b' }, '雪球'), ' mini']),
@@ -878,9 +911,14 @@ return {
878
911
  el('span', { key: 'sp', className: 'xq-spacer' }),
879
912
  el('button', { key: 'min', className: 'xq-btn-mini', title: '收起(点右下角徽章重新打开)', onClick: function () { ui.set({ open: false }) } }, '收起 —')
880
913
  ]),
881
- el('div', { key: 'body', className: 'xq-dock-body' }, [
914
+ el('div', { key: 'body', className: 'xq-dock-body', style: bodyStyle }, [
882
915
  el(XueqiuPanel, { key: 'panel' })
883
- ])
916
+ ]),
917
+ el('div', {
918
+ key: 'rs', className: 'xq-dock-resize', title: '拖动调整面板高度(双击复位)',
919
+ onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerCancel: onUp,
920
+ onDoubleClick: function () { ui.set({ dockH: null }) }
921
+ }, [el('span', { key: 'g' }, null)])
884
922
  ])
885
923
  }
886
924
 
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,
@@ -389,9 +464,11 @@ return {
389
464
 
390
465
  async function actUiGet() {
391
466
  const s = await loadUiState()
467
+ const hNum = Number(s.dockH)
392
468
  return {
393
469
  tab: s.tab || 'market', open: s.open !== false,
394
- badgePos: (s.badgePos && isFinite(Number(s.badgePos.x)) && isFinite(Number(s.badgePos.y)))
470
+ dockH: (s.dockH !== undefined && isFinite(hNum) && hNum >= 160 && hNum <= 1200) ? hNum : null,
471
+ badgePos: (s.badgePos && isFinite(Number(s.badgePos.x)) && isFinite(Number(s.badgePos.y())))
395
472
  ? { x: Number(s.badgePos.x), y: Number(s.badgePos.y) } : null
396
473
  }
397
474
  }
@@ -403,6 +480,10 @@ return {
403
480
  if (args.badgePos && typeof args.badgePos === 'object' && isFinite(Number(args.badgePos.x)) && isFinite(Number(args.badgePos.y))) {
404
481
  s.badgePos = { x: Number(args.badgePos.x), y: Number(args.badgePos.y) }
405
482
  }
483
+ if (args.dockH !== undefined) {
484
+ const dh = Number(args.dockH)
485
+ if (isFinite(dh)) s.dockH = Math.min(Math.max(dh, 160), 1200)
486
+ }
406
487
  await saveUiState()
407
488
  return { ok: true }
408
489
  }
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.5.0",
4
+ "description": "雪球 mini 行情面板 — DeepSeek Harness 免登录 A股/港美股实时行情、K线(蜡烛/成交量/均线)、分时图、热榜、搜索、7x24 快讯、热议用户。嵌入式面板 + 可拖拽行情徽标,内置请求节流、TTL 缓存与 Cookie 自动续期。",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "dsh-plugin",
@@ -1,12 +1,13 @@
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' +
7
6
  '.xq-dock *{box-sizing:border-box;}\n' +
8
7
  '.xq-dock-head{display:flex;align-items:center;gap:8px;padding:7px 12px;border-bottom:1px solid var(--dsw-alias-border-l1);}\n' +
9
- '.xq-dock-body{max-height:56vh;overflow-y:auto;padding:8px 12px 10px;}\n' +
8
+ '.xq-dock-body{height:52vh;max-height:80vh;overflow-y:auto;padding:8px 12px 10px;}\n' +
9
+ '.xq-dock-resize{display:flex;align-items:center;justify-content:center;height:12px;cursor:ns-resize;user-select:none;border-top:1px solid var(--dsw-alias-border-l1);}\n' +
10
+ '.xq-dock-resize span{display:block;width:36px;height:3px;border-radius:2px;background:var(--dsw-alias-border-l2);}\n' +
10
11
  '.xq-badge{position:fixed;right:16px;bottom:64px;display:flex;align-items:center;gap:8px;padding:5px 12px;border-radius:999px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);box-shadow:0 6px 24px rgba(0,0,0,.25);font-size:11.5px;line-height:1.4;color:var(--dsw-alias-label-primary);cursor:grab;user-select:none;pointer-events:auto;z-index:1200;}\n' +
11
12
  '.xq-badge:active{cursor:grabbing;}\n' +
12
13
  '.xq-badge b{color:var(--dsw-alias-brand-primary);}\n' +
@@ -103,9 +104,9 @@ export default {
103
104
  function el(type, props, children) { return React.createElement(type, props, children) }
104
105
 
105
106
  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
107
+ const r = await host.call('xq.call', { action: action, args: args || {} })
108
+ if (!r || !r.ok) throw new Error((r && r.error) || '调用失败')
109
+ return r.data
109
110
  }
110
111
 
111
112
  function fmt(v, d) {
@@ -172,7 +173,7 @@ export default {
172
173
 
173
174
  // ---------- 共享 UI 状态:面板开合 / 当前标签 / 徽章位置 ----------
174
175
  const ui = {
175
- open: false, tab: 'market', badgePos: null, hydrated: false,
176
+ open: false, tab: 'market', badgePos: null, dockH: null, hydrated: false,
176
177
  _fns: [],
177
178
  subscribe: function (fn) {
178
179
  this._fns.push(fn)
@@ -196,13 +197,16 @@ export default {
196
197
  }
197
198
 
198
199
  const saveUi = ctx.debounce(function () {
199
- call('ui.set', { tab: ui.tab, open: ui.open, badgePos: ui.badgePos }).catch(function () { /* 忽略 */ })
200
+ call('ui.set', { tab: ui.tab, open: ui.open, badgePos: ui.badgePos, dockH: ui.dockH }).catch(function () { /* 忽略 */ })
200
201
  }, 800)
201
202
 
202
203
  // 徽章拖拽引用(实例唯一)
203
204
  let badgeDrag = null
204
205
  let badgeMoved = false
205
206
 
207
+ // 面板高度拖拽引用(实例唯一,避免组件重渲染丢失状态)
208
+ const dockResize = { active: false, startY: 0, startH: 0 }
209
+
206
210
  // ---------- K线蜡烛图(成交量 + 均线 + 十字光标/悬浮详情) ----------
207
211
  function KlineChart(props) {
208
212
  const rows = props.rows || []
@@ -857,6 +861,9 @@ export default {
857
861
  if (d && d.badgePos && isFinite(Number(d.badgePos.x)) && isFinite(Number(d.badgePos.y))) {
858
862
  ui.set({ badgePos: { x: Number(d.badgePos.x), y: Number(d.badgePos.y) } })
859
863
  }
864
+ if (d && typeof d.dockH === 'number' && isFinite(d.dockH)) {
865
+ ui.set({ dockH: d.dockH })
866
+ }
860
867
  ui.set({ hydrated: true })
861
868
  }).catch(function () { ui.set({ hydrated: true }) })
862
869
  const off = ui.subscribe(function () { force(function (x) { return x + 1 }) })
@@ -867,10 +874,35 @@ export default {
867
874
  }
868
875
 
869
876
  function DockPanel() {
877
+ const [, force] = React.useState(0)
870
878
  React.useEffect(function () {
871
- const off = ui.subscribe(function () { if (ui.hydrated) saveUi() })
879
+ const off = ui.subscribe(function () { if (ui.hydrated) saveUi(); force(function (x) { return x + 1 }) })
872
880
  return off
873
881
  }, [])
882
+ function onDown(e) {
883
+ if (e.pointerType === 'mouse' && e.button !== 0) return
884
+ dockResize.active = true
885
+ dockResize.startY = e.clientY
886
+ dockResize.startH = ui.dockH || defaultDockH()
887
+ try { e.currentTarget.setPointerCapture(e.pointerId) } catch (err) { /* ignore */ }
888
+ }
889
+ function onMove(e) {
890
+ if (!dockResize.active) return
891
+ const vp = viewport()
892
+ const maxH = Math.round((vp ? vp.h : 800) * 0.85)
893
+ let h = dockResize.startH + (e.clientY - dockResize.startY)
894
+ h = Math.min(Math.max(160, h), Math.min(maxH, 1200))
895
+ ui.set({ dockH: h })
896
+ }
897
+ function onUp(e) {
898
+ dockResize.active = false
899
+ try { if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId) } catch (err) { /* ignore */ }
900
+ }
901
+ function defaultDockH() {
902
+ try { if (typeof window !== 'undefined' && window.innerHeight) return Math.round(window.innerHeight * 0.52) } catch (e) { /* ignore */ }
903
+ return 420
904
+ }
905
+ const bodyStyle = ui.dockH ? { height: ui.dockH + 'px' } : null
874
906
  return el('div', { className: 'xq-dock' }, [
875
907
  el('div', { key: 'head', className: 'xq-dock-head' }, [
876
908
  el('span', { key: 'logo', className: 'xq-logo' }, [el('b', { key: 'b' }, '雪球'), ' mini']),
@@ -879,9 +911,14 @@ export default {
879
911
  el('span', { key: 'sp', className: 'xq-spacer' }),
880
912
  el('button', { key: 'min', className: 'xq-btn-mini', title: '收起(点右下角徽章重新打开)', onClick: function () { ui.set({ open: false }) } }, '收起 —')
881
913
  ]),
882
- el('div', { key: 'body', className: 'xq-dock-body' }, [
914
+ el('div', { key: 'body', className: 'xq-dock-body', style: bodyStyle }, [
883
915
  el(XueqiuPanel, { key: 'panel' })
884
- ])
916
+ ]),
917
+ el('div', {
918
+ key: 'rs', className: 'xq-dock-resize', title: '拖动调整面板高度(双击复位)',
919
+ onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerCancel: onUp,
920
+ onDoubleClick: function () { ui.set({ dockH: null }) }
921
+ }, [el('span', { key: 'g' }, null)])
885
922
  ])
886
923
  }
887
924
 
@@ -1005,4 +1042,4 @@ export default {
1005
1042
  })
1006
1043
 
1007
1044
  }
1008
- }
1045
+ }
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,
@@ -390,9 +464,11 @@ export default {
390
464
 
391
465
  async function actUiGet() {
392
466
  const s = await loadUiState()
467
+ const hNum = Number(s.dockH)
393
468
  return {
394
469
  tab: s.tab || 'market', open: s.open !== false,
395
- badgePos: (s.badgePos && isFinite(Number(s.badgePos.x)) && isFinite(Number(s.badgePos.y)))
470
+ dockH: (s.dockH !== undefined && isFinite(hNum) && hNum >= 160 && hNum <= 1200) ? hNum : null,
471
+ badgePos: (s.badgePos && isFinite(Number(s.badgePos.x)) && isFinite(Number(s.badgePos.y())))
396
472
  ? { x: Number(s.badgePos.x), y: Number(s.badgePos.y) } : null
397
473
  }
398
474
  }
@@ -404,6 +480,10 @@ export default {
404
480
  if (args.badgePos && typeof args.badgePos === 'object' && isFinite(Number(args.badgePos.x)) && isFinite(Number(args.badgePos.y))) {
405
481
  s.badgePos = { x: Number(args.badgePos.x), y: Number(args.badgePos.y) }
406
482
  }
483
+ if (args.dockH !== undefined) {
484
+ const dh = Number(args.dockH)
485
+ if (isFinite(dh)) s.dockH = Math.min(Math.max(dh, 160), 1200)
486
+ }
407
487
  await saveUiState()
408
488
  return { ok: true }
409
489
  }