dsh-xueqiu 1.7.0 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md ADDED
@@ -0,0 +1,134 @@
1
+ # dsh-xueqiu · Xueqiu Mini Market Panel
2
+
3
+ > A Xueqiu (Snowball) market panel for DeepSeek Harness: **no login required** — live A-share/HK/US quotes, candlestick & minute charts, hot lists, search, 7×24 news and trending KOLs. The panel docks above the composer without covering the conversation; a draggable mini badge keeps live indices always visible.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/dsh-xueqiu?style=flat-square&label=npm)](https://www.npmjs.com/package/dsh-xueqiu)
6
+ [![npm downloads](https://img.shields.io/npm/dm/dsh-xueqiu?style=flat-square)](https://www.npmjs.com/package/dsh-xueqiu)
7
+ [![GitHub stars](https://img.shields.io/github/stars/kangjinghang/dsh-xueqiu?style=flat-square)](https://github.com/kangjinghang/dsh-xueqiu/stargazers)
8
+ [![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](./LICENSE)
9
+ [![dsh-plugin](https://img.shields.io/badge/dsh-plugin-xueqiu-1DA1F2?style=flat-square)](#install)
10
+
11
+ [中文](./README.md)
12
+
13
+ **[Features](#features) · [Screenshots](#screenshots) · [Install](#install) · [Usage](#usage) · [Stability](#stability-design) · [FAQ](#faq) · [Changelog](#changelog)**
14
+
15
+ ## Features
16
+
17
+ | Feature | Notes |
18
+ | --- | --- |
19
+ | 📊 Live quotes | Major indices (SH/SZ/ChiNext/CSI300) + watchlist, CN-style red-up/green-down, **sortable columns** |
20
+ | 🕯️ Candlestick | Candles + volume bars + **MA5/10/20** + **crosshair tooltip** (OHLC/pct/vol/MAs); 7 periods: 5m/15m/30m/60m/day/week/month |
21
+ | ⏱️ Minute chart | Price + avg lines with prev-close baseline, crosshair for any minute |
22
+ | 🔥 Hot list | Xueqiu trending stocks, switch CN/US/HK/global |
23
+ | 🔍 Search | Stocks (one-click watch/add detail) and posts |
24
+ | 📰 News | 7×24 live news, important items highlighted |
25
+ | 👥 Trending KOLs | Per-stock hot users (followers / verified badge) |
26
+ | 💼 Watchlist | Persisted locally, add/remove in one click |
27
+ | 🧲 Docked panel | Full panel docks **above the composer** (official `conversation.input.dock` slot), flows with layout, **never covers messages** |
28
+ | 🏷️ Mini badge | Floating badge with live SH/SZ indices; click toggles the panel, drag to reposition (position remembered) |
29
+ | 📏 Resizable | Drag the bottom handle (160px–85% viewport, double-click resets); height persisted |
30
+ | ⌨️ Esc collapse | Esc closes detail first, then the panel; reopen via badge or ticker bar |
31
+ | 🛡️ Request guard | Concurrency 2 + 100ms min gap; **30s watchdog** force-releases hung slots; cookie risk-control auto reseed & retry; rate-limit backoff; **auto-pauses all polling while the tab is hidden** |
32
+ | 🗂️ Progressive detail | Quote + kline render first; minute/finance/KOL merge in as they arrive |
33
+ | ⏱️ Smart refresh | 20s during trading hours, slows down after close |
34
+ | 🕐 Market sessions | Header shows **CN/HK/US** session (open·lunch·pre·closed), badge shows precise A-share session |
35
+ | 🌗 Theme aware | Follows the DSH light/dark theme |
36
+
37
+ All data comes from Xueqiu public web endpoints (anonymous cookie seeded by visiting the homepage + browser UA/Referer). **No login.**
38
+
39
+ ## Screenshots
40
+
41
+ **Docked panel** — above the composer, index cards + watchlist + four tabs:
42
+
43
+ ![Panel](https://raw.githubusercontent.com/kangjinghang/dsh-xueqiu/main/assets/panel.png)
44
+
45
+ **Stock detail** — 16 quote stats + candlestick (volume / MA5-10-20 / crosshair) + financials + trending users:
46
+
47
+ ![Detail](https://raw.githubusercontent.com/kangjinghang/dsh-xueqiu/main/assets/detail.png)
48
+
49
+ **Mini badge** — always-on live indices, click to toggle, draggable:
50
+
51
+ ![Badge](https://raw.githubusercontent.com/kangjinghang/dsh-xueqiu/main/assets/badge.png)
52
+
53
+ ## Install
54
+
55
+ ### Option 1: Standard bundle plugin (recommended)
56
+
57
+ ```bash
58
+ # from npm
59
+ dsh plugin --profile web add dsh-xueqiu
60
+
61
+ # or from GitHub source
62
+ dsh plugin --profile web add github:kangjinghang/dsh-xueqiu
63
+
64
+ # or a local checkout
65
+ dsh plugin --profile web add ./dsh-xueqiu
66
+ ```
67
+
68
+ Restart `dsh web` once after adding (plugin rows are discovered at startup), then refresh the page.
69
+
70
+ ### Option 2: Dynamic plugin (battle-tested)
71
+
72
+ The `dynamic/` directory ships tested dynamic Cordis plugin sources (`host.js` + `client.js`). Ask any DSH agent session to load them:
73
+
74
+ ```
75
+ Read dynamic/host.js and dynamic/client.js from this repo,
76
+ define a plugin with cordis_define (kind: new):
77
+ code.host = contents of host.js, code.client = contents of client.js,
78
+ then cordis_run it.
79
+ ```
80
+
81
+ ## Usage
82
+
83
+ - The panel docks above the composer, in the conversation column; `Collapse —` or `Esc` hides it.
84
+ - The bottom-right mini badge shows live SH/SZ indices; **click** toggles the panel, **drag** to move (remembered).
85
+ - The ticker bar under the composer also expands the panel on click.
86
+ - Click any watchlist row, index card or hot-list row for the detail view: 16 stats + K-line/minute toggle (hover for crosshair) + financials + trending users.
87
+ - **Resize**: drag the handle under the panel (160px–85% viewport); double-click resets; height is remembered.
88
+ - Refresh cadence: 20s quotes / 60s content during trading hours; 60s / 3min after close.
89
+
90
+ ## Stability design
91
+
92
+ The data layer is defended in depth — safe to leave open for hours:
93
+
94
+ - **Request gate**: max 2 concurrent, 100ms minimum gap, mirroring the web client's rhythm.
95
+ - **30s watchdog**: any hung request is force-released after 30s so the pipeline never freezes.
96
+ - **TTL cache + in-flight dedup**: identical URLs hit cache within the window; concurrent duplicates share one Promise.
97
+ - **Cookie self-healing**: anonymous cookie expiry (error 400016) or risk-control empty responses trigger automatic reseeding and retry.
98
+ - **Rate-limit backoff**: 2s → 4s exponential backoff on "too frequent" errors.
99
+ - **Hidden-tab pause**: all polling stops while the page is hidden and refreshes immediately on return.
100
+ - **Progressive rendering**: quote + kline first; the rest merges in.
101
+
102
+ ## FAQ
103
+
104
+ **Panel doesn't show up after install?** Restart `dsh web` once (see above) and hard-refresh (Ctrl/Cmd+Shift+R). If the badge is visible, click it to expand the panel.
105
+
106
+ **Quotes suddenly empty / errors?** The anonymous cookie occasionally gets risk-controlled (error 400016 or empty body). The plugin reseeds and retries automatically; if it keeps failing, wait a minute and hit refresh, or collapse the panel to lower the request rate.
107
+
108
+ **Why no period buttons in minute mode?** Minute mode shows only the current day; the 7 periods exist in K-line mode. By design.
109
+
110
+ **Are session hints holiday-aware?** No — they're fixed-clock weekday estimates without holiday calendars, for reference only.
111
+
112
+ **Where is my data stored?** Host-side files `~/.xueqiu-watchlist.json` and `~/.xueqiu-ui-state.json` — browser-independent.
113
+
114
+ **Does it touch DSH itself?** No. All UI mounts in official slots (`conversation.input.dock` / `shell.overlay` / `conversation.composer.dock`); uninstalling removes everything.
115
+
116
+ ## Disclaimer
117
+
118
+ - This is **not** an official Xueqiu product. "雪球/Xueqiu" is a trademark of Xueqiu Inc., referenced here only as the data source.
119
+ - Data comes from public web endpoints for **study and research** only; **not investment advice**. Do not hammer the endpoints; respect the site's terms.
120
+ - Endpoints may change at any time and break features — [issues](https://github.com/kangjinghang/dsh-xueqiu/issues) and PRs welcome.
121
+
122
+ ## Changelog
123
+
124
+ - **1.7.2** (2025-08-19) — News tab pagination: scroll-to-bottom or "load earlier" fetches older items (max_id cursor, deduped); refresh resets to the latest page.
125
+ - **1.7.1** (2025-08-19) — Fix static install (`dsh plugin add`) crashing `dsh web` startup: dual-mode RPC (harness.handle for dynamic runs, a loopback+same-origin fenced `/xq-rpc` webServer route for static installs) and a proper `__ModuleLoader__` CJS client bundle.
126
+ - **1.7.0** (2025-08-19) — Hidden-tab polling pause + CN/HK/US market session hints.
127
+ - **1.6.1** — Pipeline deadlock watchdog fix, tab-switch fix, badgePos restore fix, debug RPC.
128
+ - **1.6.0** — Draggable panel height (smooth zero-rerender, double-click reset, persisted).
129
+ - **1.5.x** — Request scheduling & cache hardening (concurrency 2 + 100ms gap, TTL cache, error-classified retries).
130
+ - **1.4.x** — Progressive detail rendering, full kline periods, crosshair.
131
+
132
+ ## License
133
+
134
+ [MIT](./LICENSE)
package/README.md CHANGED
@@ -2,9 +2,26 @@
2
2
 
3
3
  > DeepSeek Harness 上的雪球行情面板:**免登录**查看 A股/港股/美股实时行情、K线、分时、热榜、搜索、7×24 快讯与热议用户。面板嵌入输入框上方不遮挡对话,迷你徽章常驻实时指数,交易时段智能刷新。
4
4
 
5
- ![badge](https://img.shields.io/badge/dsh-plugin-xueqiu-1DA1F2)
6
- ![badge](https://img.shields.io/badge/platform-web-blue)
7
- ![license](https://img.shields.io/badge/license-MIT-green)
5
+ [![npm version](https://img.shields.io/npm/v/dsh-xueqiu?style=flat-square&label=npm)](https://www.npmjs.com/package/dsh-xueqiu)
6
+ [![npm downloads](https://img.shields.io/npm/dm/dsh-xueqiu?style=flat-square)](https://www.npmjs.com/package/dsh-xueqiu)
7
+ [![GitHub stars](https://img.shields.io/github/stars/kangjinghang/dsh-xueqiu?style=flat-square)](https://github.com/kangjinghang/dsh-xueqiu/stargazers)
8
+ [![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](./LICENSE)
9
+ [![dsh-plugin](https://img.shields.io/badge/dsh-plugin-xueqiu-1DA1F2?style=flat-square)](#-安装)
10
+
11
+ 中文 | [English](./README.en.md)
12
+
13
+ **[功能](#-功能) · [截图](#-截图) · [安装](#-安装) · [使用](#️-使用) · [稳定性](#-稳定性设计) · [FAQ](#-faq) · [更新日志](#-更新日志)**
14
+
15
+ ## 🆚 与同类行情插件
16
+
17
+ | 能力 | dsh-xueqiu | 股票皮肤类插件 |
18
+ | --- | --- | --- |
19
+ | 面板形态 | 嵌入输入框上方,不遮挡对话 | 全局换肤/状态栏 |
20
+ | K线/分时 | 蜡烛图+均线+十字光标,7 档周期 | 部分有 |
21
+ | 热榜/快讯/KOL | 全有 | 多数无 |
22
+ | 数据源 | 雪球(社区数据:帖子/热议用户独有) | 腾讯/Yahoo 等 |
23
+ | 请求防护 | 闸门+看门狗+缓存+自愈+隐藏暂停 | 一般仅缓存 |
24
+ | 主题 | 跟随 DSH 明暗 | 需整体换肤 |
8
25
 
9
26
  ## ✨ 功能
10
27
 
@@ -106,6 +123,26 @@ dsh-xueqiu/
106
123
  └── cordis.patch.yml # bundle 层插入
107
124
  ```
108
125
 
126
+ ## ❓ FAQ
127
+
128
+ **Q: 安装后面板不出现?**
129
+ 按安装说明重启一次 `dsh web`(插件行按启动缓存发现),再硬刷新页面(Ctrl/Cmd+Shift+R)。仍无面板时看右下角有无迷你徽章——徽章在则点徽章展开。
130
+
131
+ **Q: 行情数据突然全空/报错?**
132
+ 雪球匿名 cookie 偶发被风控(错误码 400016 或空响应)。插件会自动重播种 cookie 并重试;连续失败时等 1–2 分钟再点「刷新」,或收起面板降低请求频率。
133
+
134
+ **Q: K线周期里分时下为什么没有周期按钮?**
135
+ 分时模式只展示当日分钟线,K线模式才有 7 档周期,属设计行为。
136
+
137
+ **Q: 交易时段提示节假日准吗?**
138
+ 不准。时段按每周一至周五的固定钟点本地推算,不含法定节假日调休,仅供参考。
139
+
140
+ **Q: 自选股和面板设置存在哪?**
141
+ 宿主本地文件 `~/.xueqiu-watchlist.json` 与 `~/.xueqiu-ui-state.json`,与浏览器 localStorage 无关,换浏览器不丢。
142
+
143
+ **Q: 会不会影响 DSH 本体?**
144
+ 插件 UI 全部挂在官方槽位(`conversation.input.dock` / `shell.overlay` / `conversation.composer.dock`),卸载即完全消失,不改 DSH 源码。
145
+
109
146
  ## ⚠️ 免责声明
110
147
 
111
148
  - 本项目**非雪球官方**产品,与雪球网/雪球公司无关;"雪球"为雪球公司商标,此处仅作数据来源描述。
@@ -114,6 +151,11 @@ dsh-xueqiu/
114
151
 
115
152
  ## 📋 更新日志
116
153
 
154
+ - **1.7.2**(2025-08-19)
155
+ - 新增:快讯 tab 翻页——滚动到底或点「加载更早」自动加载历史快讯(max_id 游标,自动去重),「刷新」重置回最新一页。
156
+ - **1.7.1**(2025-08-19)
157
+ - 修复:`dsh plugin add` 静态安装后 `dsh web` 启动即崩(`harness is not defined`)——静态安装改走 `webServer` 前缀路由 `/xq-rpc`(带回环 + 同源双重栅栏),动态运行时仍走 `harness.handle`,双模式自动切换。
158
+ - 修复:静态安装的浏览器端 bundle 格式(`__ModuleLoader__` CJS 工厂)与网络通道(同源 fetch 替代 `host.call`),面板/徽章/指数条在静态模式下完整可用。
117
159
  - **1.7.0**(2025-08-19)
118
160
  - 新增:页面隐藏时自动暂停面板/徽章/指数条全部轮询,回到前台立即刷新——切走不浪费请求,显著降低雪球风控触发概率。
119
161
  - 新增:A 股/港股/美股三市场交易时段提示(盘中·午休·盘前·休市,本地 Intl 时区推算,不含节假日),面板头常驻显示,徽章显示精确 A 股时段,每分钟自动刷新。
package/dynamic/client.js CHANGED
@@ -62,6 +62,8 @@ return {
62
62
  '.xq-news-item{font-size:12.5px;padding:6px 8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:7px;}\n' +
63
63
  '.xq-news-item.xq-important{border-left:3px solid var(--dsw-alias-state-warn-primary);}\n' +
64
64
  '.xq-news-time{font-size:10.5px;color:var(--dsw-alias-label-secondary);margin-top:3px;}\n' +
65
+ '.xq-news-more{font-size:11.5px;color:var(--dsw-alias-label-secondary);text-align:center;padding:6px 0 2px;cursor:pointer;user-select:none;}\n' +
66
+ '.xq-news-more:hover{color:var(--dsw-alias-label-primary);}\n' +
65
67
  '.xq-detail-head{display:flex;align-items:center;gap:10px;margin-bottom:8px;flex-wrap:wrap;}\n' +
66
68
  '.xq-detail-name{font-size:15px;font-weight:700;}\n' +
67
69
  '.xq-detail-code{font-size:11px;color:var(--dsw-alias-label-secondary);}\n' +
@@ -461,6 +463,8 @@ return {
461
463
  const [hot, setHot] = React.useState([])
462
464
  const [hotMarket, setHotMarket] = React.useState('cn')
463
465
  const [news, setNews] = React.useState([])
466
+ const [newsMore, setNewsMore] = React.useState(false) // 翻页加载中
467
+ const [newsNoMore, setNewsNoMore] = React.useState(false) // 已到最旧
464
468
  const [searchQ, setSearchQ] = React.useState('')
465
469
  const [searchMode, setSearchMode] = React.useState('stock')
466
470
  const [searchRes, setSearchRes] = React.useState([])
@@ -505,12 +509,32 @@ return {
505
509
  ]).then(function (res) {
506
510
  setHot((res[0] && res[0].list) || [])
507
511
  setNews((res[1] && res[1].items) || [])
512
+ setNewsMore(false)
513
+ setNewsNoMore(false)
508
514
  setErr('')
509
515
  }).catch(function (e) {
510
516
  setErr(String((e && e.message) || e))
511
517
  })
512
518
  }
513
519
 
520
+ // 快讯翻页:max_id = 当前最旧一条 id,返回严格更早的记录
521
+ function loadMoreNews() {
522
+ if (newsMore || newsNoMore || !news.length) return
523
+ const maxId = news[news.length - 1].id
524
+ setNewsMore(true)
525
+ call('news', { count: 20, max_id: maxId }).then(function (res) {
526
+ const items = (res && res.items) || []
527
+ const seen = {}
528
+ news.forEach(function (it) { seen[it.id] = true })
529
+ const fresh = items.filter(function (it) { return !seen[it.id] })
530
+ if (!fresh.length) setNewsNoMore(true)
531
+ else setNews(news.concat(fresh))
532
+ setNewsMore(false)
533
+ }).catch(function () {
534
+ setNewsMore(false)
535
+ })
536
+ }
537
+
514
538
  React.useEffect(function () {
515
539
  setLoading(true)
516
540
  refreshMarket().then(function () { return refreshContent() })
@@ -868,8 +892,20 @@ return {
868
892
  el('div', { key: 'm', className: 'xq-news-time' }, fmtTime(it.created_at) + (it.mark === 1 ? ' · 重要' : ''))
869
893
  ])
870
894
  })
895
+ // 滚动到底自动加载;手动入口兜底(滚轮惯性/触控板场景)
896
+ kids.push(el('div', {
897
+ key: 'more',
898
+ className: 'xq-news-more',
899
+ onClick: function () { loadMoreNews() }
900
+ }, newsMore ? '加载中…' : (newsNoMore ? '没有更早的快讯了' : '加载更早 ↑')))
871
901
  return news.length
872
- ? el('div', { className: 'xq-news' }, kids)
902
+ ? el('div', {
903
+ className: 'xq-news',
904
+ onScroll: function (e) {
905
+ const n = e.target
906
+ if (n.scrollTop + n.clientHeight >= n.scrollHeight - 40) loadMoreNews()
907
+ }
908
+ }, kids)
873
909
  : el('div', { className: 'xq-muted' }, '暂无快讯')
874
910
  }
875
911
 
package/dynamic/host.js CHANGED
@@ -375,7 +375,10 @@ return {
375
375
 
376
376
  async function actNews(args) {
377
377
  const count = Math.min(Math.max(parseInt(args.count, 10) || 20, 1), 50)
378
- const data = await getJSON('/statuses/livenews/list.json', { since_id: -1, max_id: -1, count: count }, { base: SITE, ttl: 120000 })
378
+ // max_id 翻页游标:-1 取最新一页;传上一页最旧一条 id 继续往回翻
379
+ const maxId = parseInt(args.max_id, 10)
380
+ const cursor = isFinite(maxId) && maxId > 0 ? maxId : -1
381
+ const data = await getJSON('/statuses/livenews/list.json', { since_id: -1, max_id: cursor, count: count }, { base: SITE, ttl: 120000 })
379
382
  const items = (data && data.items) || []
380
383
  return {
381
384
  items: items.map(function (it) {
@@ -542,19 +545,53 @@ return {
542
545
  }
543
546
  }
544
547
 
545
- ctx.effect(function () {
546
- return harness.handle('xq.call', async function (req) {
547
- const reqObj = (req && typeof req === 'object') ? req : {}
548
- const action = reqObj.action
549
- const args = reqObj.args || {}
550
- if (!dispatch[action]) return { ok: false, error: '未知操作: ' + action }
551
- try {
552
- const data = await dispatch[action](args)
553
- return { ok: true, data: sanitize(data) }
554
- } catch (e) {
555
- return { ok: false, error: String((e && e.message) || e) }
556
- }
548
+ async function handleCall (req) {
549
+ const reqObj = (req && typeof req === 'object') ? req : {}
550
+ const action = reqObj.action
551
+ const args = reqObj.args || {}
552
+ if (!dispatch[action]) return { ok: false, error: '未知操作: ' + action }
553
+ try {
554
+ const data = await dispatch[action](args)
555
+ return { ok: true, data: sanitize(data) }
556
+ } catch (e) {
557
+ return { ok: false, error: String((e && e.message) || e) }
558
+ }
559
+ }
560
+
561
+ // 动态插件运行时提供 harness 门面;静态安装(bundle layer)没有,
562
+ // 改走 webServer 前缀路由,client 用同源 fetch 调 /xq-rpc。
563
+ if (typeof harness !== 'undefined') {
564
+ ctx.effect(function () {
565
+ return harness.handle('xq.call', handleCall)
557
566
  })
558
- })
567
+ } else {
568
+ ctx.inject(['webServer'], function (webCtx) {
569
+ webCtx.effect(function () {
570
+ return webCtx.webServer.register({
571
+ kind: 'prefix',
572
+ path: '/xq-rpc',
573
+ handler: async function (req, res) {
574
+ // 同源栅栏:Host 头必须是回环,带 Origin 时必须同源(防 DNS rebinding/CSRF)
575
+ const hostHeader = String(req.headers.host || '')
576
+ const origin = req.headers.origin ? String(req.headers.origin) : ''
577
+ const hostAuth = hostHeader.replace(/:\d+$/, '')
578
+ const loopback = hostAuth === '127.0.0.1' || hostAuth === 'localhost' || hostAuth === '[::1]' || hostAuth === '::1'
579
+ if (!loopback) { res.writeHead(403); res.end('forbidden'); return }
580
+ if (origin && origin !== 'http://' + hostHeader && origin !== 'https://' + hostHeader) {
581
+ res.writeHead(403); res.end('forbidden'); return
582
+ }
583
+ const chunks = []
584
+ for await (const c of req) chunks.push(c)
585
+ let body = {}
586
+ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') } catch (e) { body = {} }
587
+ const result = await handleCall(body)
588
+ const text = JSON.stringify(result)
589
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
590
+ res.end(text)
591
+ }
592
+ })
593
+ })
594
+ })
595
+ }
559
596
  }
560
597
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-xueqiu",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "description": "雪球 mini 行情面板 — DeepSeek Harness 免登录 A股/港美股实时行情、K线(蜡烛/成交量/均线)、分时图、热榜、搜索、7x24 快讯、热议用户。嵌入式面板 + 可拖拽行情徽标,内置请求节流、TTL 缓存与 Cookie 自动续期。",
5
5
  "keywords": [
6
6
  "dsh",
@@ -46,5 +46,10 @@
46
46
  "cordis.patch.yml",
47
47
  "README.md",
48
48
  "LICENSE"
49
- ]
49
+ ],
50
+ "scripts": {
51
+ "generate": "python3 scripts/gen-static.py",
52
+ "check": "python3 scripts/gen-static.py --check && node --check src/index.js && node --check src/client/index.js && node -e \"new Function(require('fs').readFileSync('dynamic/host.js','utf8'))\" && node -e \"new Function(require('fs').readFileSync('dynamic/client.js','utf8'))\" && bash scripts/static-smoke.sh",
53
+ "prepublishOnly": "npm run check"
54
+ }
50
55
  }
@@ -1,7 +1,26 @@
1
- return {
1
+ // 由 scripts/gen-static.py 从 dynamic/client.js 生成 —— 勿手改;改 dynamic/ 后重新生成。
2
+ // 静态 bundle 格式:classic script 注册到 __ModuleLoader__,factory 为 CJS 形式。
3
+ window.__ModuleLoader__.load({
4
+ id: 'dsh-xueqiu',
5
+ factory: function (require) {
6
+ var module = { exports: {} }
7
+ var exports = module.exports
8
+ const React = require('react')
9
+
10
+ // 动态运行时 styles.insert 的替代:自管 <style> 标签(随插件停用移除交给卸载流程)
11
+ let _styleTag = null
12
+ function insertStyle (css) {
13
+ if (typeof document === 'undefined' || !document.head) return
14
+ _styleTag = document.createElement('style')
15
+ _styleTag.setAttribute('data-plugin', 'dsh-xueqiu')
16
+ _styleTag.textContent = css
17
+ document.head.appendChild(_styleTag)
18
+ }
19
+
20
+ exports.default = {
2
21
  inject: ['timer'],
3
22
  apply(ctx) {
4
- styles.insert('\n' +
23
+ insertStyle('\n' +
5
24
  '.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
25
  '.xq-dock *{box-sizing:border-box;}\n' +
7
26
  '.xq-dock-head{display:flex;align-items:center;gap:8px;padding:7px 12px;border-bottom:1px solid var(--dsw-alias-border-l1);}\n' +
@@ -62,6 +81,8 @@ return {
62
81
  '.xq-news-item{font-size:12.5px;padding:6px 8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:7px;}\n' +
63
82
  '.xq-news-item.xq-important{border-left:3px solid var(--dsw-alias-state-warn-primary);}\n' +
64
83
  '.xq-news-time{font-size:10.5px;color:var(--dsw-alias-label-secondary);margin-top:3px;}\n' +
84
+ '.xq-news-more{font-size:11.5px;color:var(--dsw-alias-label-secondary);text-align:center;padding:6px 0 2px;cursor:pointer;user-select:none;}\n' +
85
+ '.xq-news-more:hover{color:var(--dsw-alias-label-primary);}\n' +
65
86
  '.xq-detail-head{display:flex;align-items:center;gap:10px;margin-bottom:8px;flex-wrap:wrap;}\n' +
66
87
  '.xq-detail-name{font-size:15px;font-weight:700;}\n' +
67
88
  '.xq-detail-code{font-size:11px;color:var(--dsw-alias-label-secondary);}\n' +
@@ -104,7 +125,7 @@ return {
104
125
  function el(type, props, children) { return React.createElement(type, props, children) }
105
126
 
106
127
  async function call(action, args) {
107
- const r = await host.call('xq.call', { action: action, args: args || {} })
128
+ const r = await fetch('/xq-rpc', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: action, args: args || {} }) }).then(function (res) { return res.json() })
108
129
  if (!r || !r.ok) throw new Error((r && r.error) || '调用失败')
109
130
  return r.data
110
131
  }
@@ -461,6 +482,8 @@ return {
461
482
  const [hot, setHot] = React.useState([])
462
483
  const [hotMarket, setHotMarket] = React.useState('cn')
463
484
  const [news, setNews] = React.useState([])
485
+ const [newsMore, setNewsMore] = React.useState(false) // 翻页加载中
486
+ const [newsNoMore, setNewsNoMore] = React.useState(false) // 已到最旧
464
487
  const [searchQ, setSearchQ] = React.useState('')
465
488
  const [searchMode, setSearchMode] = React.useState('stock')
466
489
  const [searchRes, setSearchRes] = React.useState([])
@@ -505,12 +528,32 @@ return {
505
528
  ]).then(function (res) {
506
529
  setHot((res[0] && res[0].list) || [])
507
530
  setNews((res[1] && res[1].items) || [])
531
+ setNewsMore(false)
532
+ setNewsNoMore(false)
508
533
  setErr('')
509
534
  }).catch(function (e) {
510
535
  setErr(String((e && e.message) || e))
511
536
  })
512
537
  }
513
538
 
539
+ // 快讯翻页:max_id = 当前最旧一条 id,返回严格更早的记录
540
+ function loadMoreNews() {
541
+ if (newsMore || newsNoMore || !news.length) return
542
+ const maxId = news[news.length - 1].id
543
+ setNewsMore(true)
544
+ call('news', { count: 20, max_id: maxId }).then(function (res) {
545
+ const items = (res && res.items) || []
546
+ const seen = {}
547
+ news.forEach(function (it) { seen[it.id] = true })
548
+ const fresh = items.filter(function (it) { return !seen[it.id] })
549
+ if (!fresh.length) setNewsNoMore(true)
550
+ else setNews(news.concat(fresh))
551
+ setNewsMore(false)
552
+ }).catch(function () {
553
+ setNewsMore(false)
554
+ })
555
+ }
556
+
514
557
  React.useEffect(function () {
515
558
  setLoading(true)
516
559
  refreshMarket().then(function () { return refreshContent() })
@@ -868,8 +911,20 @@ return {
868
911
  el('div', { key: 'm', className: 'xq-news-time' }, fmtTime(it.created_at) + (it.mark === 1 ? ' · 重要' : ''))
869
912
  ])
870
913
  })
914
+ // 滚动到底自动加载;手动入口兜底(滚轮惯性/触控板场景)
915
+ kids.push(el('div', {
916
+ key: 'more',
917
+ className: 'xq-news-more',
918
+ onClick: function () { loadMoreNews() }
919
+ }, newsMore ? '加载中…' : (newsNoMore ? '没有更早的快讯了' : '加载更早 ↑')))
871
920
  return news.length
872
- ? el('div', { className: 'xq-news' }, kids)
921
+ ? el('div', {
922
+ className: 'xq-news',
923
+ onScroll: function (e) {
924
+ const n = e.target
925
+ if (n.scrollTop + n.clientHeight >= n.scrollHeight - 40) loadMoreNews()
926
+ }
927
+ }, kids)
873
928
  : el('div', { className: 'xq-muted' }, '暂无快讯')
874
929
  }
875
930
 
@@ -1131,3 +1186,6 @@ return {
1131
1186
 
1132
1187
  }
1133
1188
  }
1189
+ return module.exports
1190
+ }
1191
+ })
package/src/index.js CHANGED
@@ -375,7 +375,10 @@ export default {
375
375
 
376
376
  async function actNews(args) {
377
377
  const count = Math.min(Math.max(parseInt(args.count, 10) || 20, 1), 50)
378
- const data = await getJSON('/statuses/livenews/list.json', { since_id: -1, max_id: -1, count: count }, { base: SITE, ttl: 120000 })
378
+ // max_id 翻页游标:-1 取最新一页;传上一页最旧一条 id 继续往回翻
379
+ const maxId = parseInt(args.max_id, 10)
380
+ const cursor = isFinite(maxId) && maxId > 0 ? maxId : -1
381
+ const data = await getJSON('/statuses/livenews/list.json', { since_id: -1, max_id: cursor, count: count }, { base: SITE, ttl: 120000 })
379
382
  const items = (data && data.items) || []
380
383
  return {
381
384
  items: items.map(function (it) {
@@ -542,19 +545,53 @@ export default {
542
545
  }
543
546
  }
544
547
 
545
- ctx.effect(function () {
546
- return harness.handle('xq.call', async function (req) {
547
- const reqObj = (req && typeof req === 'object') ? req : {}
548
- const action = reqObj.action
549
- const args = reqObj.args || {}
550
- if (!dispatch[action]) return { ok: false, error: '未知操作: ' + action }
551
- try {
552
- const data = await dispatch[action](args)
553
- return { ok: true, data: sanitize(data) }
554
- } catch (e) {
555
- return { ok: false, error: String((e && e.message) || e) }
556
- }
548
+ async function handleCall (req) {
549
+ const reqObj = (req && typeof req === 'object') ? req : {}
550
+ const action = reqObj.action
551
+ const args = reqObj.args || {}
552
+ if (!dispatch[action]) return { ok: false, error: '未知操作: ' + action }
553
+ try {
554
+ const data = await dispatch[action](args)
555
+ return { ok: true, data: sanitize(data) }
556
+ } catch (e) {
557
+ return { ok: false, error: String((e && e.message) || e) }
558
+ }
559
+ }
560
+
561
+ // 动态插件运行时提供 harness 门面;静态安装(bundle layer)没有,
562
+ // 改走 webServer 前缀路由,client 用同源 fetch 调 /xq-rpc。
563
+ if (typeof harness !== 'undefined') {
564
+ ctx.effect(function () {
565
+ return harness.handle('xq.call', handleCall)
557
566
  })
558
- })
567
+ } else {
568
+ ctx.inject(['webServer'], function (webCtx) {
569
+ webCtx.effect(function () {
570
+ return webCtx.webServer.register({
571
+ kind: 'prefix',
572
+ path: '/xq-rpc',
573
+ handler: async function (req, res) {
574
+ // 同源栅栏:Host 头必须是回环,带 Origin 时必须同源(防 DNS rebinding/CSRF)
575
+ const hostHeader = String(req.headers.host || '')
576
+ const origin = req.headers.origin ? String(req.headers.origin) : ''
577
+ const hostAuth = hostHeader.replace(/:\d+$/, '')
578
+ const loopback = hostAuth === '127.0.0.1' || hostAuth === 'localhost' || hostAuth === '[::1]' || hostAuth === '::1'
579
+ if (!loopback) { res.writeHead(403); res.end('forbidden'); return }
580
+ if (origin && origin !== 'http://' + hostHeader && origin !== 'https://' + hostHeader) {
581
+ res.writeHead(403); res.end('forbidden'); return
582
+ }
583
+ const chunks = []
584
+ for await (const c of req) chunks.push(c)
585
+ let body = {}
586
+ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') } catch (e) { body = {} }
587
+ const result = await handleCall(body)
588
+ const text = JSON.stringify(result)
589
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
590
+ res.end(text)
591
+ }
592
+ })
593
+ })
594
+ })
595
+ }
559
596
  }
560
597
  }