Hook tiết kiệm token khi đọc website
Một hook nhỏ trong Claude Code có thể tự đổi WebFetch sang bản markdown sạch qua defuddle.md. Ít HTML rác hơn, ít token hơn, và agent đọc gần trọng tâm hơn.
Hook tiết kiệm token khi đọc website
Một phần đáng kể token trong các lần Claude Code đọc website không nằm ở nội dung người viết muốn nói, mà nằm trong HTML, navigation, script, style, tracking, menu, footer, và những mảnh giao diện lặp lại quanh bài viết. Khi dùng WebFetch để đưa một URL vào context window (vùng ngữ cảnh), agent (tác nhân AI) thường phải đọc một bản trang quá rộng so với việc mình thật sự cần: tiêu đề, các đoạn chính, vài liên kết liên quan, và đôi khi metadata. Vấn đề không chỉ là tốn token (đơn vị văn bản mà mô hình đọc và sinh ra), mà còn là nhiễu, vì nội dung chính bị đặt cạnh rất nhiều chuỗi không có giá trị lập luận. Một hook nhỏ có thể sửa việc này ở tầng harness, thay vì bắt mình nhắc lại trong prompt rằng “hãy dùng bản markdown sạch nếu có”.
Vấn đề: HTML đầy rác
Khi một bài blog dài khoảng 2.000 chữ được fetch trực tiếp, lượng text đi vào model có thể phình lên nhiều lần vì HTML không phải là bài viết. Nó là toàn bộ trang, gồm DOM, template, các khối liên quan đến cookie, schema, thẻ social preview, danh sách bài liên quan, menu responsive, và đôi khi cả JSON nhúng cho framework. Với người đọc trên trình duyệt, phần này được render thành giao diện bình thường; với agent đang phân tích text, nhiều phần đó trở thành vật cản.
Điểm khó chịu là người dùng thường nhìn URL và nghĩ rằng Claude đang đọc bài viết, trong khi thực tế Claude có thể đang đọc một đống vỏ bọc quanh bài viết. Nếu task là tóm tắt, trích ý, so sánh lập luận, hoặc lấy chi tiết kỹ thuật từ docs, nhiễu này làm agent phải tự lọc thêm một lớp trước khi làm việc chính. Với context window có giới hạn, việc nhét HTML thừa vào cũng làm phần hội thoại, code, yêu cầu ban đầu, và các tài liệu khác bị chen ra sớm hơn.
Mình không xem chuyện này như một lỗi của WebFetch. WebFetch làm đúng việc của nó: lấy nội dung từ URL. Nhưng nếu workflow thường xuyên là “đọc nội dung chính của một trang web”, thì mình cần một lớp chuẩn hóa trước khi nội dung đi vào model. Lớp đó nên nằm ở chỗ tự động, ổn định, và càng ít phụ thuộc vào trí nhớ của mình càng tốt.
Cách sửa: defuddle.md
defuddle.md là một service đơn giản: đưa URL gốc qua https://defuddle.md/, nó trả về bản markdown sạch hơn của trang đó. Thay vì fetch https://example.com/post, mình fetch https://defuddle.md/https://example.com/post. Kết quả thường gần với phần bài viết thật hơn, ít khối giao diện hơn, và dễ đọc hơn cho cả người lẫn model.
Markdown không làm nội dung trở nên đúng hơn, nhưng nó làm cấu trúc rõ hơn. Heading, paragraph, link, code block, và list được giữ theo cách phù hợp với việc phân tích văn bản. Những thứ như navigation hoặc script thường bị loại bỏ, nên agent ít phải đoán đâu là phần chính. Khi cùng một bài viết giảm từ vài chục nghìn token xuống vài nghìn token, lợi ích không chỉ là tiết kiệm chi phí mà còn là giảm xác suất agent bám nhầm vào phần phụ.

Điểm mình muốn là không phải nhớ thêm một convention mỗi lần chat. Nếu mình phải luôn gõ “hãy dùng defuddle.md cho URL này”, thì giải pháp vẫn nằm trong prompt, và prompt là nơi dễ quên nhất. Thay vào đó, mình muốn Claude Code tự rewrite mọi WebFetch phù hợp sang defuddle.md trước khi tool chạy.
Hook chạy ngầm trong harness
Claude Code có hook (điểm móc chạy lệnh theo sự kiện) để can thiệp vào một số thời điểm trong vòng đời tool. Ở đây mình dùng PreToolUse (sự kiện trước khi dùng tool), nghĩa là lệnh hook được chạy ngay trước khi Claude Code gọi WebFetch. Hook nhận payload dạng JSON (định dạng dữ liệu có cấu trúc key-value), đọc URL mà WebFetch chuẩn bị dùng, rồi có thể trả về một object yêu cầu Claude Code tiếp tục gọi tool nhưng với input đã được chỉnh.
Đây là chỗ harness (lớp khung điều phối tool và agent) đáng được sửa một lần. Nếu mình sửa ở prompt, mình phải lặp lại instruction ở từng session, từng project, và từng người dùng. Nếu mình sửa ở hook, Claude Code CLI (giao diện dòng lệnh) tự áp dụng quy tắc đó cho mọi lần WebFetch trong môi trường tương ứng. Prompt lúc đó được giải phóng để nói về task, còn quy tắc vệ sinh input nằm trong hạ tầng.
Có hai trường hợp không nên rewrite. Trường hợp thứ nhất là URL đã đi qua defuddle.md rồi, vì rewrite thêm lần nữa sẽ tạo chuỗi URL lồng nhau không cần thiết. Trường hợp thứ hai là URL trỏ tới binary file như ảnh, video, PDF, archive, app installer, hoặc audio. defuddle.md hữu ích cho trang HTML có nội dung văn bản, không phải cho file .png, .pdf, .zip, hay .mp4.
Setup trong settings.json
Cấu hình này đặt trong ~/.claude/settings.json. Nếu file đã có cấu hình khác, mình cần merge phần hooks vào cấu trúc hiện có thay vì thay toàn bộ file một cách máy móc. Phần quan trọng là matcher WebFetch, vì mình chỉ muốn can thiệp vào tool fetch web, không đụng tới các tool khác.
{
"hooks": {
"PreToolUse": [
{
"matcher": "WebFetch",
"hooks": [
{
"type": "command",
"command": "jq -c 'if (.tool_input.url | test(\"^https?://defuddle\\\\.md/\")) or (.tool_input.url | test(\"(?i)\\\\.(png|jpe?g|gif|webp|svg|bmp|ico|avif|tiff?|mp4|webm|mov|mp3|wav|ogg|pdf|zip|tar|gz|bz2|7z|rar|exe|dmg|bin)(\\\\?|#|$)\")) then empty else {hookSpecificOutput: {hookEventName: \"PreToolUse\", permissionDecision: \"allow\", updatedInput: (.tool_input | .url |= sub(\"^https?://\"; \"https://defuddle.md/\"))}} end'",
"timeout": 10
}
]
}
]
}
}
Hook này dùng jq (công cụ xử lý JSON trên dòng lệnh), nên máy cần có jq trong PATH. Trên macOS, nếu dùng Homebrew, lệnh cài thường là brew install jq. Sau khi lưu settings.json, mình nên restart Claude Code hoặc mở một session mới, vì cấu hình hook thường được đọc khi session khởi động.
Điểm cần giữ nguyên là các dấu backslash trong chuỗi command. Vì command nằm trong JSON, còn bên trong command lại có regex, nên dấu \\ không phải trang trí. Nếu copy thiếu escape, regex có thể đổi nghĩa hoặc JSON không parse được. Đây là loại cấu hình nên copy nguyên khối trước, test chạy được rồi mới chỉnh dần nếu có nhu cầu riêng.
jq logic — hai nhánh bỏ qua
Claude Code gửi vào hook một JSON payload qua stdin. Payload đó có nhiều trường phục vụ runtime, nhưng phần mình cần là tool_input.url, tức URL mà WebFetch sắp dùng. Lệnh jq -c đọc payload này, chạy một biểu thức điều kiện, rồi in ra JSON compact nếu muốn thay đổi hành vi tool.
Nhánh đầu tiên kiểm tra URL có bắt đầu bằng http://defuddle.md/ hoặc https://defuddle.md/ hay không. Nếu đúng, hook trả về empty, nghĩa là không xuất gì cả. Với Claude Code hook, không xuất hook output ở đây tương đương với việc không can thiệp, để WebFetch chạy như ban đầu; điều này tránh việc URL đã sạch lại bị bọc thêm một lớp defuddle nữa.
Nhánh thứ hai là regex dài kiểm tra extension file. Cờ (?i) làm regex không phân biệt hoa thường, nên .PDF, .Pdf, hay .pdf đều được xem như nhau. Danh sách này gồm ảnh như png, jpg, webp, svg; video như mp4, webm, mov; audio như mp3, wav; tài liệu và archive như pdf, zip, tar, gz, rar; cùng vài binary phổ biến như exe, dmg, bin. Phần (\?|#|$) ở cuối đảm bảo extension nằm ngay trước query string, fragment, hoặc cuối URL, nên URL có file.pdf?download=1 vẫn được skip.
Nếu cả hai nhánh skip đều không đúng, jq trả về một object có hookSpecificOutput. Bên trong đó, hookEventName nói đây là output cho PreToolUse, permissionDecision: "allow" nói Claude Code vẫn được phép chạy WebFetch, và updatedInput là input mới cho tool. Phần sub("^https?://"; "https://defuddle.md/") lấy toàn bộ input cũ của WebFetch, chỉ sửa trường url, rồi prepend https://defuddle.md/ bằng cách thay http:// hoặc https:// ở đầu URL gốc. Kết quả là https://example.com/post thành https://defuddle.md/example.com/post, theo format mà service này hiểu.
Test xem có chạy không
Sau khi sửa ~/.claude/settings.json, mình mở session Claude Code mới rồi yêu cầu nó fetch một URL blog bất kỳ. Cách test đơn giản là chọn một bài viết có nội dung dài vừa đủ, không phải ảnh, PDF, hoặc file tải xuống. Nếu hook chạy đúng, tool call output hoặc phần hiển thị URL trong quá trình fetch sẽ cho thấy URL đã trở thành defuddle URL thay vì URL gốc. Đây là cách kiểm tra trực quan nhất, vì mình thấy rewrite xảy ra trước khi đánh giá chất lượng câu trả lời.
Một dấu hiệu khác là lượng nội dung đưa về gọn hơn. Không có hook, một bài khoảng 2.000 chữ có thể làm WebFetch tiêu thụ hơn 30k token nếu HTML và dữ liệu nhúng dày. Có hook, cùng nội dung đó thường rơi vào khoảng 4-5k token, tùy trang và cách defuddle.md trích xuất. Con số này không phải cam kết cố định, nhưng đủ để thấy khác biệt giữa “đọc trang web” và “đọc phần nội dung chính của trang”.
Nếu không thấy URL đổi, mình kiểm tra ba thứ trước. Một là Claude Code đã restart chưa. Hai là jq có chạy được từ shell không. Ba là settings.json có hợp lệ không, vì chỉ một dấu phẩy thừa cũng làm cấu hình không load được. Nếu URL là file binary hoặc đã bắt đầu bằng defuddle.md, hook cố ý không rewrite, nên đó không phải lỗi.
Một thói quen lớn hơn việc tiết kiệm token
Điểm đáng giữ lại ở đây không chỉ là defuddle.md hay vài nghìn token tiết kiệm được trong một lần fetch. Thói quen quan trọng hơn là nhìn những việc mình hay nhắc agent lặp lại, rồi hỏi xem việc đó nên nằm trong prompt hay trong harness. Prompt phù hợp cho mục tiêu, tiêu chuẩn đánh giá, và ngữ cảnh đang thay đổi. Harness phù hợp cho những quy tắc ổn định, có thể chạy tự động, và không cần agent phải “nhớ”.
Với vibe coders, designers, và founders dùng Claude Code để đọc docs, đối chiếu bài viết, hoặc nghiên cứu đối thủ, sự khác biệt này tích lũy khá nhanh. Mỗi lần fetch ít nhiễu hơn là một lần agent có cơ hội dùng context window cho thứ gần task hơn. Mỗi lần không phải nhắc “hãy lấy markdown sạch” là một lần prompt ngắn hơn và ít phụ thuộc vào ritual cá nhân hơn. Một hook nhỏ như vậy không làm workflow thành hệ thống hoàn hảo, nhưng nó chuyển một thói quen đúng từ trí nhớ con người xuống một lớp có thể chạy đều.
Mình vẫn giữ hai nhánh skip trong hook vì tự động hóa tốt cần biết lúc nào không nên chạy. Không rewrite defuddle URL tránh vòng lặp. Không rewrite binary URL tránh đưa file không phù hợp qua service đọc văn bản. Phần còn lại là một mặc định hợp lý: khi Claude Code định đọc một website bằng WebFetch, mình muốn nó đọc bản gần với nội dung chính trước.
Điều mình còn để mở là nên có thêm những rewrite nào ở tầng này: docs sang bản raw, GitHub file sang raw content, hay các trang nặng JavaScript sang một extractor khác?