SLOPSHOPPER

tw-ticker

Shows a Taiwan stock ticker above the prompt and PTT danmaku while Claude is working

newbandcommandtoastnetworktimer
v1.1.0no licenseupdated 2026-10-07starryAmy/amy-mods/plugins/tw-ticker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tw-ticker
› fix the failing auth test and add an audit log call ╭──────────────────────────────╮ │ tw-ticker │ ⏺ Read(src/auth.ts) │ 自選股:2330, 0050 · 工作時顯示 · 彈幕關 │ ⎿ Read 6 lines ╰──────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /twstock ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

tw-ticker 1.1.0

Claude 工作時,在輸入框上方顯示台股自選股報價,並可開啟 PTT Stock 版「盤中閒聊」/「盤後閒聊」彈幕。

指令

指令作用
/twstock顯示目前設定
/twstock add 2317 2454新增自選股
/twstock remove 0050移除自選股
/twstock pin切換常駐顯示 / 只在 Claude 工作時顯示
/twstock refresh立即更新報價與推文
/twstock danmaku開關彈幕(預設關閉)
/twstock danmaku width 100設定彈幕寬度上限(格數;實際寬度會自動配合畫面,不會超過)
/twstock block 字詞 ...加入過濾字詞,含這些字的推文不顯示
/twstock unblock 字詞 ...移除過濾字詞

彈幕

抓哪一串

依台灣時間決定抓 PTT Stock 版當天的哪一篇閒聊文:

時段抓的文章
週一~週五 09:00–13:30當天的「盤中閒聊」
週一~週五 13:30 以後(到午夜)當天的「盤後閒聊」
週一~週五 09:00 以前、週六、週日不自動抓
  • 只看是不是平日,沒有處理國定假日;遇到假日還是會照平日的時段去找文章,但通常找不到,盤中時段會顯示「今天的盤中閒聊還沒出現」。
  • 找文章的方式:在看板列表的最新兩頁裡,找標題同時含有當天日期(例如 2026/10/07)和「盤中閒聊」或「盤後閒聊」的文章,Re:、Fw: 開頭的不算;有好幾篇符合(例如開了續集)就用最新那篇。
  • 每 10 分鐘重新找一次,有續集就自動換過去。
  • 13:30 一過換成盤後閒聊時,會清掉盤中閒聊的彈幕,舊推文不再輪播;換日時也一樣。
  • 當天的盤中閒聊還沒開串時,狀態列會顯示「今天的盤中閒聊還沒出現」;盤後閒聊還沒開串時彈幕就保持空白,不顯示提示。

什麼時候抓、多久抓一次

  • 只有在 Claude 工作中(或用 /twstock pin 設成常駐)才會抓,閒置時不發請求。
  • 每 30 秒檢查一次,但同一篇文章最多每 90 秒抓一次;Claude 開始新一輪工作時也會檢查一次。
  • 下面這些情況會馬上強制抓一次,不受 90 秒間隔和時段限制:
  • /twstock refresh
  • /twstock danmaku 開啟彈幕
  • /twstock pin 切換成常駐
  • 常駐模式下開啟新的 session
  • 強制抓的時候如果不在上面任何一個時段(開盤前、週末),會改抓當天的「盤中閒聊」;通常這時還沒開串,所以會看到「今天的盤中閒聊還沒出現」。

播放方式

彈幕會記住「這篇文章已經讀到第幾則推文」(不是記時間),用來分辨哪些是新推文:

  1. 第一次讀這篇:把最後 50 則推文放進輪播池,並先播最後 15 則;記下目前的推文總數(例如 320 則)。
  2. 之後每次抓(最快 90 秒一次):重新抓整篇文章,只把多出來的推文(例如第 321~335 則)排進去播,同時加進輪播池(池子只留最新 50 則);再把記錄更新成 335。
  3. 沒有新推文時:從輪播池依序重播,彈幕不會停;新推文一進來就優先插播。
  4. 新推文太多時只留最新的 80 則排隊。

下面這些情況會從頭重來(回到第 1 步):

  • 換文章:盤中閒聊換盤後閒聊、換日、偵測到續集
  • 推文數比上次少(例如文章被修改過)
  • 重開 Claude Code:讀到第幾則只記在記憶體裡,不會存檔,所以每個 session 都會從最後 15 則重新開始

其他顯示規則:

  • 兩條軌道,每 0.3 秒往左移一格;寬度預設配合畫面,可用 /twstock danmaku width 設上限。
  • 推=紅、噓=綠、→=預設色;提到自選股代號或名稱的推文以黃色粗體標亮。
  • 只顯示推文內容,不顯示 PTT 帳號;只有網址的推文、含過濾字詞的推文不顯示。
  • 推文不會進入 Claude 的對話。

資料來源

證交所基本市況報導網站與 PTT 網頁版,皆非正式 API,僅供個人使用。

Source 2 files
hooks/register.js 454 lines
1// tw-ticker: 在 Claude 工作時,於輸入框上方顯示台股自選股報價,以及 PTT Stock 版盤中閒聊的彈幕
2// 報價來源:證交所「基本市況報導」網站(非正式 API,僅供個人使用)
3// 彈幕來源:PTT 網頁版 Stock 版當日「盤中閒聊」推文,收盤後改用「盤後閒聊」(只顯示內容,不顯示帳號)
4
5import { parsePushes, findDailyThread, findPrevPage, strWidth, clipWidth, renderLane, stepLanes } from './danmaku.js'
6
7const BASE = 'https://mis.twse.com.tw/stock/api/getStockInfo.jsp'
8const PTT_INDEX = 'https://www.ptt.cc/bbs/Stock/index.html'
9const PTT_INIT = { headers: { Cookie: 'over18=1' } }
10const INDEX_CH = 'tse_t00.tw' // 發行量加權股價指數,固定顯示在最前面
11const DEFAULT_LIST = ['2330', '0050']
12const POLL_MS = 60_000 // 報價:每分鐘最多一次
13const DM_POLL_MS = 90_000 // 彈幕:每 90 秒最多抓一次推文
14const DM_RECHECK_MS = 10 * 60_000 // 每 10 分鐘重新確認一次今天的閒聊串(可能開了續集)
15const FRAME_MS = 300 // 彈幕每 0.3 秒移動一格
16const LANES = 2
17const GAP = 6 // 同一軌道兩則彈幕之間至少空幾格
18const MAX_ITEM_W = 60 // 單則彈幕最長幾格(PTT 推文最長約 48 格,所以一般不會截)
19const MAX_QUEUE = 80
20const FIRST_BATCH = 15 // 剛開啟時先放最近幾則
21const POOL_SIZE = 50 // 沒有新推文時,從最近幾則輪播
22const TW_OFFSET_MS = 8 * 3600 * 1000 // 台灣時間 UTC+8
23
24// ---------- 狀態 ----------
25let watchlist = [...DEFAULT_LIST]
26let pinned = false
27let working = false
28let marketOpen = false
29let quotes = []
30let index = null // 加權指數,格式同 quotes
31let lastFetch = 0
32let lastError = ''
33let fetching = false
34
35let danmakuOn = false
36let laneWidth = 0 // 寬度上限;0 = 用滿整個 band 寬度
37let bandColumns = 0 // 上次畫 band 時的可用寬度,0 = 還不知道
38let blocklist = []
39let lanes = Array.from({ length: LANES }, () => [])
40let queue = [] // 新推文,優先播
41let pool = [] // 最近 POOL_SIZE 則,queue 空了就從這裡輪播
42let poolIdx = 0
43let dmFetching = false
44let dmLastFetch = 0
45let dmStatus = ''
46let threadUrl = ''
47let threadKey = '' // 日期 + 盤中/盤後,例如 "2026/10/07 盤後閒聊"
48let threadCheckedAt = 0
49let seen = -1 // 已經看過幾則推文;-1 代表這串還沒讀過
50
51// ---------- 小工具 ----------
52
53function num(s) {
54  const n = parseFloat(s)
55  return Number.isFinite(n) ? n : null
56}
57
58function fmt(n) {
59  return n == null ? '-' : String(Number(n.toFixed(2)))
60}
61
62function twParts(ms) {
63  const d = new Date(ms + TW_OFFSET_MS)
64  return {
65    y: d.getUTCFullYear(),
66    mo: String(d.getUTCMonth() + 1).padStart(2, '0'),
67    da: String(d.getUTCDate()).padStart(2, '0'),
68    hh: String(d.getUTCHours()).padStart(2, '0'),
69    mm: String(d.getUTCMinutes()).padStart(2, '0'),
70    day: d.getUTCDay(),
71    minutes: d.getUTCHours() * 60 + d.getUTCMinutes(),
72  }
73}
74
75function twTime(ms) {
76  const p = twParts(ms)
77  return p.hh + ':' + p.mm
78}
79
80function twDate(ms) {
81  const p = twParts(ms)
82  return p.y + '/' + p.mo + '/' + p.da
83}
84
85// 台股交易時間:週一到週五 09:00–13:30(台灣時間),未處理國定假日
86function isMarketOpen(ms) {
87  const p = twParts(ms)
88  return p.day >= 1 && p.day <= 5 && p.minutes >= 9 * 60 && p.minutes <= 13 * 60 + 30
89}
90
91function toQuote(r) {
92  const prev = num(r.y)
93  const last = num(r.z) ?? num((r.b || '').split('_')[0]) ?? prev
94  const change = last != null && prev ? last - prev : 0
95  const pct = prev ? (change / prev) * 100 : 0
96  return { code: r.c, name: r.n, last, change, pct }
97}
98
99// 推文 → 彈幕。推=紅、噓=綠、→=預設色;提到自選股的推文用黃色粗體標亮
100function toItems(pushes) {
101  const keys = [...watchlist, ...quotes.map((q) => q.name)].filter(Boolean)
102  const items = []
103  for (const p of pushes) {
104    if (blocklist.some((w) => p.text.includes(w))) continue
105    if (/^https?:\/\/\S+$/.test(p.text)) continue // 只有網址的推文跳過
106    const text = clipWidth(p.text, MAX_ITEM_W)
107    const hot = keys.some((k) => p.text.includes(k))
108    const item = { text, w: strWidth(text), bold: hot }
109    if (hot) item.color = 'yellow'
110    else if (p.kind === '推') item.color = 'red'
111    else if (p.kind === '噓') item.color = 'green'
112    items.push(item)
113  }
114  return items
115}
116
117function enqueue(pushes) {
118  queue.push(...toItems(pushes))
119  if (queue.length > MAX_QUEUE) queue = queue.slice(-MAX_QUEUE) // 推文太多就只留最新的
120}
121
122function addToPool(pushes) {
123  pool = [...pool, ...toItems(pushes)].slice(-POOL_SIZE)
124}
125
126// queue 空了就從 pool 補一則;一次只補一則,新推文進來時能馬上插播
127function refillFromPool() {
128  if (queue.length > 0 || pool.length === 0) return
129  queue.push(pool[poolIdx % pool.length])
130  poolIdx = (poolIdx + 1) % pool.length
131}
132
133function effectiveWidth() {
134  if (bandColumns <= 0) return laneWidth || 80 // 還不知道 band 多寬時先用 80
135  return laneWidth ? Math.min(laneWidth, bandColumns) : bandColumns
136}
137
138function resetLanes() {
139  lanes = Array.from({ length: LANES }, () => [])
140  queue = []
141}
142
143// ---------- 報價 ----------
144
145async function refresh($) {
146  if (fetching) return
147  fetching = true
148  try {
149    const channels = [INDEX_CH, ...watchlist.flatMap((c) => ['tse_' + c + '.tw', 'otc_' + c + '.tw'])].join('|')
150    const url = BASE + '?json=1&delay=0&ex_ch=' + encodeURIComponent(channels)
151    const res = await $.http.fetch(url)
152    if (!res.ok) throw new Error('HTTP ' + res.status)
153    const data = JSON.parse(res.text)
154    const rows = Array.isArray(data.msgArray) ? data.msgArray : []
155    quotes = watchlist
156      .map((code) => rows.find((r) => r.c === code))
157      .filter(Boolean)
158      .map(toQuote)
159    const indexRow = rows.find((r) => r.c === 't00')
160    index = indexRow ? { ...toQuote(indexRow), name: '加權' } : null
161    lastError = ''
162    lastFetch = await $.clock.now()
163  } catch (err) {
164    lastError = String((err && err.message) || err)
165  } finally {
166    fetching = false
167    $.ui.invalidate('ui.render')
168  }
169}
170
171async function tick($, force) {
172  const now = await $.clock.now()
173  marketOpen = isMarketOpen(now)
174  if (!force && !(working || pinned)) return
175  if (!force && !marketOpen && (index || quotes.length > 0)) return
176  if (!force && now - lastFetch < POLL_MS - 5_000) return
177  await refresh($)
178}
179
180// ---------- 彈幕 ----------
181
182async function findThread($, today, keyword) {
183  let url = PTT_INDEX
184  for (let i = 0; i < 2 && url; i++) {
185    const res = await $.http.fetch(url, PTT_INIT)
186    if (!res.ok) throw new Error('PTT HTTP ' + res.status)
187    const hit = findDailyThread(res.text, today, keyword)
188    if (hit) return hit
189    url = findPrevPage(res.text)
190  }
191  return null
192}
193
194// 現在該看哪一串:盤中 → 盤中閒聊;平日收盤後 → 盤後閒聊;其他時間(開盤前、週末)不抓
195function chatKind(ms) {
196  if (isMarketOpen(ms)) return '盤中閒聊'
197  const p = twParts(ms)
198  if (p.day >= 1 && p.day <= 5 && p.minutes > 13 * 60 + 30) return '盤後閒聊'
199  return null
200}
201
202async function danmakuTick($, force) {
203  if (!danmakuOn || dmFetching) return
204  const now = await $.clock.now()
205  if (!force && !(working || pinned)) return
206  const kind = chatKind(now) || (force ? '盤中閒聊' : null)
207  if (!kind) return
208  if (!force && now - dmLastFetch < DM_POLL_MS - 5_000) return
209  dmFetching = true
210  dmLastFetch = now
211  try {
212    const key = twDate(now) + ' ' + kind
213    if (threadKey !== key) {
214      // 換日或盤中 → 盤後:清掉上一串的彈幕,不再輪播舊推文
215      threadKey = key
216      threadUrl = ''
217      seen = -1
218      pool = []
219      queue = []
220    }
221    if (!threadUrl || now - threadCheckedAt > DM_RECHECK_MS) {
222      const found = await findThread($, twDate(now), kind)
223      threadCheckedAt = now
224      if (!found) {
225        if (!threadUrl) {
226          // 盤後閒聊還沒開串就空白;盤中閒聊還沒開串才提示
227          dmStatus = kind === '盤中閒聊' ? '今天的盤中閒聊還沒出現' : ''
228          return
229        }
230      } else if (found.url !== threadUrl) {
231        threadUrl = found.url
232        seen = -1
233      }
234    }
235    const res = await $.http.fetch(threadUrl, PTT_INIT)
236    if (!res.ok) throw new Error('PTT HTTP ' + res.status)
237    const pushes = parsePushes(res.text)
238    if (seen < 0 || pushes.length < seen) {
239      pool = []
240      addToPool(pushes.slice(-POOL_SIZE))
241      enqueue(pushes.slice(-FIRST_BATCH))
242    } else {
243      enqueue(pushes.slice(seen))
244      addToPool(pushes.slice(seen))
245    }
246    seen = pushes.length
247    dmStatus = '' // 正常時不顯示則數,省空間;只有出錯或還沒開串時才顯示
248  } catch (err) {
249    dmStatus = '⚠ ' + String((err && err.message) || err)
250  } finally {
251    dmFetching = false
252    $.ui.invalidate('ui.render')
253  }
254}
255
256// ---------- 設定 ----------
257
258async function saveSettings($) {
259  await $.store.set('watchlist', watchlist)
260  await $.store.set('pinned', pinned)
261  await $.store.set('danmaku', danmakuOn)
262  await $.store.set('laneMax', laneWidth)
263  await $.store.set('blocklist', blocklist)
264}
265
266async function loadSettings($) {
267  const savedList = await $.store.get('watchlist')
268  if (Array.isArray(savedList)) watchlist = savedList
269  const savedPinned = await $.store.get('pinned')
270  if (typeof savedPinned === 'boolean') pinned = savedPinned
271  const savedDm = await $.store.get('danmaku')
272  if (typeof savedDm === 'boolean') danmakuOn = savedDm
273  const savedWidth = await $.store.get('laneMax') // 舊的 laneWidth 預設存了 80,不沿用
274  if (typeof savedWidth === 'number') laneWidth = savedWidth
275  const savedBlock = await $.store.get('blocklist')
276  if (Array.isArray(savedBlock)) blocklist = savedBlock
277}
278
279// ---------- 註冊 hooks ----------
280
281export function register(on) {
282  on('session.start', async ($, e, next) => {
283    await loadSettings($)
284
285    // 報價與推文的輪詢;tick 自己決定要不要真的發請求
286    $.clock.every(30_000, async () => {
287      await tick($, false)
288      await danmakuTick($, false)
289    })
290
291    // 彈幕動畫:只有在顯示中才移動與重畫
292    $.clock.every(FRAME_MS, () => {
293      if (!danmakuOn || !(working || pinned)) return
294      refillFromPool()
295      if (stepLanes(lanes, queue, effectiveWidth(), GAP)) $.ui.invalidate('ui.render')
296    })
297
298    if (pinned) {
299      tick($, true).catch(() => {})
300      danmakuTick($, true).catch(() => {})
301    }
302
303    try {
304      await $.command.register({
305        name: 'twstock',
306        description: '台股跑馬燈:add/remove 代號、pin 常駐、refresh 更新、danmaku 彈幕、block/unblock 過濾字詞',
307        argumentHint: '[add|remove|pin|refresh|danmaku|block|unblock] ...',
308        immediate: true,
309      })
310    } catch (err) {
311      $.ui.log('無法註冊 /twstock:' + String(err))
312    }
313    return next(e)
314  })
315
316  on('turn.start', async ($, e, next) => {
317    if (e.agentId) return next(e)
318    working = true
319    $.ui.invalidate('ui.render')
320    tick($, false).catch(() => {})
321    danmakuTick($, false).catch(() => {})
322    return next(e)
323  })
324
325  on('turn.complete', async ($, e, next) => {
326    if (e.agentId) return next(e)
327    working = false
328    $.ui.invalidate('ui.render')
329    return next(e)
330  })
331
332  on('command.run', { command: 'twstock' }, async ($, e) => {
333    const [sub = '', ...rest] = (e.args || '').trim().split(/\s+/).filter(Boolean)
334    const codes = rest.map((c) => c.toUpperCase()).filter((c) => /^[0-9A-Z]{4,6}$/.test(c))
335    const action = sub.toLowerCase()
336
337    if (action === 'add') {
338      watchlist = [...new Set([...watchlist, ...codes])]
339      await saveSettings($)
340      tick($, true).catch(() => {})
341      $.ui.toast('自選股:' + watchlist.join(', '))
342    } else if (action === 'remove' || action === 'rm') {
343      watchlist = watchlist.filter((c) => !codes.includes(c))
344      quotes = quotes.filter((q) => watchlist.includes(q.code))
345      await saveSettings($)
346      $.ui.invalidate('ui.render')
347      $.ui.toast('自選股:' + (watchlist.join(', ') || '(空)'))
348    } else if (action === 'pin') {
349      pinned = !pinned
350      await saveSettings($)
351      if (pinned) {
352        tick($, true).catch(() => {})
353        danmakuTick($, true).catch(() => {})
354      }
355      $.ui.invalidate('ui.render')
356      $.ui.toast(pinned ? '跑馬燈:常駐顯示' : '跑馬燈:只在 Claude 工作時顯示')
357    } else if (action === 'refresh') {
358      tick($, true).catch(() => {})
359      danmakuTick($, true).catch(() => {})
360      $.ui.toast('正在更新…')
361    } else if (action === 'danmaku' || action === 'dm') {
362      const w = parseInt(rest[1], 10)
363      const widthArg = (rest[0] || '').toLowerCase() === 'width'
364      if (widthArg && (rest[1] || '').toLowerCase() === 'full') {
365        laneWidth = 0
366        await saveSettings($)
367        $.ui.toast('彈幕寬度:用滿整個畫面')
368      } else if (widthArg && w >= 30 && w <= 300) {
369        laneWidth = w
370        await saveSettings($)
371        $.ui.toast('彈幕寬度上限:' + w + ' 格(不超過畫面寬度)')
372      } else {
373        danmakuOn = !danmakuOn
374        if (!danmakuOn) resetLanes()
375        await saveSettings($)
376        if (danmakuOn) danmakuTick($, true).catch(() => {})
377        $.ui.toast(danmakuOn ? '💬 彈幕:開啟' : '💬 彈幕:關閉')
378      }
379      $.ui.invalidate('ui.render')
380    } else if (action === 'block') {
381      blocklist = [...new Set([...blocklist, ...rest])]
382      queue = queue.filter((it) => !blocklist.some((w) => it.text.includes(w)))
383      pool = pool.filter((it) => !blocklist.some((w) => it.text.includes(w)))
384      await saveSettings($)
385      $.ui.toast('過濾字詞:' + blocklist.length + ' 個')
386    } else if (action === 'unblock') {
387      blocklist = blocklist.filter((w) => !rest.includes(w))
388      await saveSettings($)
389      $.ui.toast('過濾字詞:' + blocklist.length + ' 個')
390    } else {
391      $.ui.toast(
392        '自選股:' + (watchlist.join(', ') || '(空)') +
393          (pinned ? ' · 常駐' : ' · 工作時顯示') +
394          (danmakuOn ? ' · 彈幕開' : ' · 彈幕關') +
395          (lastFetch ? ' · ' + twTime(lastFetch) + ' 更新' : ''),
396      )
397    }
398    // 只用 toast 回覆,不寫進對話,Claude 不會讀到
399    return {}
400  })
401
402  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
403    if (!(working || pinned)) return next(e)
404    if (!index && quotes.length === 0 && !lastError && !danmakuOn) return next(e)
405
406    // 彈幕一行若比 band 寬,引擎會把每一段都縮短並加「…」,所以寬度要跟著 band
407    if (e.props.bodyColumns > 0) bandColumns = e.props.bodyColumns
408
409    const { Box, Text } = $.ui.resolve(e)
410
411    // 台股習慣:紅漲綠跌;指數不顯示代號
412    const items = [...(index ? [{ ...index, code: '' }] : []), ...quotes].map((q) => {
413      const arrow = q.change > 0 ? '▲' : q.change < 0 ? '▼' : '─'
414      const label = q.code ? q.code + ' ' + q.name : q.name
415      const props = {
416        children: [label + ' ' + fmt(q.last) + ' ' + arrow + Math.abs(q.pct).toFixed(2) + '%'],
417      }
418      if (q.change > 0) props.color = 'red'
419      if (q.change < 0) props.color = 'green'
420      return Text(props)
421    })
422
423    const status = lastError ? '⚠ ' + lastError : !lastFetch ? '' : marketOpen ? twTime(lastFetch) : '收盤'
424    const tail = [status, danmakuOn && dmStatus ? '💬 ' + dmStatus : ''].filter(Boolean).join(' · ')
425
426    const rows = [
427      Box({
428        flexDirection: 'row',
429        columnGap: 2,
430        children: [Text({ bold: true, children: ['📈 台股'] }), ...items, Text({ dimColor: true, children: [tail || ' '] })],
431      }),
432    ]
433
434    if (danmakuOn) {
435      for (const lane of lanes) {
436        const segs = renderLane(lane, effectiveWidth())
437        const children = segs.length
438          ? segs.map((seg) => {
439              const props = { wrap: 'truncate', children: [seg.text] }
440              if (seg.color) props.color = seg.color
441              if (seg.bold) props.bold = true
442              return Text(props)
443            })
444          : [Text({ children: [' '] })]
445        rows.push(Box({ flexDirection: 'row', children }))
446      }
447    }
448
449    // 保留其他 mod 畫在 band 上的內容
450    const others = await next(e)
451    return Box({ flexDirection: 'column', children: [...rows, others].filter(Boolean) })
452  })
453}
454
hooks/danmaku.js 172 lines
1// danmaku.js:PTT 解析與彈幕軌道的純函式(這個檔案不呼叫任何 mods API)
2
3const PTT_ORIGIN = 'https://www.ptt.cc'
4
5// ---------- HTML 小工具 ----------
6
7export function decodeEntities(s) {
8  return s.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, (m, g) => {
9    const k = g.toLowerCase()
10    if (k === 'amp') return '&'
11    if (k === 'lt') return '<'
12    if (k === 'gt') return '>'
13    if (k === 'quot') return '"'
14    if (k === 'apos') return "'"
15    if (k === 'nbsp') return ' '
16    const cp = k.startsWith('#x') ? parseInt(k.slice(2), 16) : parseInt(k.slice(1), 10)
17    return Number.isFinite(cp) ? String.fromCodePoint(cp) : m
18  })
19}
20
21function stripTags(s) {
22  return s.replace(/<[^>]*>/g, '')
23}
24
25// ---------- PTT 解析 ----------
26
27// 在看板列表頁找當天的閒聊串,ymd 例如 "2026/10/07",keyword 為「盤中閒聊」或「盤後閒聊」
28export function findDailyThread(html, ymd, keyword = '盤中閒聊') {
29  const re = /<a href="(\/bbs\/Stock\/M\.[^"]+\.html)">([^<]*)<\/a>/g
30  let found = null
31  for (const m of html.matchAll(re)) {
32    const title = decodeEntities(m[2]).trim()
33    if (title.startsWith('Re:') || title.startsWith('Fw:')) continue
34    if (title.includes(ymd) && title.includes(keyword)) {
35      found = { url: PTT_ORIGIN + m[1], title } // 取最後一個符合的
36    }
37  }
38  return found
39}
40
41// 列表頁的「‹ 上頁」連結
42export function findPrevPage(html) {
43  const m = html.match(/href="(\/bbs\/Stock\/index\d+\.html)"[^>]*>[^<]*上頁/)
44  return m ? PTT_ORIGIN + m[1] : null
45}
46
47// 解析文章裡的推文,只保留推噓符號和內容,不保留帳號
48export function parsePushes(html) {
49  const out = []
50  const re = /<div class="push">([\s\S]*?)<\/div>/g
51  for (const m of html.matchAll(re)) {
52    const tag = m[1].match(/push-tag">([^<]*)</)
53    const content = m[1].match(/push-content">([\s\S]*?)<\/span>/)
54    if (!tag || !content) continue
55    const text = decodeEntities(stripTags(content[1]))
56      .replace(/^:\s?/, '')
57      .replace(/\s+/g, ' ')
58      .trim()
59    if (!text) continue
60    out.push({ kind: tag[1].trim(), text })
61  }
62  return out
63}
64
65// ---------- 顯示寬度(中文字在終端機佔兩格) ----------
66
67export function charWidth(cp) {
68  if (cp < 32 || (cp >= 0x7f && cp < 0xa0)) return 0
69  if (cp >= 0x300 && cp <= 0x36f) return 0 // 組合用符號
70  if (cp === 0x200b || cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f)) return 0
71  if (
72    (cp >= 0x1100 && cp <= 0x115f) ||
73    (cp >= 0x2e80 && cp <= 0xa4cf) ||
74    (cp >= 0xac00 && cp <= 0xd7a3) ||
75    (cp >= 0xf900 && cp <= 0xfaff) ||
76    (cp >= 0xfe30 && cp <= 0xfe4f) ||
77    (cp >= 0xff00 && cp <= 0xff60) ||
78    (cp >= 0xffe0 && cp <= 0xffe6) ||
79    (cp >= 0x1f300 && cp <= 0x1faff) ||
80    (cp >= 0x20000 && cp <= 0x3fffd)
81  ) {
82    return 2
83  }
84  return 1
85}
86
87export function strWidth(s) {
88  let w = 0
89  for (const ch of s) w += charWidth(ch.codePointAt(0))
90  return w
91}
92
93// 截到最多 maxW 格,超過的話結尾加 …
94export function clipWidth(s, maxW) {
95  if (strWidth(s) <= maxW) return s
96  let out = ''
97  let w = 0
98  for (const ch of s) {
99    const cw = charWidth(ch.codePointAt(0))
100    if (w + cw > maxW - 1) break
101    out += ch
102    w += cw
103  }
104  return out + '…'
105}
106
107// 取出字串第 from 到 to 格(不含 to),被切一半的寬字元補空白
108function sliceCols(s, from, to) {
109  let out = ''
110  let col = 0
111  for (const ch of s) {
112    const w = charWidth(ch.codePointAt(0))
113    const a = col
114    const b = col + w
115    col = b
116    if (b <= from && w > 0) continue
117    if (a >= to) break
118    if (a >= from && b <= to) out += ch
119    else out += ' '.repeat(Math.max(0, Math.min(b, to) - Math.max(a, from)))
120  }
121  return out
122}
123
124// ---------- 彈幕軌道 ----------
125
126// item: { text, w, color, bold, x },x 是左緣所在的欄位
127export function renderLane(lane, width) {
128  const segs = []
129  let col = 0
130  const sorted = [...lane].sort((a, b) => a.x - b.x)
131  for (const it of sorted) {
132    const start = Math.max(it.x, col)
133    if (start >= width) break
134    const end = Math.min(it.x + it.w, width)
135    if (end <= start) continue
136    if (start > col) segs.push({ text: ' '.repeat(start - col) })
137    segs.push({ text: sliceCols(it.text, start - it.x, end - it.x), color: it.color, bold: it.bold })
138    col = end
139  }
140  return segs
141}
142
143// 每一幀:全部往左移一格、移除跑出畫面的、最多補一則新的進來
144// 回傳 true 代表畫面有變,需要重畫
145export function stepLanes(lanes, queue, width, gap) {
146  let changed = false
147  for (let i = 0; i < lanes.length; i++) {
148    if (lanes[i].length === 0) continue
149    for (const it of lanes[i]) it.x -= 1
150    lanes[i] = lanes[i].filter((it) => it.x + it.w > 0)
151    changed = true
152  }
153  if (queue.length > 0) {
154    // 找右邊空間最多的軌道放新彈幕,讓各軌道錯開
155    let best = -1
156    let bestRoom = -Infinity
157    for (let i = 0; i < lanes.length; i++) {
158      const last = lanes[i].reduce((a, b) => (!a || b.x > a.x ? b : a), null)
159      const room = last ? width - (last.x + last.w) : Infinity
160      if (room >= gap && room > bestRoom) {
161        best = i
162        bestRoom = room
163      }
164    }
165    if (best >= 0) {
166      lanes[best].push({ ...queue.shift(), x: width })
167      changed = true
168    }
169  }
170  return changed
171}
172