- Phân biệt script và sản phẩm — biết cần thêm gì để vượt ranh giới đó.
- Xây CLI hoàn chỉnh bằng
argparse: sub-command, cờ, giá trị mặc định,--dry-run. - Thu thập dữ liệu web đúng kỹ thuật và đúng mực:
robots.txt, giới hạn tần suất, User-Agent trung thực. - Gọi REST API có phân trang, retry với backoff, và tôn trọng rate limit.
- Lưu trữ bằng
sqlite3— database không cần server. - Chạy định kỳ bằng
cron/launchd; ghi log để biết đêm qua có chuyện gì. - Áp dụng checklist chất lượng 12 mục trước khi gọi một dự án là "xong".
Although never is often better than right now.
1. Từ script tới sản phẩm
Một script là đoạn code giải quyết vấn đề của bạn, hôm nay, trên máy bạn. Một sản phẩm là đoạn code giải quyết vấn đề của người khác, trong nhiều tháng, trên máy họ. Khoảng cách giữa hai thứ đó là những hạng mục sau:
| Khía cạnh | Script | Sản phẩm | Học ở |
|---|---|---|---|
| Cấu hình | Sửa hằng số trong code | Tham số dòng lệnh / tệp cấu hình / biến môi trường | Ch. 14 |
| Đầu vào sai | Sập với traceback | Thông điệp rõ ràng + mã thoát khác 0 | Ch. 5 |
| Lỗi mạng | Sập | Retry có backoff, timeout, thông báo | Ch. 5, 14 |
| Đường dẫn | Cứng, tuyệt đối | pathlib tương đối với __file__ | Ch. 7 |
| Theo dõi | print | logging có cấp độ, ghi ra tệp | Ch. 5 |
| Cấu trúc | Một tệp | Package, src layout | Ch. 6 |
| Kiểu dữ liệu | Không annotate | Type hint đầy đủ, mypy sạch | Ch. 12 |
| Đúng đắn | "Chạy thử thấy được" | Test tự động, chạy trong CI | Ch. 13 |
| Cài đặt | Copy tệp | pip install, có lệnh trong PATH | Ch. 6, 13 |
| Thao tác nguy hiểm | Làm luôn | --dry-run mặc định, xác nhận trước khi xoá | Ch. 14 |
Mọi thao tác không thể hoàn tác phải khó thực hiện hơn thao tác an toàn. Xoá tệp, ghi đè
dữ liệu, gửi email hàng loạt — mặc định phải là "chỉ xem trước", còn thực thi thật thì cần cờ tường minh
(--apply, --yes). Người dùng sẽ gõ sai lệnh; thiết kế của bạn quyết định lần gõ
sai đó tốn 3 giây hay mất một ngày dữ liệu.
2. argparse — xương sống của mọi CLI
sys.argv đủ cho script một tham số. Từ hai tham số trở lên, hoặc khi cần cờ tuỳ chọn, hãy dùng
argparse — nó tự sinh --help, tự kiểm tra kiểu, tự báo lỗi và thoát với mã 2.
import argparse
from pathlib import Path
def tao_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="don-thu-muc",
description="Sắp xếp tệp trong thư mục theo phần mở rộng.",
epilog="Ví dụ: don-thu-muc ~/Downloads --apply",
)
# Tham số bắt buộc (positional)
p.add_argument("thu_muc", type=Path, help="Thư mục cần dọn")
# Cờ bật/tắt
p.add_argument("--apply", action="store_true",
help="Thực thi thật (mặc định chỉ xem trước)")
p.add_argument("-r", "--de-quy", action="store_true",
help="Xử lý cả thư mục con")
# Tham số có giá trị, có mặc định
p.add_argument("--kich-thuoc-toi-thieu", type=int, default=0,
metavar="BYTE", help="Bỏ qua tệp nhỏ hơn (mặc định: 0)")
# Chỉ cho phép vài giá trị
p.add_argument("--theo", choices=["duoi", "ngay", "kich-thuoc"],
default="duoi", help="Tiêu chí nhóm (mặc định: duoi)")
# Nhận nhiều giá trị
p.add_argument("--bo-qua", nargs="*", default=[],
metavar="DUOI", help="Các đuôi tệp bỏ qua, vd: .tmp .log")
# Nhóm loại trừ lẫn nhau
nhom = p.add_mutually_exclusive_group()
nhom.add_argument("-v", "--chi-tiet", action="store_true")
nhom.add_argument("-q", "--im-lang", action="store_true")
p.add_argument("--version", action="version", version="%(prog)s 1.0")
return p
$ don-thu-muc --help
usage: don-thu-muc [-h] [--apply] [-r] [--kich-thuoc-toi-thieu BYTE]
[--theo {duoi,ngay,kich-thuoc}] [--bo-qua [DUOI ...]]
[-v | -q] [--version] thu_muc
Sắp xếp tệp trong thư mục theo phần mở rộng.
positional arguments:
thu_muc Thư mục cần dọn
options:
-h, --help show this help message and exit
--apply Thực thi thật (mặc định chỉ xem trước)
...
Ví dụ: don-thu-muc ~/Downloads --apply
2.1. Sub-command — CLI nhiều lệnh con
Công cụ lớn thường có nhiều lệnh con, như git commit / git push hay
uv add / uv run. argparse làm được điều đó với
add_subparsers.
import argparse, sys
from pathlib import Path
def lenh_dong_bo(args: argparse.Namespace) -> int:
print(f"Đồng bộ {args.nguon} → {args.dich}")
return 0
def lenh_bao_cao(args: argparse.Namespace) -> int:
print(f"Báo cáo dạng {args.dinh_dang}")
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="cong-cu")
sub = p.add_subparsers(dest="lenh", required=True)
p_db = sub.add_parser("dong-bo", help="Đồng bộ hai thư mục")
p_db.add_argument("nguon", type=Path)
p_db.add_argument("dich", type=Path)
p_db.set_defaults(ham=lenh_dong_bo) # ⭐ gắn hàm xử lý vào parser con
p_bc = sub.add_parser("bao-cao", help="Sinh báo cáo")
p_bc.add_argument("--dinh-dang", choices=["json", "csv", "md"], default="md")
p_bc.set_defaults(ham=lenh_bao_cao)
args = p.parse_args(argv)
return args.ham(args) # điều phối không cần if/elif
if __name__ == "__main__":
sys.exit(main())
set_defaults(ham=...)
Thay vì viết chuỗi if args.lenh == "dong-bo": ... elif ..., hãy gắn thẳng hàm xử lý vào từng
parser con. Thêm lệnh mới chỉ cần thêm một khối 3 dòng, không phải sửa hàm điều phối — đúng nguyên tắc
"mở để mở rộng, đóng để sửa đổi".
3. Dự án 1 — CLI dọn dẹp thư mục
Một công cụ sắp xếp thư mục Downloads lộn xộn: gom tệp vào các thư mục con theo loại
(anh/, tai_lieu/, nen/…). Đây là bài toán đơn giản nhưng đủ chỗ để
thực hành mọi nguyên tắc: pathlib, xử lý xung đột tên, chế độ xem trước, ghi log.
"""Logic thuần — không biết gì về dòng lệnh, nên dễ test."""
from __future__ import annotations
import logging
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
log = logging.getLogger(__name__)
NHOM: dict[str, frozenset[str]] = {
"anh": frozenset({".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}),
"tai_lieu": frozenset({".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md"}),
"nen": frozenset({".zip", ".tar", ".gz", ".rar", ".7z"}),
"video": frozenset({".mp4", ".mkv", ".mov", ".avi"}),
"code": frozenset({".py", ".js", ".ts", ".java", ".go", ".rs"}),
}
@dataclass(frozen=True)
class KeHoach:
"""Một thao tác di chuyển dự kiến."""
nguon: Path
dich: Path
def phan_loai(duoi: str) -> str:
duoi = duoi.lower()
for ten_nhom, tap_duoi in NHOM.items():
if duoi in tap_duoi:
return ten_nhom
return "khac"
def ten_khong_trung(dich: Path) -> Path:
"""bao_cao.pdf → bao_cao (1).pdf → bao_cao (2).pdf ..."""
if not dich.exists():
return dich
for i in range(1, 1000):
ung_vien = dich.with_name(f"{dich.stem} ({i}){dich.suffix}")
if not ung_vien.exists():
return ung_vien
raise FileExistsError(f"Quá nhiều bản trùng tên với {dich.name}")
def lap_ke_hoach(
thu_muc: Path,
*,
de_quy: bool = False,
bo_qua: frozenset[str] = frozenset(),
kich_thuoc_toi_thieu: int = 0,
) -> list[KeHoach]:
"""Tính TOÀN BỘ việc cần làm mà không đụng vào đĩa."""
if not thu_muc.is_dir():
raise NotADirectoryError(f"Không phải thư mục: {thu_muc}")
ke_hoach: list[KeHoach] = []
da_dat: set[Path] = set() # tên đã "đặt gạch" trong lần chạy này
nguon_iter = thu_muc.rglob("*") if de_quy else thu_muc.iterdir()
for f in sorted(nguon_iter):
if not f.is_file() or f.suffix.lower() in bo_qua:
continue
if f.stat().st_size < kich_thuoc_toi_thieu:
continue
nhom = phan_loai(f.suffix)
if f.parent.name == nhom: # đã đúng chỗ rồi
continue
dich = ten_khong_trung(thu_muc / nhom / f.name)
while dich in da_dat: # tránh 2 tệp cùng nhắm 1 đích
dich = ten_khong_trung(dich)
da_dat.add(dich)
ke_hoach.append(KeHoach(f, dich))
return ke_hoach
def thuc_thi(ke_hoach: list[KeHoach]) -> Counter[str]:
"""Thực hiện kế hoạch. Một tệp lỗi không làm dừng cả lô."""
thong_ke: Counter[str] = Counter()
for kh in ke_hoach:
try:
kh.dich.parent.mkdir(parents=True, exist_ok=True)
kh.nguon.rename(kh.dich)
thong_ke[kh.dich.parent.name] += 1
log.debug("đã chuyển %s → %s", kh.nguon.name, kh.dich)
except OSError as e:
thong_ke["loi"] += 1
log.warning("bỏ qua %s: %s", kh.nguon.name, e)
return thong_ke
Tách "lập kế hoạch" khỏi "thực thi"
Đây là mẫu thiết kế đáng nhớ nhất của chương. lap_ke_hoach() là hàm thuần —
đọc trạng thái, trả về danh sách việc cần làm, không đụng gì vào đĩa. thuc_thi()
chỉ làm theo danh sách đó. Ba lợi ích lớn: (1) --dry-run chỉ là "chạy phần lập kế hoạch rồi
in ra" — không cần một nhánh code riêng dễ lệch; (2) test được toàn bộ logic phân loại mà không cần tệp
thật; (3) có thể hiển thị kế hoạch cho người dùng xác nhận trước khi làm.
"""Lớp CLI — chỉ lo phân tích tham số, in ấn, và mã thoát."""
import argparse, logging, sys
from pathlib import Path
from don_thu_muc.core import lap_ke_hoach, thuc_thi
def cau_hinh_log(chi_tiet: bool, im_lang: bool) -> None:
muc = logging.DEBUG if chi_tiet else (logging.ERROR if im_lang else logging.INFO)
logging.basicConfig(level=muc, format="%(levelname)s %(message)s")
def main(argv: list[str] | None = None) -> int:
args = tao_parser().parse_args(argv)
cau_hinh_log(args.chi_tiet, args.im_lang)
try:
ke_hoach = lap_ke_hoach(
args.thu_muc,
de_quy=args.de_quy,
bo_qua=frozenset(d.lower() for d in args.bo_qua),
kich_thuoc_toi_thieu=args.kich_thuoc_toi_thieu,
)
except (NotADirectoryError, PermissionError) as e:
print(f"Lỗi: {e}", file=sys.stderr)
return 2 # mã 2 = sai cách dùng
if not ke_hoach:
print("Không có gì để dọn.")
return 0
if not args.apply:
print(f"[XEM TRƯỚC] {len(ke_hoach)} tệp sẽ được chuyển:\n")
for kh in ke_hoach[:20]:
print(f" {kh.nguon.name} → {kh.dich.parent.name}/{kh.dich.name}")
if len(ke_hoach) > 20:
print(f" ... và {len(ke_hoach) - 20} tệp nữa")
print("\nThêm --apply để thực hiện.")
return 0
thong_ke = thuc_thi(ke_hoach)
for nhom, n in sorted(thong_ke.items()):
print(f" {nhom}: {n}")
return 1 if thong_ke["loi"] else 0
if __name__ == "__main__":
sys.exit(main())
| Mã | Ý nghĩa |
|---|---|
0 | Thành công |
1 | Lỗi chung khi chạy (một phần công việc thất bại) |
2 | Sai cách dùng — tham số không hợp lệ (argparse tự dùng mã này) |
130 | Người dùng bấm Ctrl+C (128 + SIGINT 2) |
Shell script và CI đọc mã thoát để quyết định chạy tiếp hay dừng. Một CLI luôn trả 0 là CLI không dùng được trong pipeline.
4. Dự án 2 — Thu thập dữ liệu web
- Kiểm tra xem có API chính thức không. Nếu có, luôn dùng API — ổn định hơn, nhanh hơn, và được phép.
- Đọc
robots.txttạihttps://ten-mien/robots.txtvà tôn trọng nó. - Đọc điều khoản sử dụng. Nhiều trang cấm thu thập tự động; vi phạm có thể dẫn tới hậu quả pháp lý.
- Giới hạn tần suất. Nghỉ giữa các request. Không có ai cảm ơn bạn vì làm sập server của họ.
- Khai báo trung thực. Đặt
User-Agentcó tên công cụ và cách liên hệ, đừng giả mạo trình duyệt. - Không thu thập dữ liệu cá nhân nếu không có cơ sở pháp lý rõ ràng.
$ uv add httpx beautifulsoup4 lxml
# httpx: HTTP client hiện đại (đồng bộ + bất đồng bộ, HTTP/2)
# beautifulsoup4: phân tích HTML
# lxml: bộ phân tích nhanh cho BeautifulSoup
from __future__ import annotations
import logging, time
from dataclasses import dataclass
from urllib.parse import urljoin, urlparse
from urllib.robotparser import RobotFileParser
import httpx
from bs4 import BeautifulSoup
log = logging.getLogger(__name__)
USER_AGENT = "HocPythonBot/1.0 (+https://vidu.vn/lien-he)"
@dataclass(frozen=True)
class BaiViet:
tieu_de: str
lien_ket: str
tom_tat: str
class ThuThapLoi(Exception):
"""Lỗi gốc của module thu thập."""
class BiCam(ThuThapLoi):
"""robots.txt không cho phép truy cập URL này."""
def duoc_phep(url: str) -> bool:
"""Hỏi robots.txt trước khi lấy bất kỳ trang nào."""
goc = urlparse(url)
rp = RobotFileParser()
rp.set_url(f"{goc.scheme}://{goc.netloc}/robots.txt")
try:
rp.read()
except OSError:
log.warning("Không đọc được robots.txt — mặc định KHÔNG cho phép")
return False # mặc định an toàn
return rp.can_fetch(USER_AGENT, url)
def tai_trang(client: httpx.Client, url: str, *, so_lan: int = 3) -> str:
"""Tải HTML với retry + backoff luỹ thừa, tôn trọng Retry-After."""
if not duoc_phep(url):
raise BiCam(f"robots.txt chặn: {url}")
loi: list[Exception] = []
for lan in range(so_lan):
try:
r = client.get(url, timeout=10.0)
if r.status_code == 429: # quá nhiều request
cho = float(r.headers.get("Retry-After", 5))
log.info("Bị giới hạn, chờ %.1fs", cho)
time.sleep(cho)
continue
r.raise_for_status() # ném với 4xx/5xx
return r.text
except (httpx.TimeoutException, httpx.HTTPStatusError) as e:
loi.append(e)
cho = 2 ** lan # 1s, 2s, 4s
log.warning("Lần %d thất bại (%s), thử lại sau %ds", lan + 1, e, cho)
time.sleep(cho)
raise ThuThapLoi(f"Không tải được {url} sau {so_lan} lần") from ExceptionGroup(
"mọi lần thử đều hỏng", loi
)
def phan_tich(html: str, url_goc: str) -> list[BaiViet]:
"""Tách dữ liệu — hàm THUẦN, test được bằng HTML mẫu offline."""
soup = BeautifulSoup(html, "lxml")
ket_qua: list[BaiViet] = []
for the in soup.select("article.post"): # CSS selector
a = the.select_one("h2 a")
p = the.select_one("p.summary")
if a is None: # HTML có thể thiếu — đừng giả định
continue
ket_qua.append(BaiViet(
tieu_de=a.get_text(strip=True),
lien_ket=urljoin(url_goc, a["href"]), # biến /bai/1 thành URL đầy đủ
tom_tat=p.get_text(strip=True) if p else "",
))
return ket_qua
def thu_thap(urls: list[str], *, nghi: float = 1.0) -> list[BaiViet]:
tat_ca: list[BaiViet] = []
with httpx.Client(headers={"User-Agent": USER_AGENT},
follow_redirects=True) as client:
for i, url in enumerate(urls):
if i:
time.sleep(nghi) # ⭐ lịch sự với server
try:
tat_ca.extend(phan_tich(tai_trang(client, url), url))
except ThuThapLoi as e:
log.error("Bỏ qua %s: %s", url, e)
return tat_ca
tai_trang và phan_tich
phan_tich() chỉ nhận chuỗi HTML và trả về dữ liệu — không mạng, không I/O. Nhờ đó bạn lưu
một trang HTML thật vào tests/du_lieu/trang_mau.html và viết test chạy trong mili-giây,
không phụ thuộc internet, không làm phiền server của người ta. Đây là ứng dụng trực tiếp của nguyên tắc
"tách phần thuần khỏi phần có tác dụng phụ" mà bạn đã thấy ở Dự án 1 và sẽ thấy lại ở
Chương 13.
5. Dự án 3 — Gọi REST API
Gọi API là công việc phổ biến hơn scraping rất nhiều, và có nhiều thứ phải làm đúng: xác thực, phân trang, giới hạn tần suất, retry chỉ với lỗi tạm thời, và không bao giờ để lộ khoá bí mật.
from __future__ import annotations
import logging, os, time
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import datetime
import httpx
log = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class Repo:
ten: str
sao: int
ngon_ngu: str | None
cap_nhat: datetime
@classmethod
def tu_json(cls, d: dict) -> Repo:
"""Alternative constructor — biên giới giữa JSON tuỳ tiện và kiểu chặt chẽ."""
return cls(
ten=d["full_name"],
sao=d["stargazers_count"],
ngon_ngu=d.get("language"),
cap_nhat=datetime.fromisoformat(d["updated_at"]),
)
class GitHubClient:
GOC = "https://api.github.com"
def __init__(self, token: str | None = None) -> None:
# ⭐ Khoá bí mật đọc từ biến môi trường, KHÔNG BAO GIỜ ghi trong code
token = token or os.getenv("GITHUB_TOKEN")
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "HocPython/1.0",
}
if token:
headers["Authorization"] = f"Bearer {token}"
self._client = httpx.Client(headers=headers, timeout=15.0)
# --- Context manager: dùng được với `with` ---
def __enter__(self) -> GitHubClient:
return self
def __exit__(self, *_) -> bool:
self._client.close()
return False # không nuốt exception
def _get(self, duong_dan: str, **tham_so) -> httpx.Response:
"""GET có retry — CHỈ retry lỗi tạm thời."""
for lan in range(4):
r = self._client.get(f"{self.GOC}{duong_dan}", params=tham_so)
# Hết hạn mức → chờ tới lúc reset
if r.status_code == 403 and r.headers.get("X-RateLimit-Remaining") == "0":
reset = int(r.headers["X-RateLimit-Reset"])
cho = max(reset - time.time(), 0) + 1
log.warning("Hết hạn mức, chờ %.0fs", cho)
time.sleep(cho)
continue
# 5xx = lỗi phía server → đáng retry
if r.status_code >= 500:
time.sleep(2 ** lan)
continue
# 4xx khác = lỗi phía TA → retry vô nghĩa, ném ngay
r.raise_for_status()
return r
raise RuntimeError(f"GET {duong_dan} thất bại sau 4 lần")
def tim_repo(self, tu_khoa: str, *, gioi_han: int = 100) -> Iterator[Repo]:
"""Generator — tự lo phân trang, người dùng chỉ việc lặp."""
trang, da_lay = 1, 0
while da_lay < gioi_han:
r = self._get("/search/repositories",
q=tu_khoa, sort="stars", per_page=30, page=trang)
muc = r.json()["items"]
if not muc:
return # hết dữ liệu
for m in muc:
if da_lay >= gioi_han:
return
yield Repo.tu_json(m)
da_lay += 1
trang += 1
# --- Dùng ---
with GitHubClient() as gh:
for repo in gh.tim_repo("language:python topic:cli", gioi_han=50):
print(f"{repo.sao:6,} {repo.ten}")
| Tình huống | Retry? | Vì sao |
|---|---|---|
| Timeout, lỗi kết nối | ✅ Có | Sự cố mạng nhất thời |
429 Too Many Requests | ✅ Có, sau Retry-After | Server bảo ta chờ |
500, 502, 503, 504 | ✅ Có, backoff luỹ thừa | Server đang gặp vấn đề |
400 Bad Request | ❌ Không | Request của ta sai — gửi lại vẫn sai |
401, 403 | ❌ Không | Thiếu quyền — retry chỉ tốn thời gian |
404 Not Found | ❌ Không | Tài nguyên không tồn tại |
POST tạo dữ liệu | ⚠️ Cẩn thận | Có thể tạo bản ghi trùng — cần khoá idempotency |
Backoff luỹ thừa (1s, 2s, 4s, 8s) quan trọng hơn người ta tưởng: nếu 1000 client cùng retry sau đúng 1 giây, server vừa hồi phục lại bị đánh sập lần nữa. Hệ thống thật còn thêm jitter — cộng một lượng ngẫu nhiên nhỏ để các client không đồng bộ với nhau.
# ❌ TUYỆT ĐỐI KHÔNG — lộ ngay khi push lên GitHub
TOKEN = "ghp_abcd1234..."
# ✅ Đọc từ biến môi trường
import os
TOKEN = os.environ["GITHUB_TOKEN"] # KeyError nếu thiếu — fail fast
TOKEN = os.getenv("GITHUB_TOKEN") # None nếu thiếu — dùng khi tuỳ chọn
# ✅ Hoặc từ tệp .env (nhớ thêm .env vào .gitignore!)
from dotenv import load_dotenv # uv add python-dotenv
load_dotenv()
Khoá đã push lên GitHub coi như đã lộ vĩnh viễn — xoá commit không đủ, vì nó còn trong
lịch sử và trong bản sao của người khác. Cách xử lý duy nhất đúng là thu hồi khoá đó và tạo khoá
mới. Bật gitleaks hoặc GitHub secret scanning để phát hiện sớm.
6. Dự án 4 — Tự động hóa có lưu trữ
Ghép Dự án 3 với sqlite3 để tạo một công cụ chạy hằng ngày: lấy dữ liệu, lưu lại, so sánh với
lần trước, và báo cáo những gì thay đổi.
"""Lưu trữ bằng sqlite3 — database trong một tệp, có sẵn trong stdlib."""
from __future__ import annotations
import sqlite3
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import date
from pathlib import Path
LUOC_DO = """
CREATE TABLE IF NOT EXISTS anh_chup (
ngay TEXT NOT NULL,
ten_repo TEXT NOT NULL,
sao INTEGER NOT NULL,
PRIMARY KEY (ngay, ten_repo)
);
CREATE INDEX IF NOT EXISTS idx_repo ON anh_chup(ten_repo);
"""
@contextmanager
def mo_kho(duong_dan: Path) -> Iterator[sqlite3.Connection]:
"""Context manager: tự commit khi xong, rollback khi lỗi, luôn đóng."""
duong_dan.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(duong_dan)
conn.row_factory = sqlite3.Row # truy cập cột theo TÊN thay vì chỉ số
try:
conn.executescript(LUOC_DO)
yield conn
conn.commit()
except Exception:
conn.rollback()
raise # ném lại — đừng nuốt
finally:
conn.close()
def luu_anh_chup(conn: sqlite3.Connection, ngay: date,
du_lieu: list[tuple[str, int]]) -> int:
cur = conn.executemany(
"INSERT OR REPLACE INTO anh_chup (ngay, ten_repo, sao) VALUES (?, ?, ?)",
[(ngay.isoformat(), ten, sao) for ten, sao in du_lieu],
) # ⭐ (?, ?, ?) — KHÔNG BAO GIỜ nối chuỗi SQL
return cur.rowcount
def thay_doi(conn: sqlite3.Connection, hom_nay: date,
hom_truoc: date) -> list[sqlite3.Row]:
"""So sánh hai ngày, trả về repo tăng sao nhiều nhất."""
return conn.execute(
"""
SELECT a.ten_repo, b.sao AS truoc, a.sao AS nay, a.sao - b.sao AS tang
FROM anh_chup a
JOIN anh_chup b ON a.ten_repo = b.ten_repo
WHERE a.ngay = ? AND b.ngay = ? AND a.sao > b.sao
ORDER BY tang DESC
LIMIT 20
""",
(hom_nay.isoformat(), hom_truoc.isoformat()),
).fetchall()
?ten = nguoi_dung_nhap # giả sử: "x'; DROP TABLE anh_chup; --"
# ❌ THẢM HOẠ — chuỗi người dùng trở thành câu lệnh SQL
conn.execute(f"SELECT * FROM anh_chup WHERE ten_repo = '{ten}'")
# ✅ AN TOÀN — driver tự escape, dữ liệu luôn là dữ liệu
conn.execute("SELECT * FROM anh_chup WHERE ten_repo = ?", (ten,))
# ↑ tuple 1 phần tử: nhớ dấu phẩy!
Quy tắc không có ngoại lệ: giá trị luôn đi qua tham số ?, không bao giờ qua f-string
hay +. Điều này đúng với mọi cơ sở dữ liệu, mọi ngôn ngữ. (Tên bảng và tên cột
không tham số hoá được — nếu chúng đến từ người dùng, phải kiểm tra bằng danh sách trắng.)
import logging, sys
from datetime import date, timedelta
from logging.handlers import RotatingFileHandler
from pathlib import Path
from api_client.github import GitHubClient
from theo_doi.kho import mo_kho, luu_anh_chup, thay_doi
THU_MUC = Path(__file__).resolve().parent
KHO = THU_MUC / "du_lieu" / "theo_doi.db"
NHAT_KY = THU_MUC / "nhat_ky" / "chay.log"
def cau_hinh_log() -> None:
"""Log ra CẢ tệp lẫn màn hình — cron không có màn hình để đọc."""
NHAT_KY.parent.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
handlers=[
RotatingFileHandler(NHAT_KY, maxBytes=5*1024*1024, backupCount=3),
logging.StreamHandler(sys.stderr),
],
)
def main() -> int:
cau_hinh_log()
log = logging.getLogger("theo_doi")
hom_nay = date.today()
try:
with GitHubClient() as gh:
du_lieu = [(r.ten, r.sao)
for r in gh.tim_repo("language:python", gioi_han=100)]
log.info("Lấy được %d repo", len(du_lieu))
with mo_kho(KHO) as conn:
luu_anh_chup(conn, hom_nay, du_lieu)
tang = thay_doi(conn, hom_nay, hom_nay - timedelta(days=1))
if tang:
print(f"\n📈 Tăng sao nhiều nhất ({hom_nay}):")
for h in tang[:10]:
print(f" +{h['tang']:<5} {h['ten_repo']}")
else:
print("Chưa có dữ liệu hôm trước để so sánh.")
return 0
except KeyboardInterrupt:
log.info("Người dùng dừng")
return 130
except Exception:
log.exception("Chạy thất bại") # ⭐ log.exception giữ traceback
return 1
if __name__ == "__main__":
sys.exit(main())
# Linux / macOS — cron
$ crontab -e
# Chạy 8:00 sáng mỗi ngày. LƯU Ý 3 ĐIỂM:
# 1. Đường dẫn TUYỆT ĐỐI tới python trong venv — cron không biết venv
# 2. cd vào thư mục dự án trước — cron chạy ở $HOME
# 3. Chuyển hướng output — cron gửi mail nếu có stdout/stderr
0 8 * * * cd /home/anh/du-an && /home/anh/du-an/.venv/bin/python -m theo_doi >> /home/anh/du-an/cron.log 2>&1
# Kiểm tra trước khi tin: chạy thủ công với môi trường TRỐNG
$ env -i /home/anh/du-an/.venv/bin/python -m theo_doi
PATHkhác nhau. Cron cóPATHtối thiểu —pythoncó thể không tồn tại. Luôn dùng đường dẫn tuyệt đối tới interpreter trong venv.- Thư mục làm việc là
$HOME. Mọi đường dẫn tương đối đều sai. Đây là lý do dùngPath(__file__).resolve().parent. - Không có biến môi trường từ shell.
GITHUB_TOKENđặt trong.zshrcsẽ không có trong cron — phải khai báo trong crontab hoặc đọc từ tệp.env. - Không có terminal. Chương trình chờ
input()sẽ treo mãi mãi.
Mẹo chẩn đoán: chạy env -i /duong/dan/python -m goi — cờ -i xoá sạch môi
trường, mô phỏng điều kiện cron ngay tại terminal của bạn.
7. Checklist chất lượng — 12 mục
Trước khi coi một dự án là "xong", đối chiếu với danh sách sau:
| # | Hạng mục | Kiểm tra bằng | Chương |
|---|---|---|---|
| 1 | Cấu trúc src layout, có pyproject.toml | uv pip install -e . chạy được | 6 |
| 2 | Có __main__.py, main() trả int | python -m goi; echo $? | 6 |
| 3 | Mọi đường dẫn dùng pathlib, neo theo __file__ | Chạy từ thư mục khác vẫn đúng | 7 |
| 4 | Mọi tệp văn bản có encoding="utf-8" | python -X warn_default_encoding -m goi | 7 |
| 5 | Không có bare except, không nuốt lỗi | ruff check (rule E722, BLE) | 5 |
| 6 | Thao tác nguy hiểm có --dry-run mặc định | Tự thử gõ sai lệnh | 14 |
| 7 | Dùng logging, không print để debug | grep -rn "print(" src/ | 5 |
| 8 | Không có khoá bí mật trong mã nguồn | gitleaks detect | 14 |
| 9 | Type hint đầy đủ, mypy sạch | mypy --strict src/ | 12 |
| 10 | Có test cho phần logic thuần, độ phủ > 70% | pytest --cov=src | 13 |
| 11 | Lint + format tự động | ruff check . && ruff format --check . | 13 |
| 12 | README có: cài đặt, ví dụ dùng, cấu hình | Đưa cho người khác đọc và làm theo | — |
[project]
name = "theo-doi"
version = "1.0.0"
description = "Theo dõi repo GitHub hằng ngày"
requires-python = ">=3.12"
dependencies = ["httpx>=0.27", "python-dotenv>=1.0"]
[project.optional-dependencies]
dev = ["pytest>=8", "pytest-cov", "mypy>=1.11", "ruff>=0.6", "respx"]
[project.scripts]
theo-doi = "theo_doi.__main__:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "SIM", "BLE", "C4", "PTH"]
# BLE = blind-except, PTH = ép dùng pathlib thay os.path
[tool.mypy]
python_version = "3.12"
strict = true
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--cov=src --cov-report=term-missing"
Bài tập — 5 dự án mở rộng
Dự án A — Công cụ tìm tệp trùng lặp
CLI tim-trung [thu_muc] [--apply] [--giu {cu-nhat,moi-nhat,ngan-nhat}]:
- Quét đệ quy, nhóm tệp trùng nội dung (không phải trùng tên).
- Tối ưu ba tầng: lọc theo kích thước → băm 4KB đầu → băm toàn bộ. Chỉ băm đầy đủ khi thật cần.
- Mặc định chỉ báo cáo;
--applymới xoá, giữ lại bản theo tiêu chí--giu. - In tổng dung lượng tiết kiệm được.
Gợi ý
Dùng hashlib.blake2b (nhanh hơn md5 và an toàn hơn). Đọc theo khối
8192 byte để không nạp tệp lớn vào RAM. Cấu trúc:
defaultdict[int, list[Path]] theo kích thước → defaultdict[bytes, list[Path]]
theo băm. Tầng lọc theo kích thước loại bỏ >90% ứng viên mà gần như không tốn gì.
Dự án B — Theo dõi giá và cảnh báo
Công cụ chạy mỗi giờ, theo dõi giá sản phẩm từ một API công khai, lưu vào SQLite và cảnh báo khi giá giảm quá ngưỡng.
- Cấu hình bằng tệp
theo_doi.toml(đọc bằngtomllibcó sẵn từ Python 3.11). - Lưu lịch sử giá; tính giá trung bình 7 ngày.
- Cảnh báo khi giá hiện tại thấp hơn trung bình 7 ngày quá
X%. - Gửi thông báo qua webhook (Discord/Slack) — dùng
httpx.post. - Chống spam: không cảnh báo lại cùng một sản phẩm trong 24 giờ.
Gợi ý
Bảng canh_bao(san_pham, gui_luc) để chống spam; trước khi gửi thì
SELECT MAX(gui_luc) WHERE san_pham = ? và so với thời điểm hiện tại.
Đưa URL webhook vào biến môi trường, đừng để trong tệp TOML nếu tệp đó được commit.
Test phần tính toán bằng dữ liệu giá giả lập — hoàn toàn không cần mạng.
Dự án C — Trích xuất dữ liệu từ trang tin
Thu thập tiêu đề bài viết từ một trang tin công khai cho phép thu thập, xuất ra JSON và CSV.
- Kiểm tra
robots.txttrước mỗi URL; nghỉ ít nhất 1 giây giữa các request. - Tách
tai_trang()(có mạng) khỏiphan_tich()(thuần) để test được offline. - Lưu HTML thô vào
cache/— chạy lại lần hai không gọi mạng nữa. - Xử lý HTML thiếu trường: không được ném
AttributeErrorvìNone. - Viết test dùng HTML mẫu lưu sẵn trong
tests/du_lieu/.
Gợi ý
Cache đơn giản: tên tệp là hashlib.sha256(url.encode()).hexdigest()[:16] + ".html".
Kiểm tra cache_path.exists() trước khi gọi mạng. Thêm cờ --lam-moi để
bỏ qua cache. Với BeautifulSoup, select_one trả None khi không thấy —
luôn kiểm tra trước khi .get_text().
Dự án D — Sao lưu tăng dần
sao-luu <nguon> <dich> [--apply] [--giu-lai N]:
- Chỉ sao chép tệp mới hoặc đã đổi (so sánh
st_mtimevà kích thước; xác nhận bằng băm nếu nghi ngờ). - Ghi
manifest.jsonchứa danh sách tệp + băm + thời điểm sao lưu. - Giữ lại N bản sao lưu gần nhất, xoá bản cũ hơn.
- Có lệnh
--khoi-phuc <ngay>để phục hồi về một thời điểm. - Báo tiến độ khi xử lý nhiều tệp.
Gợi ý
Dùng shutil.copy2 (giữ metadata) thay shutil.copy. Tiết kiệm dung lượng
bằng hard link cho tệp không đổi: os.link(ban_cu, ban_moi) — đây chính là cách
Time Machine của macOS hoạt động. Kiểm tra dich không nằm bên trong
nguon, nếu không sẽ sao lưu đệ quy vô hạn.
Dự án E — Nâng cấp một dự án lên chuẩn 12 mục
Chọn một trong bốn dự án trên (hoặc một script cũ của bạn) và đưa nó qua đủ 12 mục trong bảng checklist. Ghi lại nhật ký từng bước:
- Mục nào tốn nhiều công nhất? Vì sao?
- Mục nào phát hiện ra bug thật? Bug gì?
- Sau khi
mypy --strictsạch, bạn phải sửa những chỗ nào? - Độ phủ test đạt bao nhiêu? Phần nào khó test nhất và bạn giải quyết ra sao?
Kết quả cuối: một repo có README, cài được bằng pip install, và CI xanh.
Gợi ý
Thứ tự làm hiệu quả nhất: (1) src layout + pyproject.toml trước —
mọi thứ khác dựa vào nó; (2) ruff — sửa nhanh, phát hiện nhiều;
(3) test cho phần logic thuần; (4) mypy --strict
cuối cùng vì nó đòi hỏi nhiều nhất.
Phần khó test nhất luôn là phần chạm I/O — đó là tín hiệu cho thấy nên tách nó ra khỏi logic,
đúng như mẫu "lập kế hoạch / thực thi" ở Dự án 1.
Quiz
Vì sao nên tách "lập kế hoạch" khỏi "thực thi" trong công cụ CLI?
Xem đáp án
Ba lý do. (1) --dry-run trở thành miễn phí — chỉ cần chạy phần lập kế
hoạch rồi in ra, thay vì duy trì một nhánh code song song luôn có nguy cơ lệch khỏi nhánh thật.
(2) Test dễ hơn hẳn — hàm lập kế hoạch là hàm thuần, nhận trạng thái và trả về danh
sách việc; test nó không cần tệp thật, không cần mạng, chạy trong mili-giây.
(3) Kiểm tra được trước khi làm — phát hiện xung đột tên, thiếu quyền, hoặc kế hoạch
rỗng trước khi thay đổi bất cứ thứ gì trên đĩa, nên không bao giờ để lại trạng thái nửa vời.
Vì sao main() nên nhận argv và trả về int?
Xem đáp án
Nhận argv để test được: gọi thẳng main(["--apply", "/tmp/x"])
trong pytest, không cần giả lập sys.argv hay chạy tiến trình con. Mặc định
argv=None rồi argv if argv is not None else sys.argv[1:] để dùng bình
thường vẫn tiện.
Trả int vì đó là mã thoát: sys.exit(main()). Shell script
và CI đọc $? để quyết định chạy tiếp hay dừng. Một CLI luôn trả 0 dù thất bại là CLI
không dùng được trong pipeline — và tệ hơn, nó khiến CI báo xanh trong khi công việc đã hỏng.
Lỗi nào nên retry và lỗi nào không? Vì sao cần backoff luỹ thừa?
Xem đáp án
Nên retry — lỗi tạm thời, tức lỗi mà việc gửi lại y nguyên request có cơ
hội thành công: timeout, lỗi kết nối, 429 (chờ theo Retry-After), và
5xx (server đang gặp vấn đề).
Không retry — lỗi vĩnh viễn do phía ta: 400 (request sai),
401/403 (thiếu quyền), 404 (không tồn tại). Gửi lại vẫn hỏng,
chỉ tốn thời gian và làm phiền server.
Backoff luỹ thừa (1s, 2s, 4s, 8s) cần thiết vì nếu hàng nghìn client cùng retry sau
đúng một giây, server vừa hồi phục lại bị đánh sập ngay — hiện tượng "thundering herd". Hệ thống
thật còn thêm jitter (một lượng ngẫu nhiên nhỏ) để các client không đồng bộ với nhau. Riêng
POST tạo dữ liệu cần cẩn trọng: retry có thể tạo bản ghi trùng nếu API không hỗ trợ
khoá idempotency.
Vì sao script chạy tay được nhưng cron thì không?
Xem đáp án
Vì cron chạy trong môi trường tối thiểu, khác hẳn shell tương tác của bạn:
PATHngắn —pythoncó thể không tìm thấy, và chắc chắn không phải python trong venv. Phải dùng đường dẫn tuyệt đối/duong/dan/.venv/bin/python.- Thư mục làm việc là
$HOME— mọi đường dẫn tương đối đều sai. Đây là lý do neo theoPath(__file__).resolve().parent. - Không có biến môi trường từ
.zshrc/.bashrc—API_TOKENbiến mất. Phải khai báo trong crontab hoặc đọc từ tệp.env. - Không có terminal —
input()treo mãi mãi; output không chuyển hướng sẽ bị cron gửi thành email.
Cách chẩn đoán nhanh: env -i /duong/dan/.venv/bin/python -m goi — cờ -i xoá
sạch môi trường, tái hiện điều kiện cron ngay tại terminal.
Vì sao conn.execute(f"... WHERE ten = '{x}'") là lỗi nghiêm trọng?
Xem đáp án
Đó là lỗ hổng SQL injection. Chuỗi do người dùng cung cấp được ghép thẳng vào câu
lệnh SQL, nên nó không còn là dữ liệu mà trở thành mã lệnh. Với
x = "'; DROP TABLE users; --", câu lệnh chạy ra thành hai lệnh, lệnh thứ hai xoá bảng.
Kẻ tấn công cũng có thể dùng UNION SELECT để đọc dữ liệu ngoài phạm vi cho phép.
Cách đúng duy nhất là dùng tham số: conn.execute("... WHERE ten = ?", (x,)).
Driver gửi câu lệnh và dữ liệu riêng biệt tới database, nên dữ liệu không bao giờ được diễn
dịch thành mã. Lưu ý dấu phẩy trong (x,) — thiếu nó thì đó không phải tuple.
Tên bảng và tên cột không tham số hoá được; nếu chúng đến từ người dùng, phải kiểm tra bằng
danh sách trắng cố định.
Trước khi thu thập dữ liệu từ một trang web, cần kiểm tra những gì?
Xem đáp án
Theo thứ tự ưu tiên: (1) Có API chính thức không? Nếu có thì luôn dùng API — ổn định
hơn, nhanh hơn, và được phép rõ ràng. (2) robots.txt tại
https://ten-mien/robots.txt — dùng urllib.robotparser để kiểm tra bằng
code, và khi không đọc được thì mặc định là không cho phép. (3) Điều khoản sử
dụng — nhiều trang cấm thu thập tự động, vi phạm có thể dẫn tới hậu quả pháp lý.
(4) Tần suất — nghỉ giữa các request, tôn trọng Crawl-delay nếu có.
(5) User-Agent trung thực — nêu tên công cụ và cách liên hệ, đừng giả
mạo trình duyệt. (6) Dữ liệu cá nhân — không thu thập nếu không có cơ sở pháp lý.
Vì sao dùng logging thay vì print trong sản phẩm?
Xem đáp án
Năm lý do: (1) Cấp độ — người dùng bật -v để xem DEBUG, mặc định chỉ
INFO trở lên; với print bạn phải tự viết cơ chế đó. (2) Nhiều đích đến
— cùng lúc ghi ra tệp (có xoay vòng theo dung lượng) và ra màn hình; cron cần tệp vì không ai ngồi
nhìn terminal lúc 3 giờ sáng. (3) Ngữ cảnh tự động — thời gian, tên module, cấp độ
được thêm vào mọi dòng mà không phải gõ tay. (4) Traceback —
log.exception() đính kèm nguyên vẹn vết ngăn xếp, trong khi
print(f"lỗi: {e}") vứt bỏ nó. (5) Tắt được theo module — thư viện dùng
logging.getLogger(__name__) để ứng dụng chủ quyết định hiển thị gì.
Ngoại lệ hợp lý: print vẫn đúng cho kết quả mà người dùng cần đọc hoặc
chuyển qua ống lệnh — logging dành cho thông tin chẩn đoán, không phải đầu ra chính của chương trình.
Bạn lỡ commit một API token lên GitHub. Xử lý thế nào?
Xem đáp án
Thu hồi token ngay lập tức và tạo token mới. Đây là bước đầu tiên và bắt buộc — mọi việc khác chỉ là dọn dẹp.
Xoá commit không đủ, vì: token vẫn nằm trong lịch sử git; đã có trong bản sao của bất kỳ ai
từng clone/fork; các bot quét GitHub công khai thường tìm thấy khoá mới
trong vài giây; và GitHub còn giữ commit qua API một thời gian kể cả sau khi
force-push.
Sau khi đã thu hồi, mới dọn lịch sử bằng git filter-repo hoặc BFG (lưu ý điều này viết
lại lịch sử, cần phối hợp với cả nhóm). Phòng ngừa về sau: đọc khoá từ biến môi trường, thêm
.env vào .gitignore, cài gitleaks làm pre-commit hook
(xem Chương 13), và bật secret scanning của GitHub.
Tổng kết
Sau chương 14 — chương cuối của Python sub-pillar — bạn đã master:
- Ranh giới script/sản phẩm: 10 khía cạnh phải nâng cấp, và nguyên tắc "thao tác không hoàn tác được phải khó thực hiện hơn".
argparse: positional, cờ,choices,nargs, nhóm loại trừ, sub-command vớiset_defaults(ham=...).- Mẫu lập kế hoạch / thực thi — cho
--dry-runmiễn phí và test dễ. - Thu thập dữ liệu web:
robots.txt, giới hạn tần suất, User-Agent trung thực, tách hàm thuần khỏi mạng. - REST API: xác thực qua biến môi trường, phân trang bằng generator, retry đúng loại lỗi, backoff luỹ thừa.
sqlite3: context manager tự commit/rollback,row_factory, và tham số?chống SQL injection.- Chạy định kỳ: cron và bốn cái bẫy môi trường;
loggingcó xoay vòng tệp. - Checklist 12 mục — thước đo khách quan cho "dự án đã xong".
Hết sub-pillar Python — đi đâu tiếp?
Bạn đã đi qua 14 chương: từ cài đặt, cú pháp, cấu trúc dữ liệu, hàm, ngoại lệ, module, tệp tin, OOP, generator, decorator, async, typing, testing, tới dự án hoàn chỉnh. Ba hướng đi tiếp tự nhiên:
- Web backend — FastAPI (tận dụng trực tiếp type hint ở Chương 12 và async ở Chương 11) hoặc Django (nếu cần ORM và admin sẵn có).
- Dữ liệu —
polarshoặcpandas,numpy, và các thư viện trực quan hoá. - Đi sâu vào ngôn ngữ — metaclass, descriptor,
__slots__, mở rộng bằng C/Rust, và profiling hiệu năng.
Dù chọn hướng nào, nền tảng ở 14 chương này đều dùng được nguyên vẹn — framework thay đổi, ngôn ngữ thì không.
Kết nối
- Chương 5 (Exceptions) —
ExceptionGroupgom lỗi retry;log.exception; mã thoát theo loại lỗi. - Chương 6 (Modules) — src layout,
__main__.py,[project.scripts]tạo lệnh terminal. - Chương 7 (File I/O) —
pathlib, JSON, CSV, encoding: nền tảng của cả bốn dự án. - Chương 8 (OOP) —
@dataclass(frozen=True)cho model,@classmethodlàm alternative constructor,__enter__/__exit__cho client. - Chương 9 (Generators) — phân trang API bằng generator, xử lý tệp lớn theo dòng.
- Chương 10 (Decorators & CM) —
@contextmanagercho kết nối database, decorator@retry. - Chương 11 (Async) — nâng cấp: thu thập 100 URL song song bằng
asyncio.TaskGroup+httpx.AsyncClient. - Chương 12 (Typing) —
mypy --strictlà mục 9 của checklist. - Chương 13 (Testing) —
pytest,respxđể giả lập HTTP,tmp_pathfixture cho test tệp. - CLI pillar — cron, biến môi trường, mã thoát, chuyển hướng output.
- Database pillar — SQL, chỉ mục, giao dịch: nền cho phần
sqlite3. - DevOps pillar — đóng gói Docker, CI/CD cho chính dự án bạn vừa xây.