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 +44 -6
- package/dynamic/host.js +113 -32
- package/package.json +2 -2
- package/src/client/index.js +50 -13
- package/src/index.js +113 -33
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:
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
147
|
+
return attempt(depth + 1)
|
|
86
148
|
}
|
|
87
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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",
|
package/src/client/index.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
|
|
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:
|
|
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
|
|
107
|
-
if (!
|
|
108
|
-
return
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
147
|
+
return attempt(depth + 1)
|
|
87
148
|
}
|
|
88
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
}
|