Bài viết này lưu lại toàn bộ phiên chat với Claude khi mình build từng bước bộ tool migrate blog WordPress cũ (
blog.thaimeo.info) sang Hugo. Mỗi turn đều có ngữ cảnh, lý do thay đổi và đoạn code tương ứng. Hữu ích nếu bạn đang muốn migrate WP → Hugo, hoặc tham khảo cách “pair programming” từng bước với LLM thay vì viết một prompt khổng lồ ngay từ đầu.
Bối cảnh
Mình có một blog WordPress cũ với hơn 1500 bài viết. Muốn chuyển hết sang
Hugo (theme PaperMod), giữ nguyên metadata (ngày, tác giả, category, tag),
giữ ảnh local (vì có thể tắt server WP), và quan trọng là giữ nguyên cấu
trúc URL /YYYY/MM/DD/slug/ để không mất SEO.
Toàn bộ tool đều là Python script đơn giản, không framework, dễ sửa.
Turn 1 — “Viết cho tôi đoạn code chuyển một link website bất kì thành dạng markdown, có tải ảnh về local”
Mình bắt đầu rất generic — chỉ cần convert 1 URL bất kỳ. Claude tạo file
web2md.py dùng requests + BeautifulSoup + markdownify:
"""
Chuyển link website -> Markdown, tải ảnh về thư mục local.
Cài đặt:
pip install requests beautifulsoup4 markdownify
"""
import argparse
import hashlib
import mimetypes
import os
import re
from urllib.parse import urljoin, urlparse
import requests
from bs4 import BeautifulSoup
from markdownify import markdownify as md
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
)
}
def slugify(text: str, max_len: int = 60) -> str:
text = re.sub(r"[^\w\s-]", "", text, flags=re.UNICODE).strip().lower()
text = re.sub(r"[\s_-]+", "-", text)
return text[:max_len] or "page"
def pick_main_content(soup: BeautifulSoup):
for tag in soup(["script", "style", "noscript", "iframe", "svg"]):
tag.decompose()
for selector in ["article", "main", '[role="main"]', "#content", ".content", ".post", ".article"]:
node = soup.select_one(selector)
if node and len(node.get_text(strip=True)) > 200:
return node
return soup.body or soup
Đầu ra: thư mục có file .md + thư mục images/ chứa ảnh download về.
Điểm thú vị nhỏ ở bước này: dùng <article> / <main> / [role="main"]
làm selector ưu tiên để tự loại bỏ menu, sidebar, footer. Heuristic: chỉ
chọn node nếu text content > 200 ký tự.
Turn 2 — “Tốt đấy, nhưng định dạng cần có thêm các metadata phù hợp để chuyển thành post trên hugo blog”
Yêu cầu nâng lên: đầu ra phải là Hugo post chuẩn. Claude đổi 3 thứ:
- Output theo dạng page bundle của Hugo: thư mục riêng cho mỗi bài,
ảnh nằm cùng cấp
index.md→ Hugo tự xử lý đường dẫn relative. - Trích metadata từ Open Graph /
<meta>tags: title, description, author, date, tags, categories, og:image. - Front matter YAML đầy đủ:
---
title: Tiêu đề bài viết
date: '2026-05-03T10:30:00+00:00'
draft: false
slug: tieu-de-bai-viet
description: Mô tả lấy từ og:description
tags:
- python
- scraping
categories:
- Tech
author: Nguyen Van A
source: https://example.com/bai-viet
cover:
image: cover.jpg
alt: Tiêu đề bài viết
---
Field source là tự thêm — sau này hóa ra cực kỳ hữu ích để fix date sai.
Turn 3 — “Điều chỉnh lại code cho phù hợp với việc lấy content từ blog wordpress, ví dụ link https://blog.thaimeo.info/2026/04/20/ham-lon-tran-ai-ai-ai/"
Đây là bước rẽ quan trọng. Thay vì parse HTML chung chung, Claude chuyển sang ưu tiên WordPress REST API:
def fetch_wp_post(url, session):
parsed = urlparse(url)
base = f"{parsed.scheme}://{parsed.netloc}"
slug = slug_from_url(url)
if not slug:
return None
try:
r = session.get(
f"{base}/wp-json/wp/v2/posts",
params={"slug": slug, "_embed": "true"},
headers=HEADERS, timeout=20,
)
r.raise_for_status()
data = r.json()
except (requests.RequestException, ValueError):
return None
return data[0] if isinstance(data, list) and data else None
Param _embed=true cực kỳ tiện — WP trả về kèm tác giả, featured media,
categories, tags trong cùng response. Khỏi phải call thêm.
Bonus thêm:
- Loại noise WordPress (
.sharedaddy,.jp-relatedposts,.comments-area, …) trước khi convert markdown. - Lấy ảnh chất lượng cao nhất từ
srcset(parse300w,1024w… rồi chọn lớn nhất). - Strip suffix
-300x200của WP để fetch ảnh gốc, fallback URL ban đầu nếu 404.
Turn 4 — “Rất tốt, nhưng tôi cần lấy cả comment, hiển thị như nội dung tĩnh cũng được”
WordPress có endpoint riêng cho comments:
/wp-json/wp/v2/comments?post=<id>. Claude thêm:
- Fetch có pagination (per_page=100, loop tới khi
400hoặc rỗng). - Build cây thread theo
parent_id. - Render lồng nhau bằng blockquote — mỗi cấp reply prefix thêm
>.
def render_comment(c, depth=0):
prefix = "> " * depth
author = c.get("author_name") or "Ẩn danh"
author_url = c.get("author_url") or ""
author_md = f"[{author}]({author_url})" if author_url else f"**{author}**"
raw_date = c.get("date_gmt") or c.get("date") or ""
try:
dt = dateparser.parse(raw_date)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
date_str = dt.strftime("%Y-%m-%d %H:%M")
except (ValueError, TypeError):
date_str = ""
body_md = md(c.get("content", {}).get("rendered", ""), heading_style="ATX").strip()
body_md = re.sub(r"\n{3,}", "\n\n", body_md)
indented_body = "\n".join(
f"{prefix}{line}" if line else prefix.rstrip()
for line in body_md.splitlines()
)
out = f"{prefix}{author_md} — *{date_str}*\n{prefix}\n{indented_body}\n\n"
for child in c.get("_children", []):
out += render_comment(child, depth + 1)
return out
Output trông giống thread comments thật:
**Nguyễn Văn A** — *2026-04-21 09:15*
Bài hay quá anh ơi!
> [Trần B](https://example.com) — *2026-04-21 10:00*
>
> Đồng ý với bạn A.
>
> > **Nguyễn Văn A** — *2026-04-21 11:30*
> >
> > Cảm ơn bạn 😄
Turn 5 — “Test với link này thì bị lỗi https://blog.thaimeo.info/2009/08/20/mi-han-qu%e1%bb%91c/ — xử lý thế nào với font tiếng Việt?”
Bug điển hình của tiếng Việt. Có 2 vấn đề:
Vấn đề 1: URL slug không được decode. mi-han-qu%e1%bb%91c (encoded
UTF-8 cho mi-han-quốc) được gửi nguyên xi tới WP API → không match →
fallback HTML, nhưng tên thư mục cũng dùng dạng %e1%bb%91 xấu hoắc.
Vấn đề 2: slugify không bỏ dấu tiếng Việt. \w trong regex giữ
nguyên ố, đ, ă… → tạo path/filename có dấu, dễ vỡ trên một số
hệ thống.
Fix:
_VN_TRANSLATE = str.maketrans({"đ": "d", "Đ": "d", "ð": "d", "Ð": "d"})
def strip_diacritics(text: str) -> str:
"""Bỏ dấu tiếng Việt: đ/Đ -> d, rồi NFD bỏ combining marks."""
text = text.translate(_VN_TRANSLATE)
text = unicodedata.normalize("NFD", text)
return "".join(c for c in text if unicodedata.category(c) != "Mn")
def slugify(text: str, max_len: int = 60) -> str:
text = strip_diacritics(text).lower()
text = re.sub(r"[^a-z0-9\s-]", "", text).strip()
text = re.sub(r"[\s_-]+", "-", text)
return text[:max_len].strip("-") or "page"
def slug_from_url(url: str) -> str:
"""Lấy slug từ phần cuối path, decode %xx về UTF-8 thật."""
parts = [p for p in urlparse(url).path.split("/") if p]
return unquote(parts[-1]) if parts else ""
Cái khó của tiếng Việt: đ/Đ không tách được bằng NFD normalize
(không phải letter+combining mark, mà là code point độc lập). Phải
translate riêng trước.
Còn cho fetch_wp_post, thử cả 2 dạng vì WP có thể lưu slug giữ dấu
hoặc strip dấu tuỳ cấu hình:
candidates = [raw] # mi-han-quốc
ascii_slug = slugify(raw) # mi-han-quoc
if ascii_slug and ascii_slug != raw:
candidates.append(ascii_slug)
for slug in candidates:
# try API call with each
Cuối cùng, ảnh URL non-ASCII cần requests.utils.requote_uri() trước khi
tải.
Turn 6 — “Làm sao để cấu hình hugo giữ định dạng link của post giống với wordpress”
Câu trả lời: Hugo có [permalinks] config rất powerful.
[permalinks]
posts = "/:year/:month/:day/:slug/"
Hugo tự fill :year/:month/:day từ field date, :slug từ field slug
trong front matter — mà web2md.py đã ghi đầy đủ cả 2 từ trước.
Trick: pattern này không có :section, nên kể cả khi file nằm trong
content/posts/, URL output sẽ là /2009/08/20/mi-han-quoc/ chứ không
phải /posts/2009/... — đúng format WP.
Categories và tags cũng cần override để khớp WP (số ít thay vì số nhiều):
[taxonomies]
category = "categories"
tag = "tags"
[permalinks]
posts = "/:year/:month/:day/:slug/"
categories = "/category/:slug/"
tags = "/tag/:slug/"
Vấn đề slug có dấu tiếng Việt (URL gốc là mi-han-qu%e1%bb%91c):
- Option A: giữ slug có dấu trong front matter → URL trùng tuyệt đối.
- Option B (recommend cho SEO): slug ASCII sạch + thêm
aliases:trỏ về URL WP cũ → Hugo tự tạo HTML redirect.
aliases:
- /2009/08/20/mi-han-quốc/
Turn 7 — “Nếu tôi tắt server wordpress thì link ảnh này có bị mất không https://i0.wp.com/blog.thaimeo.info/wp-content/uploads/..."
Có, sẽ mất — chỉ là vấn đề thời gian.
i0.wp.com (cùng họ với i1/i2/i3.wp.com) là Jetpack Photon CDN —
hoạt động như reverse proxy + cache, không phải kho lưu trữ. Khi cache
miss/expire, Photon fetch lại từ origin. Origin tắt → 404 → ảnh biến mất
khỏi CDN luôn. Ngoài ra Jetpack có thể disable Photon cho domain bất cứ
lúc nào (đặc biệt khi không còn site WP active).
May là web2md.py đã download ảnh về local, nên markdown sau convert
không phụ thuộc vào WP server hay Photon nữa.
Một cải tiến nhỏ đáng làm: Photon URL nhúng URL gốc bên trong, có thể
strip prefix i0.wp.com/ để fetch trực tiếp từ origin (tránh phụ thuộc
Photon nếu Photon down trước WP):
def resolve_photon(u: str) -> str:
p = urlparse(u)
if re.match(r"i\d+\.wp\.com$", p.netloc):
return f"https://{p.path.lstrip('/')}"
return u
Turn 8 — “Tạo cho tôi đoạn code lấy toàn bộ link các post, pages trên blog”
Viết tool số 2: wp_list_urls.py. Crawl hết URL post + page qua REST API
để feed vào web2md.py.
def fetch_all(base, kind, session):
"""kind = 'posts' | 'pages'. Loop hết các trang cho đến khi rỗng."""
items = []
page = 1
endpoint = f"{base}/wp-json/wp/v2/{kind}"
while True:
try:
r = session.get(
endpoint,
params={
"per_page": 100,
"page": page,
"_fields": "id,date,modified,slug,link,title,status",
"status": "publish",
"orderby": "date",
"order": "asc",
},
headers=HEADERS, timeout=30,
)
if r.status_code == 400: # vượt trang cuối
break
r.raise_for_status()
batch = r.json()
except (requests.RequestException, ValueError):
break
if not isinstance(batch, list) or not batch:
break
# ...append to items
total_pages = int(r.headers.get("X-WP-TotalPages", "0") or 0)
if total_pages and page >= total_pages:
break
page += 1
return items
Vài chi tiết nhỏ đáng học:
- Dùng
_fields=...để giảm payload (không tải full content). - Đọc header
X-WP-TotalPagesđể biết khi nào dừng. - WP trả
400(rest_post_invalid_page_number) khi vượt trang cuối — bắt và dừng êm. - Log progress ra stderr (không trộn với output stdout) → an toàn khi pipe.
- Order theo
date asc→ URL theo thứ tự thời gian, dễ resume nếu convert hàng loạt bị ngắt.
Pipe sang web2md.py:
python wp_list_urls.py https://blog.thaimeo.info/ -o urls.txt
xargs -I{} -n1 python web2md.py {} < urls.txt
Sau bước này, mình có ~1610 file index.md trong content/posts/.
Turn 9 — “Trong thư mục content/posts có một số bài có date đang bị sai, date phải khớp với trường source. Hãy code đoạn mã python kiểm tra các file index.md, nếu file nào có ngày ở trường date không khớp với trường source thì chỉnh lại date cho khớp với thông tin ở trường source”
Một số bài bị sai date — có thể do WP REST API trả date_gmt lệch, hoặc
parse sai khi convert. Cần dùng source URL (chứa /YYYY/MM/DD/) làm
nguồn chân lý để fix.
URL_DATE_RE = re.compile(r"/(\d{4})/(\d{2})/(\d{2})/")
FRONT_MATTER_RE = re.compile(r"\A---\n(.*?)\n---", re.DOTALL)
DATE_LINE_RE = re.compile(r"^(date:\s*['\"]?)(\d{4}-\d{2}-\d{2})", re.MULTILINE)
def date_from_source(url):
m = URL_DATE_RE.search(url or "")
return f"{m.group(1)}-{m.group(2)}-{m.group(3)}" if m else None
Quyết định thiết kế quan trọng: không re-dump toàn bộ YAML qua
yaml.safe_dump(). PyYAML sẽ reformat (đổi quote, sort key) → tạo diff
khổng lồ không cần thiết. Dùng regex chỉ thay 10 ký tự ngày → diff sạch,
dễ review qua git.
Thêm --dry-run để preview trước, log status ok | fixed | skip | error.
Turn 10 — “Sửa file fix_post_dates, nếu lệch năm mới sửa, lệch vài ngày thì không cần sửa”
Sau khi chạy dry-run mới phát hiện: nhiều bài lệch 1-2 ngày do timezone (GMT vs Asia/Ho_Chi_Minh), không phải sai thật. Chỉ muốn fix khi lệch hẳn năm.
Fix duy nhất 1 dòng:
if cur_ymd[:4] == src_ymd[:4]:
# Cùng năm: bỏ qua kể cả khi lệch tháng/ngày
return "ok", cur_ymd
So sánh [:4] (YYYY) thay vì cả YYYY-MM-DD. Khi quyết định sửa thì vẫn
ghi cả YYYY-MM-DD từ source (logic không đổi) — chỉ trigger điều kiện
được nới lỏng.
Tổng kết
Cuối cùng có 3 script:
| File | Vai trò |
|---|---|
wp_list_urls.py | Crawl toàn bộ URL post + page từ blog WP |
web2md.py | Convert 1 URL WP → Hugo page bundle (kèm ảnh, comments) |
fix_post_dates.py | Sửa date front matter cho khớp source URL |
Workflow đầy đủ:
# 1. Lấy danh sách URL
python wp_list_urls.py https://blog.thaimeo.info/ -o urls.txt
# 2. Convert hàng loạt
xargs -I{} -n1 python web2md.py {} -o content/posts < urls.txt
# 3. Fix date sai (nếu có)
python fix_post_dates.py content/posts --dry-run
python fix_post_dates.py content/posts
# 4. Hugo build
hugo --minify
Plus config Hugo để URL khớp WordPress format:
[permalinks]
posts = "/:year/:month/:day/:slug/"
categories = "/category/:slug/"
tags = "/tag/:slug/"
[taxonomies]
category = "categories"
tag = "tags"
Bài học rút ra khi pair với LLM
Bắt đầu generic, refine theo case thực tế. Mình không liệt kê hết yêu cầu trong prompt đầu. Mỗi turn add thêm 1-2 yêu cầu mới khi gặp vấn đề thực — code dễ hiểu hơn, ít over-engineering hơn.
Cho LLM URL thực để test. Bug tiếng Việt chỉ lộ ra khi mình test với URL
mi-han-qu%e1%bb%91c. Cho ví dụ cụ thể luôn tốt hơn yêu cầu “support Vietnamese”.Đặt câu hỏi bên cạnh việc viết code. Câu “ảnh i0.wp.com có mất không?” giúp mình hiểu Photon CDN trước khi tắt WP server — tránh được mất ảnh.
Yêu cầu giải pháp tối giản. Khi muốn sửa logic so sánh date, mình chỉ nói “lệch năm mới sửa” — Claude thay 1 dòng
[:4]thay vì refactor hẳn function. Đúng cái mình cần.Diff sạch là tính năng, không phải bonus. Quyết định không re-dump YAML khi fix date là vì mình muốn review qua git. Nói rõ “diff sạch” trong prompt → Claude chọn tool phù hợp (regex thay vì PyYAML round-trip).
Toàn bộ code có thể tái sử dụng cho bất kỳ blog WordPress nào — chỉ cần endpoint REST API còn enabled. Hy vọng có ích cho ai đang định migrate khỏi WP. 🏔️