Hôm nay trên Hacker News có một bài lên top với tiêu đề rất đơn giản: "If you love a website, back it up". Đọc xong mình nghĩ ngay đến mấy lần "mất trắng" của chính mình: một blog kỹ thuật tiếng Việt cực hay về Linux bị hết hạn domain, một tutorial về Nginx mình bookmark từ 2019 giờ trả về 404, và cả blog cá nhân của mình trên một nền tảng miễn phí đã đóng cửa. Bookmark không phải là backup. Link sẽ chết, domain sẽ hết hạn, nền tảng sẽ đổi chính sách. Bài này mình chia sẻ cách mình đang archive web content thực tế: từ một trang lẻ, đến cả site, đến một hệ thống tự động chạy định kỳ.

Chọn đúng công cụ cho đúng nhu cầu

Trước khi gõ lệnh, cần xác định bạn muốn lưu cái gì. Mình chia làm 3 nhóm:

  • Một trang lẻ (bài viết, tài liệu): dùng monolith hoặc extension SingleFile để gói toàn bộ HTML, CSS, ảnh, font thành một file .html duy nhất. Mở offline vẫn y nguyên.
  • Cả một site tĩnh (blog, docs): dùng wget --mirror, kèm theo file WARC nếu muốn chuẩn lưu trữ.
  • Bộ sưu tập lâu dài, có tìm kiếm: self-host ArchiveBox, nó gọi nhiều công cụ cùng lúc (wget, SingleFile, Chromium để chụp PDF/screenshot, readability để tách text).
flowchart TD A[Muốn backup gì?] --> B{Phạm vi} B -->|1 trang| C[monolith / SingleFile] B -->|Cả site tĩnh| D[wget --mirror + WARC] B -->|Bộ sưu tập lâu dài| E[ArchiveBox self-host] C --> F[File .html độc lập] D --> G[Thư mục HTML + file .warc.gz] E --> H[Web UI + tìm kiếm full-text]

Một lưu ý quan trọng: site render bằng JavaScript (React/Vue SPA) thì wget chỉ tải về cái khung HTML rỗng. Với loại này bạn cần công cụ dùng headless browser như SingleFile CLI hoặc ArchiveBox (có Chromium bên trong).

Mirror cả site với wget (và tại sao nên bật WARC)

wget có sẵn trên hầu hết Linux, trên macOS cài bằng brew install wget. Từ bản 1.14 trở đi wget đã hỗ trợ ghi WARC (Web ARChive, chuẩn ISO 28500 mà Internet Archive dùng). Đây là lệnh mình hay dùng:

#!/usr/bin/env bash # mirror-site.sh - backup một site tĩnh, kèm file WARC set -euo pipefail URL="$1" NAME=$(echo "$URL" | sed -E 's#https?://##; s#/.*##') DATE=$(date +%Y%m%d) OUT="$HOME/web-archive/$NAME/$DATE" mkdir -p "$OUT" && cd "$OUT" wget --mirror --convert-links --adjust-extension --page-requisites --no-parent --wait=1 --random-wait --limit-rate=500k --user-agent='Mozilla/5.0 (personal-archive)' --warc-file="$NAME-$DATE" --warc-cdx "$URL" echo "Xong: $OUT" du -sh "$OUT" 

Giải thích nhanh mấy flag quan trọng:

  • --mirror: tương đương -r -N -l inf --no-remove-listing, tải đệ quy và chỉ tải lại file đã thay đổi.
  • --convert-links: sửa link trong HTML thành link local, để mở offline click được.
  • --page-requisites: tải luôn CSS, JS, ảnh mà trang cần.
  • --no-parent: không leo lên thư mục cha. Backup example.com/blog/ thì không kéo cả site về.
  • --wait=1 --random-wait --limit-rate: lịch sự với server. Đừng biến backup thành DDoS nhỏ vào site mà bạn yêu thích.
  • --warc-file: ngoài thư mục HTML, sinh thêm file .warc.gz chứa nguyên HTTP request/response. Sau này có thể replay bằng pywb hoặc ReplayWeb.page, hoặc nộp cho các dự án lưu trữ.

Mẹo: kiểm tra robots.txt và điều khoản của site trước. Backup để đọc cá nhân thì thường ổn, nhưng đừng đem nội dung người khác đi đăng lại.

Archive từng trang theo sitemap bằng Python + monolith

Với blog có sitemap.xml (gần như mọi blog WordPress, Hugo, Ghost đều có), mình thích archive từng bài thành một file .html độc lập bằng monolith. Mỗi bài một file, dễ grep, dễ đồng bộ sang ổ khác.

#!/usr/bin/env python3 # archive_sitemap.py - Python 3.10+, cần: pip install requests import hashlib import subprocess import sys import time import xml.etree.ElementTree as ET from pathlib import Path import requests NS = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"} OUT_DIR = Path.home() / "web-archive" / "pages" def get_urls(sitemap_url: str) -> list[str]: resp = requests.get(sitemap_url, timeout=30) resp.raise_for_status() root = ET.fromstring(resp.content) # sitemap index -> đệ quy vào từng sitemap con  if root.tag.endswith("sitemapindex"): urls = [] for loc in root.findall("sm:sitemap/sm:loc", NS): urls.extend(get_urls(loc.text.strip())) return urls return [loc.text.strip() for loc in root.findall("sm:url/sm:loc", NS)] def archive(url: str) -> None: key = hashlib.sha1(url.encode()).hexdigest()[:12] target = OUT_DIR / f"{key}.html" if target.exists(): print(f"[skip] {url}") return # -j: bỏ JavaScript, -I: cô lập file khỏi network khi mở lại  result = subprocess.run( ["monolith", url, "-j", "-I", "-o", str(target)], capture_output=True, text=True, timeout=120, ) status = "ok" if result.returncode == 0 else "FAIL" print(f"[{status}] {url} -> {target.name}") with open(OUT_DIR / "index.tsv", "a") as f: f.write(f"{key}\t{status}\t{url}\n") if __name__ == "__main__": OUT_DIR.mkdir(parents=True, exist_ok=True) for u in get_urls(sys.argv[1]): archive(u) time.sleep(2) # lịch sự với server 

Chạy: python3 archive_sitemap.py https://example.com/sitemap.xml. File index.tsv giúp map hash ngược về URL gốc. Vì có check target.exists(), chạy lại nhiều lần chỉ tải bài mới, rất hợp để đưa vào cron.

Flag -j của monolith bỏ JavaScript, giúp file nhẹ và an toàn hơn khi mở lại sau nhiều năm. Nếu trang cần JS để hiện nội dung thì bỏ flag này hoặc chuyển sang SingleFile CLI.

Tự động hóa: ArchiveBox + cron + backup 3-2-1

Khi số lượng link lên tới hàng trăm, mình chuyển sang ArchiveBox (dòng 0.7.x chạy ổn định qua Docker). Nó lưu mỗi URL bằng nhiều định dạng cùng lúc: HTML gốc, SingleFile, PDF, screenshot, WARC, text đã tách. Cũng có web UI để tìm kiếm.

mkdir -p ~/archivebox/data && cd ~/archivebox curl -fsSL 'https://docker-compose.archivebox.io' > docker-compose.yml docker compose run archivebox init --setup docker compose up -d # thêm link lẻ hoặc cả file danh sách docker compose run archivebox add 'https://example.com/bai-viet-hay' docker compose run archivebox add < ~/links.txt # cron: mỗi Chủ nhật 3h sáng, mirror lại blog yêu thích # 0 3 * * 0 /home/andy/bin/mirror-site.sh https://example.com/blog/ >> /var/log/mirror.log 2>&1 

Nhưng archive nằm trên đúng một ổ cứng thì vẫn chưa phải backup. Mình áp dụng quy tắc 3-2-1: 3 bản, 2 loại thiết bị, 1 bản ở ngoài nhà.

flowchart LR A[cron hàng tuần] --> B[wget / monolith / ArchiveBox] B --> C[(Ổ local ~/web-archive)] C -->|restic backup| D[(NAS trong nhà)] C -->|restic backup| E[(Object storage S3-compatible)] D --> F[restic check định kỳ] E --> F

Mình dùng restic (0.16+) vì nó tự deduplicate và mã hóa. Archive web có rất nhiều file giống nhau giữa các lần chạy (CSS, font, logo), nên deduplicate giảm dung lượng rất đáng kể:

restic -r /mnt/nas/restic-web init restic -r /mnt/nas/restic-web backup ~/web-archive --tag weekly restic -r /mnt/nas/restic-web forget --keep-weekly 8 --keep-monthly 12 --prune restic -r /mnt/nas/restic-web check 

Đừng quên bước cuối: thỉnh thoảng thử mở lại archive. Backup chưa từng restore thì coi như chưa có backup. Mỗi quý mình chọn ngẫu nhiên vài file .html và một file .warc.gz, mở thử xem còn đọc được không.

Kết luận

Internet không có nút "lưu vĩnh viễn". Những tài liệu, blog tiếng Việt chất lượng mà bạn học được nhiều thứ từ đó có thể biến mất bất cứ lúc nào. Vài việc làm được ngay tối nay:

  1. Liệt kê 5-10 site bạn sợ mất nhất: blog kỹ thuật yêu thích, docs nội bộ, và cả blog của chính bạn.
  2. Trang lẻ: cài monolith hoặc extension SingleFile, lưu những bài quan trọng thành file .html độc lập.
  3. Site tĩnh: dùng wget --mirror ... --warc-file, nhớ đặt --wait và --limit-rate để không làm phiền server.
  4. Blog có sitemap: dùng script Python ở trên, đưa vào cron chạy hàng tuần.
  5. Hơn trăm link: self-host ArchiveBox bằng Docker.
  6. Áp dụng 3-2-1 với restic, và định kỳ kiểm tra khả năng restore.

Và nếu bạn đang viết blog trên một nền tảng nào đó: hãy export nội dung của chính mình ra Markdown ngay hôm nay. Người cần backup đầu tiên chính là bạn.