SLOPSHOPPER

ptt

在 Claude Code 面板裡滑 PTT,可自訂看板,附老闆鍵

newpanecommandnetwork
v0.1.1MITupdated 2026-10-06chenyao0910/claude-ptt-mod/plugins/ptt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ptt
│ ┃ PTT ✕ › fix the failing auth test and add an audit log call │ ┃ PTT 批踢踢實業坊 › Stock │ ┃ ⏺ Read(src/auth.ts) │ ┃ 看板 [ Stock ] | Gossiping | Tech_Job | Soft ⎿ Read 6 lines │ ┃ ──────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ──────────── ⎿ Added 2 lines, removed 1 line │ ┃ 操作 [ 重新整理 ] [ 管理看板 ] [ 老闆鍵 ] ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ 讀取失敗 (HTTP 0) │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ptt │ ⎿ ptt: PTT 面板已開啟:Stock 板。面板取得焦點後(ctrl+x tab):b 老闆鍵、r 重新整理、n 載入更舊、h │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · PTT
PTT 批踢踢實業坊 › Stock 看板 [ Stock ] | Gossiping | Tech_Job | Soft_Job | DigiCurre ──────────────────────────────────────────────────────── 操作 [ 重新整理 ] [ 管理看板 ] [ 老闆鍵 ] 讀取失敗 (HTTP 0)
README

🐟 claude-ptt-mod

一個 Claude Code mod:在 Claude Code 裡面開一個 PTT 面板,邊跟 Claude 寫 code 邊滑 PTT。

Claude Code mod Claude Code 2.1.289+ License: MIT

🌐 網站:<https://chenyao0910.github.io/claude-ptt-mod/>(可以在網頁上試玩老闆鍵)

Claude 在跑測試、改檔案的時候,你不用切到瀏覽器:面板就在對話旁邊,看看盤後閒聊、刷一下八卦。老闆經過?按 b,整個面板瞬間變成一堆編譯 log。

左邊是 Claude Code 的對話,右邊是 PTT 面板

<sub>實際使用畫面:左邊照常跟 Claude 對話,右邊的 PTT 面板正在看八卦板文章。截圖中的使用者資訊和推文 IP 已遮蔽。</sub>

什麼是 Claude Code mod?

Claude Code 的 plugin 除了能提供 skill 和指令,還能用 function hooks 直接改 Claude Code 自己的介面:畫面板(pane)、在輸入框上方加一列、顯示 toast、改狀態列、攔截工具呼叫等等。這類「改 Claude Code 本身」的 plugin 就叫 mod。

這個 mod 用到的功能:

Claude Code 功能在這個 mod 裡的用途
面板(ui.render 的 Pane)PTT 文章列表、文章內頁、老闆鍵畫面
斜線指令(command.register)/ptt、/ptt add、/ptt remove
面板快捷鍵(Button hotkey)b 老闆鍵、r 重新整理……
跨 session 儲存($.store)記住你自訂的看板清單
網路請求($.http.fetch)讀取 PTT 網頁版

程式全部在 plugins/ptt/hooks/,大約 300 行 TypeScript,想學怎麼寫 mod 可以從這裡看起。

功能

  • 看板文章列表:推文數上色(爆 / 10+ / 一般 / X),分類標籤上色,一路往前載入更舊的文章
  • 文章閱讀:內文、推 / 噓統計、推文列表
  • 自訂看板:新增、移除、恢復預設,關掉 Claude Code 再開還在
  • 老闆鍵:一鍵把面板換成一堆看起來很忙的編譯和測試 log
  • 免登入,八卦版的「已滿 18 歲」確認自動處理

教學

1. 確認 Claude Code 版本

claude --version

需要 2.1.289 以上,舊版不支援 mod 的面板功能。太舊的話先執行 claude update。

2. 安裝

在終端機執行(不是在 Claude Code 對話裡):

claude plugin marketplace add chenyao0910/claude-ptt-mod
claude plugin install ptt@claude-ptt-mod

第一行把這個 repo 加成 plugin 來源,第二行安裝裡面的 ptt mod。

3. 打開 Claude Code,叫出面板

照平常一樣啟動 claude。如果 Claude Code 本來就開著,先在對話裡執行 /reload-plugins。

在輸入框打:

/ptt

面板會出現在 Claude Code 裡,顯示 Stock 板的最新文章。想直接看別的板:

/ptt Gossiping

💡 終端機寬一點比較好看,建議 144 欄以上,面板才放得下又不擠到對話。

4. 看文章

  • 滑鼠:點文章標題就會打開,點「← 回列表」回去
  • 鍵盤:先按 ctrl+x 再按 tab,讓面板取得焦點,接著就能用快捷鍵
鍵作用
b老闆鍵(再按一次切回來)
r重新整理
n載入更舊的文章
h從文章回到列表
e管理看板

面板有焦點時,你打的字會給面板而不是對話。想回去跟 Claude 講話,點一下輸入框,或再按一次 ctrl+x tab 切換。

5. 換成你常逛的看板

預設看板是 Stock、Gossiping、Tech_Job、Soft_Job、DigiCurrency、Lifeismoney。改成你自己的:

方法 A:在面板裡改

  1. 按「管理看板」(或快捷鍵 e)
  2. 不要的看板按「✕ 移除」
  3. 在「新增看板」輸入英文板名(例如 NBA),按 Enter
  4. 按「完成」

方法 B:用指令

/ptt add NBA
/ptt remove Soft_Job

板名不分大小寫,打 nba 會自動修正成 PTT 上的正式名稱 NBA;打錯的板名會直接告訴你找不到。改過的清單會存下來,下次開 Claude Code 還在。

6. 老闆來了

按 b(或面板上的「老闆鍵」),面板立刻變成這樣:

老闆鍵畫面

再按一次 b,或點最下面那個灰色的 _,就會回到剛剛看的地方。

7. 更新與移除

claude plugin update ptt@claude-ptt-mod      # 更新到最新版
claude plugin uninstall ptt@claude-ptt-mod   # 移除

畫面

看板文章列表

文章內頁管理看板
文章內頁:標題、作者、內文、推噓統計與推文管理看板:移除、新增、恢復預設

面板的圖是照實際排版畫的,列表和文章用的是 PTT Stock 板的實際資料。實際顏色會依你的終端機配色而不同。

限制

  • 只能看,不能推文或發文
  • 面板一次能畫的字數有限:內文超過 6000 字會截斷,推文只顯示最後 150 則
  • 資料來自 PTT 網頁版 www.ptt.cc,只在你打開看板或文章時才抓。PTT 改版的話解析可能會壞,歡迎開 issue

開發

claude plugin validate plugins/ptt   # 檢查 manifest 和 hooks
claude plugin test plugins/ptt       # 跑測試
claude --plugin-dir ./plugins/ptt    # 不安裝,直接載入本機版本試跑

用 --plugin-dir 載入時,改完存檔 mod 會自動重新載入,不用重開 Claude Code。

重新產生 README 的圖片(用 PTT 現在的資料):

npx tsx scripts/dump-live.mts Stock > /tmp/live.json
python3 scripts/make-screenshots.py /tmp/live.json

授權

MIT

Source 3 files
hooks/register.tsx 355 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { Article, Mode, Post } from '../types'
5import { parseArticle, parseIndex } from './parse'
6
7const PANE = 'ptt'
8const TITLE = 'PTT'
9const HOST = 'https://www.ptt.cc'
10// 面板一次最多畫約 10 萬字元,長文和爆文要截斷
11const MAX_BODY = 6000
12const MAX_PUSHES = 150
13const MAX_POSTS = 200
14const DEFAULT_BOARDS = ['Stock', 'Gossiping', 'Tech_Job', 'Soft_Job', 'DigiCurrency', 'Lifeismoney']
15// 看板清單存在 $.store,關掉 Claude Code 再開還在
16const STORE_BOARDS = 'boards'
17
18const board = atom({ plugin: 'ptt', key: 'board' } as const, 'Stock')
19const boards = atom({ plugin: 'ptt', key: 'boards' } as const, DEFAULT_BOARDS)
20const editing = atom({ plugin: 'ptt', key: 'editing' } as const, false)
21const posts = atom({ plugin: 'ptt', key: 'posts' } as const, [] as Post[])
22const prevPage = atom({ plugin: 'ptt', key: 'prevPage' } as const, '')
23const article = atom({ plugin: 'ptt', key: 'article' } as const, null as Article | null)
24const mode = atom({ plugin: 'ptt', key: 'mode' } as const, 'list' as Mode)
25const lastMode = atom({ plugin: 'ptt', key: 'lastMode' } as const, 'list' as Mode)
26const status = atom({ plugin: 'ptt', key: 'status' } as const, '')
27
28async function get($: Engine, path: string): Promise<string | undefined> {
29  const res = await $.http.fetch(HOST + path, { headers: { Cookie: 'over18=1' } })
30  if (!res.ok) {
31    await update($, status, () => `讀取失敗 (HTTP ${res.status})`)
32    return undefined
33  }
34  return res.text
35}
36
37async function loadBoard($: Engine, name: string, page?: string) {
38  await update($, status, () => '讀取中…')
39  const html = await get($, page ?? `/bbs/${name}/index.html`)
40  if (html === undefined) return
41  const parsed = parseIndex(html)
42  await update($, board, () => parsed.boardName || name)
43  // 翻舊頁時接在後面,方便一路往下滑
44  await update($, posts, list => (page ? [...list, ...parsed.posts] : parsed.posts).slice(0, MAX_POSTS))
45  await update($, prevPage, () => parsed.prevPage)
46  await update($, mode, () => 'list')
47  await update($, status, () => '')
48}
49
50async function openArticle($: Engine, url: string) {
51  await update($, status, () => '讀取中…')
52  const html = await get($, url)
53  if (html === undefined) return
54  await update($, article, () => parseArticle(url, html))
55  await update($, mode, () => 'article')
56  await update($, status, () => '')
57}
58
59async function toggleBoss($: Engine) {
60  const now = await read($, mode)
61  if (now === 'boss') {
62    const back = await read($, lastMode)
63    await update($, mode, () => back)
64  } else {
65    await update($, lastMode, () => now)
66    await update($, mode, () => 'boss')
67  }
68}
69
70const sameBoard = (a: string, b: string) => a.toLowerCase() === b.toLowerCase()
71
72async function saveBoards($: Engine, list: string[]) {
73  await update($, boards, () => list)
74  await $.store.set(STORE_BOARDS, list)
75}
76
77// 回傳給使用者看的結果訊息
78async function addBoard($: Engine, raw: string): Promise<string> {
79  const name = raw.trim()
80  const list = await read($, boards)
81  let msg: string
82  if (!/^[A-Za-z0-9_-]+$/.test(name)) {
83    msg = '看板名稱只能有英文、數字、底線和減號,例如 NBA、C_Chat'
84  } else if (list.some(b => sameBoard(b, name))) {
85    msg = `已經有 ${list.find(b => sameBoard(b, name))} 了`
86  } else {
87    const res = await $.http.fetch(`${HOST}/bbs/${name}/index.html`, { headers: { Cookie: 'over18=1' } })
88    if (res.status === 404) {
89      msg = `PTT 上找不到 ${name} 這個看板`
90    } else if (!res.ok) {
91      msg = `確認看板時連線失敗 (HTTP ${res.status}),稍後再試`
92    } else {
93      const canonical = parseIndex(res.text).boardName || name
94      await saveBoards($, [...list, canonical])
95      msg = `已新增 ${canonical}`
96    }
97  }
98  await update($, status, () => msg)
99  return msg
100}
101
102async function removeBoard($: Engine, raw: string): Promise<string> {
103  const list = await read($, boards)
104  const target = list.find(b => sameBoard(b, raw.trim()))
105  const msg = target ? `已移除 ${target}` : `清單裡沒有 ${raw.trim()}`
106  if (target) await saveBoards($, list.filter(b => b !== target))
107  await update($, status, () => msg)
108  return msg
109}
110
111async function resetBoards($: Engine) {
112  await saveBoards($, DEFAULT_BOARDS)
113  await update($, status, () => '已恢復預設看板')
114}
115
116function nrecColor(n: string): string | undefined {
117  if (n === '爆') return 'red'
118  if (n.startsWith('X')) return 'gray'
119  const v = Number(n)
120  if (v >= 10) return 'yellow'
121  if (v > 0) return 'green'
122  return undefined
123}
124
125// 標題拆成「Re: / Fw:」、分類標籤、其餘文字,標籤依分類上色
126function splitTitle(title: string): { prefix: string; tag: string; rest: string } {
127  const m = title.match(/^((?:Re|Fw|R|轉):\s*)?\[([^\]]{1,6})\]\s*(.*)$/i)
128  if (!m) return { prefix: '', tag: '', rest: title }
129  return { prefix: (m[1] ?? '').trim(), tag: m[2] ?? '', rest: m[3] || title }
130}
131
132const TAG_COLORS: Record<string, string> = {
133  新聞: 'cyan',
134  標的: 'magenta',
135  請益: 'green',
136  問卦: 'green',
137  情報: 'yellow',
138  心得: 'blue',
139  閒聊: 'blueBright',
140  討論: 'blueBright',
141  爆卦: 'red',
142  公告: 'red',
143  分享: 'yellowBright',
144}
145const tagColor = (tag: string) => TAG_COLORS[tag.trim()] ?? 'white'
146
147function statusColor(msg: string): string {
148  if (msg.startsWith('讀取中')) return 'yellow'
149  if (msg.startsWith('已')) return 'green'
150  return 'red'
151}
152
153const pushColor = (tag: string) => (tag.startsWith('推') ? 'green' : tag.startsWith('噓') ? 'red' : undefined)
154
155// 老闆鍵畫面:看起來很忙的假 log
156const FAKE_LOG = [
157  '[info]  webpack 5.98.0 compiled successfully in 2384 ms',
158  '[info]  ✓ 412 modules transformed.',
159  '[debug] resolving dependency graph for @internal/payment-gateway',
160  '[info]  running migration 20261006_add_index_on_orders ... ok',
161  '[warn]  deprecated: Buffer() is deprecated, use Buffer.from()',
162  '[info]  PASS  src/services/__tests__/ledger.test.ts (3.214 s)',
163  '[info]  PASS  src/services/__tests__/settlement.test.ts (2.871 s)',
164  '[debug] cache hit ratio 0.93 (hits=18231, misses=1374)',
165  '[info]  Tests: 128 passed, 128 total',
166  '[info]  watching for file changes...',
167]
168
169export const register: Register = on => {
170  on('session.start', async ($, e, next) => {
171    await $.command.register({
172      name: 'ptt',
173      description: '在面板裡滑 PTT。/ptt 看板名 直接開;/ptt add 看板名、/ptt remove 看板名 管理看板',
174    })
175    const saved = await $.store.get(STORE_BOARDS)
176    if (Array.isArray(saved) && saved.every(b => typeof b === 'string')) await update($, boards, () => saved as string[])
177    return next(e)
178  })
179
180  on('command.run', { command: 'ptt' }, async ($, e) => {
181    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
182    const arg = rest.join(' ')
183    if (verb === 'add' || verb === 'remove' || verb === 'rm') {
184      if (arg === '') return { text: `用法:/ptt ${verb} 看板名稱` }
185      return { text: verb === 'add' ? await addBoard($, arg) : await removeBoard($, arg) }
186    }
187    const name = verb || (await read($, board))
188    await $.ui.open({ id: PANE, title: TITLE })
189    await loadBoard($, name)
190    const shown = await read($, board)
191    return { text: `PTT 面板已開啟:${shown} 板。面板取得焦點後(ctrl+x tab):b 老闆鍵、r 重新整理、n 載入更舊、h 回列表、e 管理看板。` }
192  })
193
194  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
195    const ui = $.ui.resolve(e)
196    const { Box, Text, Button, Markdown } = ui
197    const m = await read($, mode)
198    const msg = await read($, status)
199
200    if (m === 'boss') {
201      return (
202        <Box flexDirection="column">
203          {FAKE_LOG.map(line => (
204            <Text dimColor={line.startsWith('[debug]')} color={line.startsWith('[warn]') ? 'yellow' : undefined}>
205              {line}
206            </Text>
207          ))}
208          <Button key="boss" plain dimColor hotkey="b" label="_" onPress={() => void toggleBoss($)} />
209        </Box>
210      )
211    }
212
213    const name = await read($, board)
214    const list = await read($, boards)
215    const isEditing = await read($, editing)
216    const width = Math.max(20, Math.min(e.props.bodyColumns, 100))
217    const divider = <Text color="blue" dimColor>{'─'.repeat(width)}</Text>
218
219    const header = (
220      <Box key="header" flexDirection="row" columnGap={1}>
221        <Text backgroundColor="blue" color="white" bold>{' PTT '}</Text>
222        <Text dimColor>批踢踢實業坊 ›</Text>
223        <Text color="yellow" bold>{m === 'article' ? `${name} › 文章` : name}</Text>
224      </Box>
225    )
226
227    const statusLine = msg !== '' && <Text color={statusColor(msg)}>{msg}</Text>
228
229    if (m === 'article') {
230      const a = await read($, article)
231      if (a) {
232        const good = a.pushes.filter(p => p.tag.startsWith('推')).length
233        const bad = a.pushes.filter(p => p.tag.startsWith('噓')).length
234        const body = a.body.length > MAX_BODY ? `${a.body.slice(0, MAX_BODY)}\n…(內文太長,後面省略)` : a.body
235        const shown = a.pushes.slice(-MAX_PUSHES)
236        const t = splitTitle(a.title)
237        return (
238          <Box flexDirection="column" gap={1}>
239            {header}
240            <Box key="nav" flexDirection="row" columnGap={1}>
241              <Button key="back" hotkey="h" variant="primary" label="← 回列表" onPress={() => void update($, mode, () => 'list')} />
242              <Button key="boss" hotkey="b" variant="secondary" label="老闆鍵" onPress={() => void toggleBoss($)} />
243            </Box>
244            <Box key="head" flexDirection="column" borderStyle="round" borderColor="blue" paddingX={1}>
245              <Box key="title-row" flexDirection="row" columnGap={1}>
246                {t.prefix !== '' && <Text dimColor>{t.prefix}</Text>}
247                {t.tag !== '' && <Text color={tagColor(t.tag)} bold>{`[${t.tag}]`}</Text>}
248                <Text bold>{t.rest}</Text>
249              </Box>
250              <Box key="meta-row" flexDirection="row" columnGap={1}>
251                <Text dimColor>作者</Text>
252                <Text color="cyan">{a.author}</Text>
253                <Text dimColor>{`· ${a.time}`}</Text>
254              </Box>
255            </Box>
256            <Text wrap="wrap">{body}</Text>
257            {divider}
258            <Box key="score" flexDirection="row" columnGap={1}>
259              <Text color="green" bold>{`推 ${good}`}</Text>
260              <Text color="red" bold>{`噓 ${bad}`}</Text>
261              <Text dimColor>{`共 ${a.pushes.length} 則${shown.length < a.pushes.length ? `(只顯示最後 ${shown.length} 則)` : ''}`}</Text>
262            </Box>
263            <Box key="pushes" flexDirection="column">
264              {shown.map((p, i) => (
265                <Box key={`push${i}`} flexDirection="row" columnGap={1}>
266                  <Text color={pushColor(p.tag)} bold>{p.tag}</Text>
267                  <Text color="yellow">{p.user}</Text>
268                  <Text wrap="wrap">{p.text}</Text>
269                  <Text dimColor>{p.time}</Text>
270                </Box>
271              ))}
272            </Box>
273          </Box>
274        )
275      }
276    }
277
278    // 看板一列、操作一列,中間用線分開
279    const boardRow = (
280      <Box key="boards" flexDirection="row" flexWrap="wrap" columnGap={1}>
281        <Text color="cyan" bold>看板</Text>
282        {list.flatMap((b, i) => [
283          ...(i > 0 ? [<Text dimColor>|</Text>] : []),
284          <Button
285            key={`board-${b}`}
286            variant={b === name ? 'primary' : undefined}
287            plain={b === name ? undefined : true}
288            label={b}
289            onPress={() => void loadBoard($, b)}
290          />,
291        ])}
292      </Box>
293    )
294    const actionRow = (
295      <Box key="actions" flexDirection="row" flexWrap="wrap" columnGap={1}>
296        <Text color="magenta" bold>操作</Text>
297        <Button key="refresh" hotkey="r" variant="secondary" label="重新整理" onPress={() => void loadBoard($, name)} />
298        <Button key="edit" hotkey="e" variant={isEditing ? 'primary' : 'secondary'} label={isEditing ? '完成' : '管理看板'} onPress={() => void update($, editing, v => !v)} />
299        <Button key="boss" hotkey="b" variant="secondary" label="老闆鍵" onPress={() => void toggleBoss($)} />
300      </Box>
301    )
302
303    const manage = isEditing && (
304      <Box key="manage" flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
305        <Text color="cyan" bold>管理看板</Text>
306        {list.length === 0 && <Text dimColor>清單是空的,新增一個看板吧</Text>}
307        {list.map(b => (
308          <Box key={`row-${b}`} flexDirection="row" columnGap={1}>
309            <Text color="yellow">{b}</Text>
310            <Button key={`remove-${b}`} plain label="✕ 移除" hover={{ color: 'red', bold: true }} onPress={() => void removeBoard($, b)} />
311          </Box>
312        ))}
313        {'Input' in ui ? (
314          <ui.Input key="add-board" label="新增看板" placeholder="輸入看板英文名稱,例如 NBA,按 Enter" onSubmit={(v: string) => void addBoard($, v)} />
315        ) : (
316          <Text dimColor>新增看板:輸入 /ptt add 看板名稱</Text>
317        )}
318        <Button key="reset" plain dimColor label="恢復預設看板" onPress={() => void resetBoards($)} />
319      </Box>
320    )
321
322    const items = await read($, posts)
323    const older = await read($, prevPage)
324    return (
325      <Box flexDirection="column" gap={1}>
326        {header}
327        <Box key="controls" flexDirection="column">
328          {boardRow}
329          {divider}
330          {actionRow}
331        </Box>
332        {manage}
333        {statusLine}
334        <Box key="list" flexDirection="column">
335          {items.length === 0 && msg === '' && <Markdown key="empty" text="_還沒載入,按上面的看板或「重新整理」_" />}
336          {items.map(p => {
337            const t = splitTitle(p.title)
338            return (
339              <Box key={p.url} flexDirection="row" columnGap={1}>
340                <Text color={nrecColor(p.nrec)} bold>{p.nrec.padStart(2, ' ') || '  '}</Text>
341                {t.prefix !== '' && <Text dimColor>{t.prefix}</Text>}
342                {t.tag !== '' && <Text color={tagColor(t.tag)}>{`[${t.tag}]`}</Text>}
343                <Button key={`open-${p.url}`} plain label={t.rest} hover={{ color: 'cyan', bold: true }} onPress={() => void openArticle($, p.url)} />
344                <Text color="cyan" dimColor wrap="truncate">{p.author}</Text>
345                <Text dimColor>{p.date}</Text>
346              </Box>
347            )
348          })}
349        </Box>
350        {older !== '' && <Button key="older" hotkey="n" variant="secondary" label="載入更舊的文章" onPress={() => void loadBoard($, name, older)} />}
351      </Box>
352    )
353  })
354}
355
hooks/parse.ts 63 lines
1import type { Article, Post, Push } from '../types'
2
3const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', lsaquo: '‹', rsaquo: '›' }
4
5export function decode(s: string): string {
6  return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, code: string) => {
7    if (code[0] === '#') return String.fromCodePoint(code[1] === 'x' || code[1] === 'X' ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10))
8    return ENTITIES[code] ?? m
9  })
10}
11
12const stripTags = (s: string) => decode(s.replace(/<[^>]*>/g, ''))
13
14export function parseIndex(html: string): { posts: Post[]; prevPage: string; boardName: string } {
15  // 置底公告在 r-list-sep 之後,略過
16  const listPart = html.split('<div class="r-list-sep"></div>')[0] ?? html
17  const posts: Post[] = []
18  for (const chunk of listPart.split('<div class="r-ent">').slice(1)) {
19    const link = chunk.match(/<a href="(\/bbs\/[^"]+\.html)">([\s\S]*?)<\/a>/)
20    if (!link) continue // 已刪除的文章沒有連結
21    posts.push({
22      url: link[1] ?? '',
23      title: stripTags(link[2] ?? '').trim(),
24      nrec: stripTags(chunk.match(/<div class="nrec">([\s\S]*?)<\/div>/)?.[1] ?? '').trim(),
25      author: (chunk.match(/<div class="author">([^<]*)/)?.[1] ?? '').trim(),
26      date: (chunk.match(/<div class="date">([^<]*)/)?.[1] ?? '').trim(),
27    })
28  }
29  const prevPage = html.match(/href="([^"]+)">&lsaquo; 上頁/)?.[1] ?? ''
30  // 看板的正式名稱(大小寫以 PTT 為準)
31  const boardName = html.match(/<a class="board" href="\/bbs\/([^/]+)\/index\.html">/)?.[1] ?? ''
32  return { posts: posts.reverse(), prevPage, boardName }
33}
34
35// 有些看板(例如八卦板)推文會附 IP,面板只顯示日期時間
36export function pushTime(raw: string): string {
37  return raw.match(/\d{1,2}\/\d{1,2}(?:\s+\d{1,2}:\d{2})?\s*$/)?.[0].trim() ?? raw
38}
39
40export function parseArticle(url: string, html: string): Article {
41  const start = html.indexOf('<div id="main-content"')
42  const main = start >= 0 ? html.slice(start) : html
43  const meta = [...main.matchAll(/<span class="article-meta-value">([^<]*)<\/span>/g)].map(m => decode(m[1] ?? ''))
44  const firstPush = main.indexOf('<div class="push">')
45  const bodyHtml = (firstPush >= 0 ? main.slice(0, firstPush) : main)
46    .replace(/<div class="article-metaline(?:-right)?">[\s\S]*?<\/div>/g, '')
47    .replace(/<div id="main-content"[^>]*>/, '')
48  const body = stripTags(bodyHtml).replace(/\n{3,}/g, '\n\n').trim()
49  const pushes: Push[] = [...main.matchAll(/<div class="push">([\s\S]*?)<\/div>/g)].map(m => {
50    const p = m[1] ?? ''
51    const span = (cls: string) => stripTags(p.match(new RegExp(`<span[^>]*${cls}[^>]*>([\\s\\S]*?)</span>`))?.[1] ?? '').trim()
52    return { tag: span('push-tag'), user: span('push-userid'), text: span('push-content').replace(/^:\s*/, ''), time: pushTime(span('push-ipdatetime')) }
53  })
54  return {
55    url,
56    author: meta[0] ?? '',
57    title: meta[2] ?? meta[1] ?? '',
58    time: meta[3] ?? '',
59    body,
60    pushes,
61  }
62}
63
types/index.d.ts 21 lines
1export type Post = { url: string; title: string; nrec: string; author: string; date: string }
2export type Push = { tag: string; user: string; text: string; time: string }
3export type Article = { url: string; title: string; author: string; time: string; body: string; pushes: Push[] }
4export type Mode = 'list' | 'article' | 'boss'
5
6declare module 'claude-code' {
7  interface PluginState {
8    ptt: {
9      board: string
10      boards: string[]
11      editing: boolean
12      posts: Post[]
13      prevPage: string
14      article: Article | null
15      mode: Mode
16      lastMode: Mode
17      status: string
18    }
19  }
20}
21