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 +106 -31
- package/package.json +2 -2
- package/src/client/index.js +6 -7
- package/src/index.js +106 -32
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,
|
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.4.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,6 +1,5 @@
|
|
|
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' +
|
|
@@ -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
|
|
107
|
-
if (!
|
|
108
|
-
return
|
|
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
|
|
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,
|