iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0

「正則表達式抓得到長得像 Wikilink 的文字,但分不出它是不是躲在程式碼區塊裡——真正安全的做法,是先讓 goldmark 把正文解析成 AST,再談怎麼抓連結。」

今天的實作範圍很聚焦:讓 internal/vault.ParseRawNote 在切出正文字串之後,額外用 goldmark 把正文解析成 ast.Node 節點樹,並新增 Note.BodyASTNote.BodySource 兩個欄位承接解析結果;原本的 Note.Body 字串欄位維持不動。這一步本身不做任何 Wikilink 或標題的抽取邏輯,純粹是把「結構化理解正文」這個地基先打好,留給接下來幾天使用。

正則表達式的破綻:程式碼區塊裡的假 Wikilink

Day 06 封存後,Note.Body 從頭到尾只是一段不透明的字串。接下來 Day 09 要精準抓出 [[Wikilink]]、Day 10 要建立全庫標題/別名索引、Day 11 要偵測孤立筆記與斷鏈——這些工作如果各自在原始字串上跑正則表達式,會撞上同一個結構性問題。想像一篇筆記長這樣:

用法範例:
```
連結語法是 [[某篇筆記]],標題語法是 # 標題
```

程式碼區塊裡的文字並不是真正的 Wikilink 或標題,但 \[\[(.+?)\]\]^#{1,6}\s 這類正則表達式並不知道自己「目前在不在程式碼區塊裡」,照樣會把它們抓出來,產生錯誤的雙向連結或錯誤的標題索引。要正確排除這種情境,勢必得先知道「哪些文字屬於程式碼區塊」——這正是 AST(而不是原始字串)能提供的資訊。

Segment 與 BodySource:AST 節點不自己保存文字

goldmark 的節點(例如 *ast.Text)內部用 text.Segment(一組起訖 byte 位移)指向原始輸入,而不是自己複製一份字串——這是 goldmark 為了避免大量記憶體複製的設計。也就是說,AST 節點樹脫離「解析時用的那份原始 byte slice」就無法取出實際文字。所以 Note 除了 BodyAST 之外,還得額外保存 BodySource

// Note 代表一篇已解析的筆記。
type Note struct {
	FilePath    string
	Frontmatter Frontmatter
	Body        string
	RawContent  string
	// BodyAST 是 Body 以 goldmark 預設 CommonMark parser 解析後的根節點。
	BodyAST ast.Node
	// BodySource 是 BodyAST 節點內部 Segment 取出文字內容時所需的原始 byte slice。
	BodySource []byte
}

解析本身只是一行:

// ParseBodyAST 以 goldmark 預設設定的 CommonMark parser,將正文 byte slice 解析為 AST 節點樹。
// goldmark 的預設 parser 對任何合法 UTF-8 輸入都不會解析失敗,未定義語法會被當成一般文字節點處理。
func ParseBodyAST(body []byte) ast.Node {
	return goldmark.DefaultParser().Parse(text.NewReader(body))
}

後續要拿到某個節點對應的文字,呼叫 segment.Value(note.BodySource)(區塊節點則是 node.Lines().Value(note.BodySource))即可——這也是為什麼 BodySource 一定要跟著 BodyAST 一起保存,兩者缺一不可。

Body string 與 BodyAST 並存,而非取代

ParseRawNote 切出正文字串後,直接內建呼叫 ParseBodyAST,把兩種表示一起塞進回傳的 Note

bodySource := []byte(body)

return &Note{
	FilePath:    filePath,
	Frontmatter: fm,
	Body:        body,
	RawContent:  string(rawContent),
	BodyAST:     ParseBodyAST(bodySource),
	BodySource:  bodySource,
}, nil

這裡刻意不是「額外提供一個獨立函式,讓呼叫端自己決定要不要解析 AST」。如果 AST 解析是選擇性的,brain scan 或未來的呼叫端就必須記得多呼叫一次,很容易遺漏;而 goldmark 的預設 parser 對任何合法 UTF-8 輸入都不會解析失敗(未定義語法一律被當成一般文字),所以內建進 ParseRawNote 不會替呼叫端增加新的錯誤處理負擔,函式簽章 (*Note, error) 完全不變。

另一方面,Body string 也刻意保留、沒有被取代。Day 07 的 capture.RenderWrite 直接組字串寫檔,brain scan 的輸出也直接印字串欄位——這些既有行為不需要,也不應該被要求先建 AST 再轉回字串。字串與 AST 是同一份正文內容的兩種表示,服務不同用途:字串給「序列化/顯示」,AST 給「結構化分析」。

實機驗證:程式碼區塊不會被誤判

用前面那個「程式碼區塊裡藏假標題、假 Wikilink」的例子實際跑一次 ParseBodyAST,走訪整棵樹並印出每個節點的 Kind()

source := []byte("```\n# 看起來像標題\n[[看起來像 Wikilink]]\n```\n")
root := vault.ParseBodyAST(source)
ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
	if entering {
		fmt.Println(n.Kind())
	}
	return ast.WalkContinue, nil
})

輸出只有兩個節點:

Document
FencedCodeBlock

整段內容被收斂成單一的 FencedCodeBlock 節點,AST 樹裡完全沒有出現 Heading 節點——# 看起來像標題[[看起來像 Wikilink]]都只是這個程式碼區塊節點內容的一部分,不會被拆解成一般段落文字或標題。對照組是一段真正的標題加段落:

source := []byte("# 標題\n\n一般文字段落。\n")

這種情況下 AST 就會正確產出 HeadingParagraph 兩個節點。測試也涵蓋了巢狀情境——清單項目內包著一段 fenced 程式碼區塊,確認 ast.Walk 仍然能走訪到每一層,這是 Day 09「跳過程式碼區塊」抓 Wikilink 邏輯能成立的前提。go test ./...gofmt -l .go vet ./... 全數通過;brain scanbrain capture 兩個既有指令重新跑過,行為與輸出格式都沒有變化。

銜接 Day 09

AST 並不能完全消除字串比對——goldmark 遇到 [[Wikilink]] 這種非標準 CommonMark 語法,預設還是會把它當成一般文字節點(ast.Text)內容的一部分,不會產生專屬的節點類型。AST 的價值在於「排除誤判」,不是「完全取代字串比對」:先用 ast.Walk 跳過程式碼區塊等節點,只在真正的段落文字節點內容上做 Wikilink 字串比對,範圍就大幅縮小、也不會誤觸程式碼區塊。

結語

今天沒有加任何新指令、也沒有改變任何既有輸出——brain scan 印出來的東西跟昨天一模一樣。改變的是內部:Note 現在同時具備字串與 AST 兩種正文表示,且透過 ParseRawNote 自動產生,未來的 Day 不需要重新設計解析層,直接讀 note.BodyAST 就好。

👉 明天 Day 09,我們會在這棵 AST 上動手,走訪節點樹跳過程式碼區塊,精準抓出真正的 [[Wikilink]]。我們明天見!


上一篇
零阻力收集(Capture):實作 brain capture 終端指令
下一篇
精準語法提取:用 AST Walking 抓取 [[Wikilink]] 雙向連結
系列文
打造 AI Agent 驅動的第二大腦:用 Go + Claude Code + Obsidian + Graphify 打造工程師知識作業系統15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言