📘 Phần 2 · Lập Trình Tools · Chương 4

Python Tools với AI

Xây dựng CLI tools, file automation, web scrapers và API clients chuyên nghiệp — tất cả với sức mạnh của AI và Python.

6 giờ học
📝 5 bài học
🎯 3 dự án mini
📊 Mức độ: Trung cấp

🎯 Mục tiêu học tập

  • Tạo CLI tool chuyên nghiệp với Python và Click library
  • Xây dựng file organizer tự động bằng pathlib + AI
  • Viết web scraper với requests và BeautifulSoup
  • Tích hợp REST API bên ngoài vào tool Python
  • Quản lý virtual environment và dependencies đúng chuẩn
1

Bài 4.1 — Python Fundamentals trong VS Code

Virtual Environment (venv)
Môi trường Python cô lập cho từng project. Mỗi project có bộ packages riêng, không ảnh hưởng nhau. Luôn bật venv trước khi làm việc.
pip
Package manager của Python. pip install package, pip freeze > requirements.txt để lưu dependencies, pip install -r requirements.txt để restore.
Click
Framework Python phổ biến nhất để build CLI tools. Dùng decorators: @click.command(), @click.option(), @click.argument().
requests / httpx
Thư viện HTTP cho Python. requests: đơn giản, sync. httpx: hỗ trợ async, type hints tốt hơn, tương lai của requests.
BeautifulSoup
Thư viện parse HTML. Dùng với requests để scrape web. Selector: soup.find(), soup.find_all(), soup.select('css.selector').

Virtual Environment — Không thể bỏ qua

Virtual environment (venv) cô lập dependencies của mỗi project — như Docker nhưng nhẹ hơn nhiều. Luôn luôn dùng venv cho mọi Python project.

bash
# Tạo project mới
mkdir my-python-tool
cd my-python-tool

# Tạo virtual environment
python -m venv venv

# Kích hoạt venv
# Windows:
venv\Scripts\activate
# Mac/Linux:
source venv/bin/activate

# Dấu hiệu venv đang bật: (venv) xuất hiện ở đầu dòng terminal
# (venv) C:\my-python-tool>

# Cài package
pip install requests click rich

# Lưu dependencies
pip freeze > requirements.txt

# Tắt venv khi xong
deactivate
💡
VS Code tự nhận diện venv

Khi bạn tạo venv trong folder project, VS Code sẽ hỏi "Use this virtual environment?" — nhấn Yes. Hoặc nhấn vào Python interpreter ở status bar để chọn manually.

Cấu trúc project Python chuẩn

text — Cấu trúc folder
my-tool/
├── venv/              # Virtual environment (KHÔNG commit lên git)
├── src/
│   ├── __init__.py
│   └── main.py        # Entry point
├── tests/
│   └── test_main.py
├── .gitignore          # Thêm: venv/, __pycache__/, *.pyc
├── requirements.txt    # Dependencies
└── README.md

2

Bài 4.2 — CLI Tool đầu tiên với Click

So Sánh Thư Viện CLI Python

Thư việnCú phápĐộ phức tạpKhi nào dùng
argparseVerbose, nhiều boilerplate⭐⭐Script đơn giản, không cần cài thêm
ClickDecorator-based, gọn⭐⭐⭐CLI tool chuyên nghiệp — khuyên dùng
TyperType hints tự động, minimal code⭐⭐Dự án dùng TypeScript-style, FastAPI ecosystem
HTTP Libraries
requestsSimple, sync onlyScript nhanh, không cần async
httpxrequests-compatible + async⭐⭐App cần async, type hints, HTTP/2
aiohttpFull async, cao cấp hơn⭐⭐⭐High-performance async scraping

Click là thư viện Python phổ biến nhất để xây dựng CLI (Command Line Interface) tool. Với AI hỗ trợ, chúng ta có thể tạo CLI tool chuyên nghiệp rất nhanh.

Cài đặt và Hello World

bash
pip install click rich
python — src/main.py
import click
from rich.console import Console
from rich.table import Table

console = Console()

@click.group()
@click.version_option("1.0.0")
def cli():
    """🚀 My First CLI Tool — built with Python + AI"""
    pass

@cli.command()
@click.option("--name", "-n", prompt="Nhập tên của bạn", help="Tên người dùng")
@click.option("--count", "-c", default=1, help="Số lần chào")
def greet(name: str, count: int):
    """Chào mừng người dùng."""
    for i in range(count):
        console.print(f"[bold green]👋 Xin chào, {name}![/bold green]")

@cli.command()
@click.argument("numbers", nargs=-1, type=float)
def calculate(numbers):
    """Tính tổng một dãy số."""
    if not numbers:
        console.print("[red]Cần ít nhất 1 số[/red]")
        return
    total = sum(numbers)
    console.print(f"[cyan]Tổng: {total}[/cyan]")
    console.print(f"[cyan]Trung bình: {total / len(numbers):.2f}[/cyan]")

if __name__ == "__main__":
    cli()
bash — Chạy thử
# Chạy
python src/main.py --help
python src/main.py greet --name "Minh" --count 3
python src/main.py calculate 10 20 30.5

Dự án Mini: File Renamer Tool

Cùng dùng AI để xây dựng tool đổi tên file hàng loạt. Đây là prompt gửi cho Copilot:

text — Prompt cho Copilot Chat
Tôi đang dùng Python với Click và Rich.
Viết CLI command "rename" nhận:
  --pattern: glob pattern để chọn files (vd: "*.jpg")
  --prefix: tiền tố thêm vào tên file (optional)
  --suffix: hậu tố thêm vào trước extension (optional)
  --dry-run: chỉ preview không thực sự đổi tên
  --folder: folder để thao tác (default: thư mục hiện tại)

Dùng pathlib.Path, hiển thị preview table bằng Rich trước khi rename,
hỏi xác nhận "Bạn có chắc? [y/N]" trước khi thực thi.
Có error handling đầy đủ.
python — Kết quả sau khi AI generate (đã review)
@cli.command()
@click.option("--pattern", "-p", default="*", help="Glob pattern (vd: *.jpg)")
@click.option("--prefix",  default="", help="Tiền tố thêm vào")
@click.option("--suffix",  default="", help="Hậu tố thêm vào (trước extension)")
@click.option("--folder",  "-f", default=".", type=click.Path(exists=True))
@click.option("--dry-run", is_flag=True, help="Chỉ preview, không thực thi")
def rename(pattern, prefix, suffix, folder, dry_run):
    """Đổi tên file hàng loạt."""
    from pathlib import Path
    
    folder_path = Path(folder)
    files = list(folder_path.glob(pattern))
    
    if not files:
        console.print(f"[yellow]Không tìm thấy file nào với pattern '{pattern}'[/yellow]")
        return
    
    # Hiển thị preview table
    table = Table(title="Preview - File sẽ được đổi tên")
    table.add_column("Tên cũ", style="red")
    table.add_column("Tên mới", style="green")
    
    renames = []
    for f in files:
        stem = f.stem          # Tên không có extension
        ext  = f.suffix        # Extension (.jpg, .txt, ...)
        new_name = f"{prefix}{stem}{suffix}{ext}"
        new_path = f.parent / new_name
        renames.append((f, new_path))
        table.add_row(f.name, new_name)
    
    console.print(table)
    
    if dry_run:
        console.print("[yellow]🔍 Dry-run mode: không có thay đổi nào được thực thi.[/yellow]")
        return
    
    # Xác nhận
    if not click.confirm(f"\nĐổi tên {len(renames)} file?"):
        console.print("Đã hủy.")
        return
    
    # Thực thi
    success = 0
    for old_path, new_path in renames:
        try:
            old_path.rename(new_path)
            success += 1
        except Exception as e:
            console.print(f"[red]Lỗi khi đổi tên {old_path.name}: {e}[/red]")
    
    console.print(f"[green]✅ Đã đổi tên thành công {success}/{len(renames)} file[/green]")

3

Bài 4.3 — File Automation Tool

Dự án: File Organizer tự động

Tool này tự động sắp xếp files trong một folder theo loại file (ảnh, video, tài liệu, code...).

python — organizer.py
"""
File Organizer Tool — tự động phân loại file theo extension
Được tạo với Python + GitHub Copilot
"""
from pathlib import Path
from datetime import datetime
import shutil
import click
from rich.console import Console
from rich.progress import track

console = Console()

# Mapping extension → folder
FILE_CATEGORIES = {
    "Ảnh":      [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg", ".webp"],
    "Video":    [".mp4", ".avi", ".mov", ".mkv", ".wmv", ".flv"],
    "Âm thanh": [".mp3", ".wav", ".flac", ".aac", ".ogg"],
    "Tài liệu": [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"],
    "Code":     [".py", ".js", ".ts", ".html", ".css", ".json", ".yaml", ".yml"],
    "Nén":      [".zip", ".rar", ".7z", ".tar", ".gz"],
    "Khác":     [],  # Fallback
}

def get_category(extension: str) -> str:
    """Xác định category của file dựa theo extension."""
    ext_lower = extension.lower()
    for category, extensions in FILE_CATEGORIES.items():
        if ext_lower in extensions:
            return category
    return "Khác"

@click.command()
@click.argument("source_folder", type=click.Path(exists=True))
@click.option("--dest", "-d", default=None, help="Thư mục đích (mặc định: tạo subfolder trong source)")
@click.option("--dry-run", is_flag=True, help="Preview không thực thi")
@click.option("--by-date", is_flag=True, help="Tổ chức theo năm/tháng")
def organize(source_folder, dest, dry_run, by_date):
    """Tự động tổ chức files trong SOURCE_FOLDER theo loại file."""
    source = Path(source_folder)
    destination = Path(dest) if dest else source / "_organized"
    
    # Collect all files (không include subdirectory files)
    files = [f for f in source.iterdir() if f.is_file()]
    
    if not files:
        console.print("[yellow]Không có file nào trong thư mục này.[/yellow]")
        return
    
    console.print(f"[cyan]Tìm thấy {len(files)} files trong '{source}'[/cyan]")
    
    moved = 0
    for file in track(files, description="Đang phân loại..."):
        category = get_category(file.suffix)
        
        if by_date:
            # Tổ chức theo năm/tháng
            modified = datetime.fromtimestamp(file.stat().st_mtime)
            target_dir = destination / category / str(modified.year) / f"{modified.month:02d}"
        else:
            target_dir = destination / category
        
        target_path = target_dir / file.name
        
        if not dry_run:
            target_dir.mkdir(parents=True, exist_ok=True)
            # Nếu file trùng tên, thêm số
            counter = 1
            while target_path.exists():
                stem = file.stem
                target_path = target_dir / f"{stem}_{counter}{file.suffix}"
                counter += 1
            shutil.move(str(file), str(target_path))
        else:
            console.print(f"  [dim]{file.name}[/dim] → [green]{category}/{file.name}[/green]")
        
        moved += 1
    
    if dry_run:
        console.print(f"\n[yellow]Dry-run: {moved} files sẽ được di chuyển[/yellow]")
    else:
        console.print(f"\n[green]✅ Đã tổ chức {moved} files vào '{destination}'[/green]")

if __name__ == "__main__":
    organize()
bash — Cách dùng
# Preview trước khi thực thi
python organizer.py ~/Downloads --dry-run

# Tổ chức vào thư mục mặc định
python organizer.py ~/Downloads

# Tổ chức theo ngày tháng, chỉ định thư mục đích
python organizer.py ~/Downloads --dest ~/Organized --by-date

4

Bài 4.4 — Web Scraping Tool

⚠️
Lưu ý đạo đức khi scraping

Trước khi scrape, hãy kiểm tra robots.txt của website (VD: example.com/robots.txt). Không scrape website không cho phép. Thêm delay giữa các request. Không làm quá tải server.

bash
pip install requests beautifulsoup4 lxml
python — news_scraper.py
"""
News Headline Scraper — scrape tiêu đề tin tức
"""
import time
import json
import requests
from bs4 import BeautifulSoup
from datetime import datetime
from pathlib import Path
import click
from rich.console import Console
from rich.table import Table

console = Console()

# Headers để tránh bị block
HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/120.0.0.0 Safari/537.36"
    )
}

def scrape_vnexpress_headlines(limit: int = 20) -> list[dict]:
    """Scrape headlines từ VnExpress (cho mục đích học tập)."""
    url = "https://vnexpress.net"
    
    try:
        response = requests.get(url, headers=HEADERS, timeout=10)
        response.raise_for_status()
        response.encoding = "utf-8"
    except requests.RequestException as e:
        console.print(f"[red]Lỗi kết nối: {e}[/red]")
        return []
    
    soup = BeautifulSoup(response.text, "lxml")
    
    articles = []
    # Tìm tất cả link bài viết
    for a_tag in soup.select("h3.title-news a, h2.title-news a")[:limit]:
        title = a_tag.get_text(strip=True)
        link  = a_tag.get("href", "")
        
        if title and link:
            articles.append({
                "title": title,
                "url": link,
                "scraped_at": datetime.now().isoformat()
            })
    
    return articles

@click.command()
@click.option("--limit", "-l", default=10, help="Số bài viết lấy về")
@click.option("--output", "-o", default=None, help="Lưu ra file JSON")
@click.option("--format", "fmt", type=click.Choice(["table", "json"]), default="table")
def scrape(limit, output, fmt):
    """Scrape tin tức mới nhất từ VnExpress."""
    console.print(f"[cyan]🔍 Đang scrape {limit} bài viết...[/cyan]")
    
    articles = scrape_vnexpress_headlines(limit)
    
    if not articles:
        console.print("[red]Không lấy được bài viết nào.[/red]")
        return
    
    if fmt == "table":
        table = Table(title=f"Tin tức VnExpress — {datetime.now().strftime('%d/%m/%Y %H:%M')}")
        table.add_column("#", style="dim", width=4)
        table.add_column("Tiêu đề", style="white", max_width=70)
        table.add_column("URL", style="blue dim", max_width=40)
        
        for i, article in enumerate(articles, 1):
            table.add_row(str(i), article["title"], article["url"][:40] + "...")
        
        console.print(table)
    else:
        console.print_json(json.dumps(articles, ensure_ascii=False, indent=2))
    
    if output:
        output_path = Path(output)
        output_path.write_text(
            json.dumps(articles, ensure_ascii=False, indent=2),
            encoding="utf-8"
        )
        console.print(f"[green]✅ Đã lưu {len(articles)} bài vào '{output}'[/green]")

if __name__ == "__main__":
    scrape()

5

Bài 4.5 — API Integration Tool

Dự án thực tế: Xây dựng Weather CLI Tool — lấy thông tin thời tiết từ OpenWeatherMap API.

bash
pip install requests python-dotenv rich click
text — .env (KHÔNG commit file này)
OPENWEATHER_API_KEY=your_api_key_here
python — weather.py
"""
Weather CLI Tool — xem thời tiết từ command line
API: OpenWeatherMap (free tier: 60 calls/phút)
"""
import os
import requests
from rich.console import Console
from rich.panel import Panel
from rich.columns import Columns
from rich.text import Text
import click
from dotenv import load_dotenv

load_dotenv()  # Load .env file
console = Console()

WEATHER_API_BASE = "https://api.openweathermap.org/data/2.5"

WEATHER_ICONS = {
    "Clear":        "☀️",
    "Clouds":       "☁️",
    "Rain":         "🌧️",
    "Drizzle":      "🌦️",
    "Thunderstorm": "⛈️",
    "Snow":         "❄️",
    "Mist":         "🌫️",
    "Fog":          "🌫️",
}

def get_weather(city: str, api_key: str) -> dict:
    """Lấy thông tin thời tiết hiện tại."""
    params = {
        "q": city,
        "appid": api_key,
        "units": "metric",
        "lang": "vi",
    }
    response = requests.get(f"{WEATHER_API_BASE}/weather", params=params, timeout=10)
    
    if response.status_code == 404:
        raise ValueError(f"Không tìm thấy thành phố: {city}")
    if response.status_code == 401:
        raise ValueError("API key không hợp lệ")
    
    response.raise_for_status()
    return response.json()

def format_weather_display(data: dict) -> None:
    """Hiển thị thời tiết theo format đẹp."""
    city        = data["name"]
    country     = data["sys"]["country"]
    temp        = data["main"]["temp"]
    feels_like  = data["main"]["feels_like"]
    humidity    = data["main"]["humidity"]
    description = data["weather"][0]["description"].capitalize()
    main_weather= data["weather"][0]["main"]
    wind_speed  = data["wind"]["speed"]
    visibility  = data.get("visibility", 0) / 1000  # Convert to km
    
    icon = WEATHER_ICONS.get(main_weather, "🌡️")
    
    content = Text()
    content.append(f"\n  {icon}  {description}\n\n", style="bold")
    content.append(f"  🌡️  Nhiệt độ:    ", style="dim")
    content.append(f"{temp:.1f}°C", style="bold yellow")
    content.append(f"  (cảm giác {feels_like:.1f}°C)\n")
    content.append(f"  💧  Độ ẩm:      ", style="dim")
    content.append(f"{humidity}%\n", style="cyan")
    content.append(f"  💨  Gió:        ", style="dim")
    content.append(f"{wind_speed} m/s\n", style="green")
    content.append(f"  👁️   Tầm nhìn:  ", style="dim")
    content.append(f"{visibility:.1f} km\n", style="white")
    
    console.print(Panel(
        content,
        title=f"[bold]{city}, {country}[/bold]",
        border_style="blue"
    ))

@click.command()
@click.argument("city")
@click.option("--api-key", envvar="OPENWEATHER_API_KEY", help="OpenWeatherMap API Key")
def weather(city, api_key):
    """Xem thời tiết hiện tại của một CITY."""
    if not api_key:
        console.print("[red]❌ Cần OPENWEATHER_API_KEY. Set trong .env hoặc --api-key[/red]")
        return
    
    try:
        data = get_weather(city, api_key)
        format_weather_display(data)
    except ValueError as e:
        console.print(f"[red]❌ {e}[/red]")
    except requests.RequestException as e:
        console.print(f"[red]❌ Lỗi kết nối: {e}[/red]")

if __name__ == "__main__":
    weather()
bash — Chạy thử
python weather.py "Hanoi"
python weather.py "Ho Chi Minh City"
python weather.py "London"

6

Bài 4.6 — Dự Án Thực Hành: Xây Dựng File Organizer CLI Hoàn Chỉnh

Trong bài này chúng ta sẽ xây dựng hoàn toàn từ đầu một công cụ CLI dọn dẹp thư mục — tự động phân loại file theo đuôi mở rộng, hỗ trợ preview trước khi di chuyển, có undo, và output màu sắc đẹp. Đây là dự án thực tế giống hệt các tool bạn sẽ làm cho công việc.

📦
Kết quả cuối cùng của bài này

Tool organizer với 4 lệnh: sort (phân loại), preview (xem trước), undo (hoàn tác), stats (thống kê). Chạy được từ terminal bất kỳ đâu.

Bước 1 — Tạo Cấu Trúc Thư Mục Dự Án

Mở terminal (trong VS Code nhấn Ctrl+`) và chạy lần lượt từng lệnh:

bash — Tạo project
# Tạo thư mục dự án
mkdir file-organizer
cd file-organizer

# Tạo virtual environment (bắt buộc — cô lập dependencies)
python -m venv venv

# Kích hoạt virtual environment
# Windows:
venv\Scripts\activate
# macOS/Linux:
source venv/bin/activate

# Kiểm tra đã kích hoạt (terminal sẽ hiển thị "(venv)" ở đầu dòng)
python --version
bash — Cài dependencies
# Cài các thư viện cần thiết
pip install click rich

# Lưu danh sách thư viện vào file requirements.txt
pip freeze > requirements.txt

# Kiểm tra nội dung requirements.txt
cat requirements.txt
# Sẽ thấy: click==8.x.x  và  rich==13.x.x
bash — Tạo cấu trúc file
# Tạo các file cần thiết (Windows dùng ni thay touch)
# Windows:
ni organizer.py, config.py, README.md

# macOS/Linux:
touch organizer.py config.py README.md

Cấu trúc thư mục sau khi tạo xong:

Cấu trúc thư mục dự án
file-organizer/
├── venv/                   ← Virtual environment (KHÔNG commit lên Git)
├── organizer.py            ← File chính chứa toàn bộ logic
├── config.py               ← Cấu hình các loại file
├── requirements.txt        ← Danh sách dependencies
└── README.md               ← Tài liệu hướng dẫn

Bước 2 — Viết File config.py

Mở config.py và dán nội dung sau (hoặc nhờ Copilot generate từ comment):

python — config.py
# config.py — Cấu hình phân loại file theo đuôi mở rộng
# Prompt cho Copilot: "Tạo dict phân loại file extensions thành các nhóm"

FILE_CATEGORIES = {
    "Hình ảnh": {
        "extensions": [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg", ".ico", ".tiff"],
        "folder": "Images",
        "color": "bright_cyan"
    },
    "Video": {
        "extensions": [".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".webm", ".m4v"],
        "folder": "Videos",
        "color": "bright_magenta"
    },
    "Âm thanh": {
        "extensions": [".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a", ".wma"],
        "folder": "Audio",
        "color": "bright_yellow"
    },
    "Tài liệu": {
        "extensions": [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods"],
        "folder": "Documents",
        "color": "bright_green"
    },
    "Code": {
        "extensions": [".py", ".js", ".ts", ".html", ".css", ".json", ".xml", ".yaml", ".yml", ".sh", ".bash", ".java", ".cpp", ".c", ".h"],
        "folder": "Code",
        "color": "bright_blue"
    },
    "Lưu trữ": {
        "extensions": [".zip", ".rar", ".7z", ".tar", ".gz", ".bz2"],
        "folder": "Archives",
        "color": "yellow"
    },
    "File khác": {
        "extensions": [],  # Default cho các file không khớp
        "folder": "Others",
        "color": "white"
    }
}

# File lưu lịch sử để hỗ trợ undo
HISTORY_FILE = ".organizer_history.json"

Bước 3 — Viết File organizer.py (Logic Chính)

Đây là file quan trọng nhất. Copy từng phần và để Copilot hỗ trợ hoàn thiện:

python — organizer.py — Phần 1: Import và setup
#!/usr/bin/env python3
"""
File Organizer CLI — Tự động phân loại file trong thư mục
Tác giả: ThanhDoIT | Phiên bản: 1.0.0
"""

import click
import json
import shutil
from pathlib import Path
from datetime import datetime
from rich.console import Console
from rich.table import Table
from rich.progress import track
from rich.panel import Panel
from rich import print as rprint

from config import FILE_CATEGORIES, HISTORY_FILE

console = Console()
python — organizer.py — Phần 2: Helper functions
def get_category(file_ext: str) -> dict:
    """Tìm danh mục cho một đuôi file."""
    ext_lower = file_ext.lower()
    for category_name, info in FILE_CATEGORIES.items():
        if ext_lower in info["extensions"]:
            return category_name, info
    return "File khác", FILE_CATEGORIES["File khác"]


def save_history(moves: list, source_dir: str):
    """Lưu lịch sử di chuyển file để hỗ trợ undo."""
    history_path = Path(source_dir) / HISTORY_FILE
    history = {
        "timestamp": datetime.now().isoformat(),
        "source_dir": source_dir,
        "moves": moves
    }
    with open(history_path, "w", encoding="utf-8") as f:
        json.dump(history, f, ensure_ascii=False, indent=2)


def load_history(source_dir: str) -> dict | None:
    """Đọc file lịch sử."""
    history_path = Path(source_dir) / HISTORY_FILE
    if not history_path.exists():
        return None
    with open(history_path, "r", encoding="utf-8") as f:
        return json.load(f)
python — organizer.py — Phần 3: CLI commands
@click.group()
@click.version_option(version="1.0.0", prog_name="organizer")
def cli():
    """🗂  File Organizer — Tự động dọn dẹp và phân loại file"""
    pass


@cli.command()
@click.argument("directory", default=".", type=click.Path(exists=True))
def preview(directory):
    """Xem trước những gì sẽ bị di chuyển (không thực sự di chuyển)."""
    source = Path(directory).resolve()
    files = [f for f in source.iterdir() if f.is_file() and not f.name.startswith(".")]

    if not files:
        console.print("[yellow]⚠ Không tìm thấy file nào trong thư mục.[/yellow]")
        return

    table = Table(title=f"Preview — {source}", show_lines=True)
    table.add_column("File", style="cyan")
    table.add_column("Loại", style="green")
    table.add_column("Sẽ di chuyển đến", style="yellow")
    table.add_column("Kích thước", justify="right")

    for file in sorted(files):
        cat_name, cat_info = get_category(file.suffix)
        size_kb = file.stat().st_size / 1024
        size_str = f"{size_kb:.1f} KB" if size_kb < 1024 else f"{size_kb/1024:.1f} MB"
        table.add_row(file.name, cat_name, cat_info["folder"], size_str)

    console.print(table)
    console.print(f"\n[dim]Tổng: {len(files)} file sẽ được phân loại[/dim]")


@cli.command()
@click.argument("directory", default=".", type=click.Path(exists=True))
@click.option("--yes", "-y", is_flag=True, help="Xác nhận không hỏi lại")
def sort(directory, yes):
    """Phân loại và di chuyển file vào các thư mục con."""
    source = Path(directory).resolve()
    files = [f for f in source.iterdir() if f.is_file() and not f.name.startswith(".")]

    if not files:
        console.print("[yellow]⚠ Không tìm thấy file nào.[/yellow]")
        return

    console.print(Panel(f"[bold]Sẽ phân loại {len(files)} file trong:[/bold]\n{source}", title="File Organizer"))

    if not yes:
        click.confirm("Tiếp tục?", abort=True)

    moves = []
    errors = []

    for file in track(files, description="Đang phân loại..."):
        cat_name, cat_info = get_category(file.suffix)
        dest_folder = source / cat_info["folder"]
        dest_folder.mkdir(exist_ok=True)
        dest_file = dest_folder / file.name

        # Tránh ghi đè: thêm timestamp nếu file đã tồn tại
        if dest_file.exists():
            stem = file.stem
            suffix = file.suffix
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            dest_file = dest_folder / f"{stem}_{timestamp}{suffix}"

        try:
            shutil.move(str(file), str(dest_file))
            moves.append({"from": str(file), "to": str(dest_file)})
        except Exception as e:
            errors.append({"file": file.name, "error": str(e)})

    # Lưu lịch sử
    if moves:
        save_history(moves, str(source))

    # Báo cáo kết quả
    console.print(f"\n[bold green]✅ Hoàn thành![/bold green]")
    console.print(f"   Di chuyển thành công: [green]{len(moves)}[/green] file")
    if errors:
        console.print(f"   Lỗi: [red]{len(errors)}[/red] file")
        for err in errors:
            console.print(f"   [red]✗[/red] {err['file']}: {err['error']}")


@cli.command()
@click.argument("directory", default=".", type=click.Path(exists=True))
def undo(directory):
    """Hoàn tác lần phân loại gần nhất."""
    source = Path(directory).resolve()
    history = load_history(str(source))

    if not history:
        console.print("[yellow]⚠ Không tìm thấy lịch sử để hoàn tác.[/yellow]")
        return

    moves = history["moves"]
    timestamp = history["timestamp"]
    console.print(f"[dim]Lịch sử tìm thấy: {timestamp}[/dim]")
    click.confirm(f"Hoàn tác {len(moves)} di chuyển?", abort=True)

    restored = 0
    for move in track(reversed(moves), description="Đang hoàn tác..."):
        src = Path(move["to"])
        dst = Path(move["from"])
        if src.exists():
            shutil.move(str(src), str(dst))
            restored += 1

    # Xóa file lịch sử sau khi undo
    (source / HISTORY_FILE).unlink(missing_ok=True)
    console.print(f"[bold green]✅ Đã hoàn tác {restored} file.[/bold green]")


@cli.command()
@click.argument("directory", default=".", type=click.Path(exists=True))
def stats(directory):
    """Hiển thị thống kê các file trong thư mục."""
    source = Path(directory).resolve()
    all_files = list(source.rglob("*"))
    files_only = [f for f in all_files if f.is_file() and not f.name.startswith(".")]

    if not files_only:
        console.print("[yellow]⚠ Thư mục trống.[/yellow]")
        return

    # Đếm theo danh mục
    cat_count = {}
    total_size = 0
    for file in files_only:
        cat_name, _ = get_category(file.suffix)
        cat_count[cat_name] = cat_count.get(cat_name, 0) + 1
        total_size += file.stat().st_size

    table = Table(title=f"Thống kê — {source.name}", show_lines=True)
    table.add_column("Danh mục", style="cyan")
    table.add_column("Số file", justify="right", style="green")
    table.add_column("Phần trăm", justify="right", style="yellow")

    for cat, count in sorted(cat_count.items(), key=lambda x: x[1], reverse=True):
        pct = (count / len(files_only)) * 100
        table.add_row(cat, str(count), f"{pct:.1f}%")

    console.print(table)
    size_mb = total_size / (1024 * 1024)
    console.print(f"\nTổng: [bold]{len(files_only)}[/bold] file | Dung lượng: [bold]{size_mb:.2f} MB[/bold]")


if __name__ == "__main__":
    cli()

Bước 4 — Chạy Tool Và Test

bash — Các lệnh chạy tool
# Xem trợ giúp
python organizer.py --help

# Xem trợ giúp của từng lệnh
python organizer.py sort --help
python organizer.py preview --help

# Preview thư mục Downloads (chưa di chuyển gì)
python organizer.py preview ~/Downloads

# Preview thư mục hiện tại
python organizer.py preview .

# Phân loại thư mục Downloads (sẽ hỏi xác nhận)
python organizer.py sort ~/Downloads

# Phân loại không hỏi xác nhận
python organizer.py sort ~/Downloads --yes

# Xem thống kê sau khi phân loại
python organizer.py stats ~/Downloads

# Hoàn tác nếu không vừa ý
python organizer.py undo ~/Downloads

Bước 5 — Cài Tool Toàn Hệ Thống (Dùng Được Ở Bất Kỳ Đâu)

bash — Cài globally với pip
# Tạo file setup.py để đóng gói tool
# (Prompt Copilot: "Viết setup.py cho CLI tool organizer dùng click")

# Nội dung setup.py:
# from setuptools import setup
# setup(
#     name="file-organizer",
#     version="1.0.0",
#     py_modules=["organizer", "config"],
#     install_requires=["click", "rich"],
#     entry_points={"console_scripts": ["organizer=organizer:cli"]},
# )

# Cài tool vào hệ thống (vẫn trong venv)
pip install --editable .

# Bây giờ có thể dùng lệnh ngắn gọn từ bất kỳ thư mục nào:
organizer --help
organizer preview ~/Desktop
organizer sort ~/Desktop --yes
🎉
Tool đã hoàn thành!

Bạn vừa xây dựng một CLI tool Python thực sự, với đầy đủ: phân loại file, preview an toàn, undo, thống kê, output màu sắc, error handling, và hỗ trợ cài globally. Đây là cấp độ tool mà nhiều developer dùng hàng ngày.

Mở Rộng Tool — Thử Thách Nâng Cao

  • Thêm option --dry-run (alias cho preview)
  • Thêm lệnh watch để tự động phân loại khi có file mới (dùng thư viện watchdog)
  • Thêm config file (.organizer.yaml) để người dùng tùy chỉnh rules
  • Thêm lệnh clean để xóa file trùng lặp (dùng hashlib để so sánh MD5)
  • Thêm progress bar khi xử lý thư mục có nhiều nghìn file
💡 Mẹo từ ThanhDoIT
  • Mỗi script Python nên có if __name__ == "__main__": — giúp code reusable và testable. AI thường quên điều này, hãy luôn thêm vào.
  • Dùng pathlib.Path thay vì os.path — code đọc dễ hơn, cross-platform hơn. Khi AI dùng os.path, nhắc nó chuyển sang pathlib.
  • Luôn lưu API keys trong .env và dùng python-dotenv. KHÔNG BAO GIỜ hardcode key vào code, dù chỉ để test tạm thời.
  • Rich library (pip install rich) giúp terminal output đẹp hơn rất nhiều — tables, progress bars, colors. AI biết cách dùng Rich rất tốt.
🏋️ Bài Tập Thực Hành

Dự án Chapter 4: "My First Python Tool"

Chọn 1 trong 3 dự án để làm:

  • Option A: Password Generator CLI — tạo mật khẩu an toàn với độ dài và ký tự tùy chọn
  • Option B: PDF to Text Converter — tool đọc PDF và extract text (dùng pypdf2)
  • Option C: Reminder Tool — tool đặt nhắc nhở lưu vào file JSON, list và xóa

Yêu cầu chung:

  • Sử dụng Click cho CLI, Rich cho output đẹp
  • requirements.txtREADME.md
  • Error handling đầy đủ (try/except)
  • Dùng AI để generate code, sau đó review và hiểu từng phần

🗒 Tóm Tắt Chương 4

  • Virtual environment (venv) — LUÔN dùng, cô lập dependencies mỗi project
  • Click + Rich — combo tốt nhất để xây dựng CLI tool Python chuyên nghiệp
  • File automation với pathlib — Path object mạnh hơn os.path rất nhiều
  • Web scraping với requests + BeautifulSoup — tôn trọng robots.txt và rate limiting
  • API integration — luôn lưu API key trong .env, không hardcode, không commit
  • AI workflow: viết comment rõ ràng → AI generate → review → test → iterate
7

Bài 4.7 — Selenium Web Automation với AI

🌐 Browser Automation · Python · VS Code

Selenium là thư viện Python cho phép bạn điều khiển trình duyệt Chrome/Firefox bằng code — như thể có người thật ngồi dùng máy tính nhưng tất cả đều tự động. Đây là công cụ cực kỳ mạnh để xây dựng automation tools có giá trị thương mại cao.

🎯 Bạn sẽ học được

  • Cài đặt Selenium + Chrome tự động với webdriver-manager
  • Tìm elements bằng CSS, XPath, ID, Class
  • Click, gõ text, submit form, navigate trang
  • Chờ đúng cách với WebDriverWait (không time.sleep)
  • Screenshot, xử lý alerts, multiple tabs
  • Headless mode — chạy không cần mở browser
  • 3 dự án thực hành có thể bán ngay

💡 Khi nào dùng Selenium?

  • Website dùng JavaScript nhiều (không scrape được bằng requests)
  • Cần login trước khi lấy dữ liệu
  • Tự động hoá quy trình lặp đi lặp lại
  • Tự động điền form hàng loạt
  • Test web UI tự động
  • Price monitoring, job alerts, data collection

Trước khi scrape/automate bất kỳ website nào: Đọc file robots.txt (thêm /robots.txt vào URL gốc) và Terms of Service. Chỉ tự động hoá những gì bạn được phép làm. Không tải quá nhiều request gây ảnh hưởng server.

Cài Đặt Môi Trường Trong VS Code

1
Tạo project và virtual environment
# Mở VS Code Terminal (Ctrl+`)
mkdir selenium-automation && cd selenium-automation
python -m venv venv

# Kích hoạt venv
# Windows:
venv\Scripts\activate
# Mac/Linux:
source venv/bin/activate

# Bạn sẽ thấy (venv) ở đầu dòng terminal
2
Cài packages cần thiết
pip install selenium webdriver-manager python-dotenv

# Tạo requirements.txt
pip freeze > requirements.txt
3
VS Code: Chọn Python interpreter từ venv

Nhấn Ctrl+Shift+P → gõ Python: Select Interpreter → chọn interpreter trong thư mục venv của project. VS Code sẽ tự dùng đúng Python có selenium.

4
Tạo file driver_setup.py — dùng lại mọi project
"""
driver_setup.py — Chrome WebDriver factory
Dùng webdriver-manager để tự động cài ChromeDriver đúng version
"""
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from webdriver_manager.chrome import ChromeDriverManager

def get_driver(headless: bool = False, window_size: tuple = (1920, 1080)) -> webdriver.Chrome:
    """
    Tạo Chrome WebDriver đã cấu hình sẵn.
    headless=True: chạy ẩn, không hiện cửa sổ Chrome
    """
    options = Options()
    if headless:
        options.add_argument('--headless=new')
    options.add_argument('--no-sandbox')
    options.add_argument('--disable-dev-shm-usage')
    options.add_argument('--disable-blink-features=AutomationControlled')
    options.add_experimental_option('excludeSwitches', ['enable-automation'])
    options.add_experimental_option('useAutomationExtension', False)
    options.add_argument(f'--window-size={window_size[0]},{window_size[1]}')
    options.add_argument(
        'user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
        'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
    )
    service = Service(ChromeDriverManager().install())
    driver = webdriver.Chrome(service=service, options=options)
    driver.implicitly_wait(10)
    driver.execute_script(
        "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
    )
    return driver

Hiểu Locators — Tìm Elements Trên Trang

Trước khi click hay gõ text, bạn cần tìm được element đó. Mở Chrome DevTools (F12) → click vào element → chuột phải → Inspect để xem HTML và tìm selector phù hợp.

By.ID ✅ Tốt nhất
By.ID, "username"
Nếu element có id="username" — dùng ngay, nhanh và chính xác nhất
By.CSS_SELECTOR ⭐ Linh hoạt
By.CSS_SELECTOR, "input[name='email']"
Như CSS trong HTML. Hỗ trợ: .class, #id, [attr=val], parent > child
By.XPATH 💪 Mạnh nhất
By.XPATH, "//button[text()='Login']"
Tìm theo text, vị trí tương đối. Dùng khi CSS không đủ. XPath có thể tìm parent element.
By.NAME
By.NAME, "password"
Với form fields có name attribute. Nhanh, đơn giản.
By.CLASS_NAME
By.CLASS_NAME, "product-card"
Dùng find_elements (số nhiều) để lấy danh sách. Tránh nếu class hay thay đổi.
By.LINK_TEXT
By.LINK_TEXT, "Đăng nhập"
Tìm thẻ <a> theo text. Dùng PARTIAL_LINK_TEXT nếu chỉ biết một phần.

Mẹo tìm XPath nhanh: Trong Chrome DevTools → Elements tab → chuột phải vào element → Copy → Copy XPath. Nhưng XPath copy tự động thường rất dài và dễ vỡ. Hãy đơn giản hoá: nhờ Copilot Chat: "Simplify this XPath: [paste XPath] — make it more robust"

Wait Strategies — Chờ Đúng Cách (Quan Trọng!)

Nguyên nhân số 1 khiến Selenium script fail: không chờ đủ lâu cho element load. Tuyệt đối không dùng time.sleep(3) — chậm và không ổn định.

from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By

wait = WebDriverWait(driver, timeout=15)

# Chờ element visible
element = wait.until(EC.visibility_of_element_located((By.ID, "result")))

# Chờ element clickable (phổ biến nhất!)
btn = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "#submit-btn")))
btn.click()

# Chờ text xuất hiện trong element
wait.until(EC.text_to_be_present_in_element((By.ID, "status"), "Thành công"))

# Chờ URL thay đổi (sau khi submit form)
wait.until(EC.url_contains("/dashboard"))

# Chờ element biến mất (loading spinner)
wait.until(EC.invisibility_of_element_located((By.CLASS_NAME, "loading")))

Actions Thường Dùng — Tham Khảo Nhanh

from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.support.ui import Select

# Navigation
driver.get("https://example.com")
driver.back(); driver.forward(); driver.refresh()
print(driver.title, driver.current_url)

# Find & interact
el = driver.find_element(By.ID, "email")
els = driver.find_elements(By.CLASS_NAME, "item")   # Tìm nhiều

el.click()
el.send_keys("hello@email.com")
el.clear()
el.send_keys(Keys.RETURN)        # Nhấn Enter
el.send_keys(Keys.CONTROL, 'a')  # Ctrl+A

# Get info
print(el.text)                           # Text hiển thị
print(el.get_attribute("href"))          # Lấy attribute
print(el.get_attribute("value"))         # Giá trị input
print(el.is_displayed(), el.is_enabled())

# Dropdown
select_el = Select(driver.find_element(By.ID, "country"))
select_el.select_by_visible_text("Việt Nam")
select_el.select_by_value("VN")

# Scroll
driver.execute_script("window.scrollTo(0, document.body.scrollHeight)")
driver.execute_script("arguments[0].scrollIntoView(true);", el)

# Screenshot
driver.save_screenshot("screenshot.png")
el.screenshot("element.png")

# Hover
ActionChains(driver).move_to_element(el).perform()

# Multiple tabs
driver.execute_script("window.open('https://google.com')")
handles = driver.window_handles
driver.switch_to.window(handles[1])
driver.close()
driver.switch_to.window(handles[0])

# Alerts
alert = driver.switch_to.alert
print(alert.text); alert.accept()

# File upload
upload = driver.find_element(By.CSS_SELECTOR, "input[type='file']")
upload.send_keys(os.path.abspath("myfile.pdf"))

# Cookies
driver.add_cookie({"name": "session", "value": "abc123"})
cookies = driver.get_cookies()
driver.delete_all_cookies()
Tôi cần viết Selenium script để tự động:
[Mô tả workflow cần tự động - ví dụ:]
1. Mở trang https://example.com/login
2. Điền email và password
3. Click nút "Đăng nhập"
4. Chờ redirect đến /dashboard
5. Tìm tất cả rows trong bảng .data-table
6. Lấy text từ cột 1 và 3 của mỗi row
7. Lưu vào CSV file

Python 3.11, Selenium 4, webdriver-manager, VS Code
Không dùng time.sleep — dùng WebDriverWait
Xử lý lỗi: nếu login fail → screenshot + log error + exit

Viết code hoàn chỉnh, có comments giải thích từng bước.
Để chính xác hơn: paste HTML của trang target vào Copilot Chat làm context. Vào trang → Ctrl+U → copy HTML → paste vào chat.

Error Handling Chuyên Nghiệp

"""
robust_automation.py — Template với error handling đầy đủ
"""
import logging
import os
from datetime import datetime
from selenium.common.exceptions import (
    TimeoutException, NoSuchElementException,
    StaleElementReferenceException, WebDriverException
)
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(message)s',
    handlers=[
        logging.FileHandler('automation.log', encoding='utf-8'),
        logging.StreamHandler()
    ]
)
log = logging.getLogger(__name__)

def safe_click(driver, locator, timeout=15, retries=3):
    """Click an element với retry tự động."""
    wait = WebDriverWait(driver, timeout)
    for attempt in range(retries):
        try:
            element = wait.until(EC.element_to_be_clickable(locator))
            element.click()
            log.info(f"Clicked {locator}")
            return True
        except StaleElementReferenceException:
            log.warning(f"Stale element, retry {attempt + 1}/{retries}")
        except TimeoutException:
            screenshot_on_error(driver, f"timeout_click_{attempt}")
            return False
    return False

def screenshot_on_error(driver, name="error"):
    """Tự động chụp screenshot khi có lỗi."""
    os.makedirs("screenshots", exist_ok=True)
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    path = f"screenshots/{name}_{timestamp}.png"
    driver.save_screenshot(path)
    log.info(f"Screenshot saved: {path}")

def run_automation():
    driver = None
    try:
        driver = get_driver(headless=False)
        log.info("Automation started")

        driver.get("https://target-website.com")
        safe_click(driver, (By.CSS_SELECTOR, "button[type='submit']"))

        WebDriverWait(driver, 15).until(EC.url_contains("/dashboard"))
        log.info("Login successful!")

        # --- Logic chính của bạn ---

        log.info("Automation completed")
    except WebDriverException as e:
        log.error(f"WebDriver error: {e}")
        if driver: screenshot_on_error(driver, "webdriver_error")
        raise
    finally:
        if driver:
            driver.quit()
            log.info("Browser closed")

if __name__ == "__main__":
    run_automation()

Headless Mode — Chạy Trên Server: Khi deploy lên server (không có màn hình), dùng headless=True. Trên Linux cần cài Chrome: apt-get install -y google-chrome-stable. Thêm --disable-gpu--disable-software-rasterizer vào options.

8

Bài 4.8 — Dự Án: 3 Automation Tools Có Thể Bán Ngay

Đây là 3 dự án Selenium thực tế, mỗi cái giải quyết một vấn đề thực sự và có thể kiếm tiền. Build từng cái theo hướng dẫn để nắm vững automation.

🔐
Dự Án 1 — Auto Login & Data Fetcher
Tự động đăng nhập + lấy dữ liệu từ website sau login

Nhiều website chỉ hiển thị data sau khi login — requests thông thường không làm được. Selenium tự động hoá hoàn toàn luồng này.

"""
auto_login_fetcher.py — Tự động login và lấy dữ liệu
"""
import csv, os
from dotenv import load_dotenv
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from driver_setup import get_driver

load_dotenv()

CONFIG = {
    "url": "https://target-website.com/login",
    "email_field": "#email",
    "pass_field": "#password",
    "login_btn": "button[type='submit']",
    "success_url_contains": "/dashboard",
    "data_table": ".data-table tbody tr",
}

def login(driver, email, password):
    wait = WebDriverWait(driver, 15)
    driver.get(CONFIG["url"])
    wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, CONFIG["email_field"]))).send_keys(email)
    driver.find_element(By.CSS_SELECTOR, CONFIG["pass_field"]).send_keys(password)
    driver.find_element(By.CSS_SELECTOR, CONFIG["login_btn"]).click()
    wait.until(EC.url_contains(CONFIG["success_url_contains"]))
    print("✅ Login thành công!")
    return True

def fetch_data(driver):
    wait = WebDriverWait(driver, 15)
    wait.until(EC.presence_of_all_elements_located((By.CSS_SELECTOR, CONFIG["data_table"])))
    rows = driver.find_elements(By.CSS_SELECTOR, CONFIG["data_table"])
    results = []
    for i, row in enumerate(rows):
        cells = row.find_elements(By.TAG_NAME, "td")
        if cells:
            results.append({
                "stt": i + 1,
                "col_1": cells[0].text.strip() if len(cells) > 0 else "",
                "col_2": cells[1].text.strip() if len(cells) > 1 else "",
                "col_3": cells[2].text.strip() if len(cells) > 2 else "",
            })
    print(f"📊 Thu thập được {len(results)} records")
    return results

def save_to_csv(data, filename="output/data.csv"):
    os.makedirs("output", exist_ok=True)
    with open(filename, "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=data[0].keys())
        writer.writeheader()
        writer.writerows(data)
    print(f"💾 Đã lưu vào {filename}")

def main():
    email = os.getenv("LOGIN_EMAIL")
    password = os.getenv("LOGIN_PASSWORD")
    driver = get_driver(headless=False)
    try:
        if login(driver, email, password):
            save_to_csv(fetch_data(driver))
    finally:
        driver.quit()

if __name__ == "__main__":
    main()
Tôi có website [URL] với form login.
HTML của form login: [paste HTML]
HTML của bảng dữ liệu sau login: [paste HTML]

Hãy điều chỉnh CONFIG dictionary trong auto_login_fetcher.py
để khớp với website này — cho tôi CSS selectors chính xác cho:
- Email input, password input, login button
- Data table rows và từng column cần lấy
💰
Dự Án 2 — Price Tracker Tự Động
Theo dõi giá sản phẩm + gửi thông báo khi giá giảm

Theo dõi giá hàng trăm sản phẩm tự động, lưu lịch sử, thông báo khi giá đạt ngưỡng. Đây là tool có thể bán $10-50/tháng.

"""
price_tracker.py — Theo dõi giá sản phẩm tự động
Chạy định kỳ: Task Scheduler (Windows) hoặc cron (Linux)
"""
import json, re, smtplib, os
from datetime import datetime
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from dotenv import load_dotenv
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from driver_setup import get_driver

load_dotenv()

PRODUCTS = [
    {
        "name": "iPhone 15 Pro 256GB",
        "url": "https://tiki.vn/...",
        "price_selector": ".product-price .price",
        "target_price": 25_000_000,  # Thông báo khi giá <= số này
    },
    # Thêm products...
]

HISTORY_FILE = "price_history.json"

def load_history():
    if os.path.exists(HISTORY_FILE):
        with open(HISTORY_FILE, "r", encoding="utf-8") as f:
            return json.load(f)
    return {}

def save_history(history):
    with open(HISTORY_FILE, "w", encoding="utf-8") as f:
        json.dump(history, f, ensure_ascii=False, indent=2)

def extract_price(price_text):
    """'26.990.000₫' → 26990000.0"""
    numbers = re.sub(r'[^\d]', '', price_text)
    return float(numbers) if numbers else 0.0

def get_price(driver, product):
    try:
        driver.get(product["url"])
        price_el = WebDriverWait(driver, 15).until(
            EC.visibility_of_element_located((By.CSS_SELECTOR, product["price_selector"]))
        )
        price = extract_price(price_el.text)
        print(f"  💵 {product['name']}: {price:,.0f}₫")
        return price
    except Exception as e:
        print(f"  ❌ Không lấy được giá: {e}")
        return None

def send_alert_email(product, current_price):
    sender = os.getenv("EMAIL_SENDER")
    password = os.getenv("EMAIL_PASSWORD")
    receiver = os.getenv("EMAIL_RECEIVER")
    if not all([sender, password, receiver]):
        return
    msg = MIMEMultipart("alternative")
    msg["Subject"] = f"🔔 Giá {product['name']} đã giảm!"
    msg["From"] = sender; msg["To"] = receiver
    html = f"""
    

🎉 Giá mục tiêu đạt được!

{product['name']}

Giá hiện tại: {current_price:,.0f}₫

Mục tiêu: {product['target_price']:,.0f}₫

🛒 Mua ngay """ msg.attach(MIMEText(html, "html")) with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server: server.login(sender, password) server.sendmail(sender, receiver, msg.as_string()) print(" 📧 Đã gửi email thông báo!") def track_prices(): print(f"\n{'='*50}\n🔍 Kiểm tra giá lúc {datetime.now().strftime('%d/%m/%Y %H:%M')}") history = load_history() driver = get_driver(headless=True) try: for product in PRODUCTS: print(f"\n📦 {product['name']}") current_price = get_price(driver, product) if current_price is None: continue name = product["name"] if name not in history: history[name] = [] history[name].append({"time": datetime.now().isoformat(), "price": current_price}) history[name] = history[name][-90:] if current_price <= product["target_price"]: print(f" 🎯 ĐẠT MỤC TIÊU!") send_alert_email(product, current_price) finally: driver.quit() save_history(history) print("✅ Hoàn thành!") if __name__ == "__main__": track_prices()
📝
Dự Án 3 — Auto Form Filler
Đọc dữ liệu từ CSV → tự động điền vào web form hàng loạt

Nhiều doanh nghiệp cần điền cùng một form hàng trăm, hàng nghìn lần. Tool này tiết kiệm hàng chục giờ thủ công.

"""
auto_form_filler.py — Tự động điền form từ CSV
"""
import csv, time, os
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait, Select
from selenium.webdriver.support import expected_conditions as EC
from driver_setup import get_driver

FORM_CONFIG = {
    "url": "https://target-website.com/register",
    # Mapping: tên cột CSV → CSS selector
    "fields": {
        "ho_ten": "#fullName",
        "email": "#email",
        "dien_thoai": "#phone",
        "dia_chi": "#address",
        "tinh_thanh": "#province",
    },
    "select_fields": ["tinh_thanh"],  # Các field là dropdown
    "submit_btn": "button[type='submit']",
    "success_indicator": "Đăng ký thành công",
    "delay_between_submissions": 2,
}

def read_csv_data(filepath):
    with open(filepath, "r", encoding="utf-8-sig") as f:
        return list(csv.DictReader(f))

def fill_form(driver, data):
    wait = WebDriverWait(driver, 15)
    try:
        driver.get(FORM_CONFIG["url"])
        for field_name, selector in FORM_CONFIG["fields"].items():
            value = data.get(field_name, "").strip()
            if not value:
                continue
            element = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, selector)))
            if field_name in FORM_CONFIG["select_fields"]:
                Select(element).select_by_visible_text(value)
            else:
                element.clear(); element.send_keys(value)
        wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, FORM_CONFIG["submit_btn"]))).click()
        wait.until(EC.text_to_be_present_in_element((By.TAG_NAME, "body"), FORM_CONFIG["success_indicator"]))
        return True
    except Exception as e:
        print(f"  ❌ Lỗi: {e}")
        driver.save_screenshot(f"screenshots/error_{data.get('email','unknown')}.png")
        return False

def run_batch(csv_file):
    os.makedirs("screenshots", exist_ok=True)
    rows = read_csv_data(csv_file)
    print(f"📋 Tổng cộng {len(rows)} records")
    driver = get_driver(headless=False)
    success = fail = 0
    try:
        for i, row in enumerate(rows, 1):
            print(f"[{i}/{len(rows)}] {row.get('ho_ten', 'Record')}")
            if fill_form(driver, row):
                success += 1; print("  ✅ OK")
            else:
                fail += 1
            if i < len(rows):
                time.sleep(FORM_CONFIG["delay_between_submissions"])
    finally:
        driver.quit()
    print(f"\n✅ Thành công: {success} | ❌ Thất bại: {fail}")

if __name__ == "__main__":
    if not os.path.exists("data.csv"):
        with open("data.csv", "w", encoding="utf-8-sig", newline="") as f:
            f.write("ho_ten,email,dien_thoai,dia_chi,tinh_thanh\n")
            f.write("Nguyễn Văn A,a@email.com,0912345678,123 Lê Lợi,Hà Nội\n")
        print("📄 Tạo data.csv mẫu — điền dữ liệu thật rồi chạy lại")
    else:
        run_batch("data.csv")

🎯 Thử Thách — Build Price Tracker Hoàn Chỉnh:

  1. Chọn 1 website bán hàng bạn hay dùng (Tiki, Shopee, Lazada)
  2. Mở DevTools (F12) → Inspect giá → Copy CSS selector
  3. Copy code price_tracker.py, cập nhật CONFIG
  4. Chạy thử để xem có lấy được giá không
  5. Setup Task Scheduler (Windows) / cron (Linux) chạy mỗi 1 tiếng
  6. Bonus: Thêm Telegram bot notification thay vì email
Thêm Telegram bot notification vào price_tracker.py.
Khi giá đạt target → gửi Telegram message thay vì email.
- Dùng requests gọi Telegram Bot API trực tiếp
- Message format đẹp với emoji
- Có inline keyboard "Mua ngay" → link sản phẩm
- Token từ .env: TELEGRAM_TOKEN, TELEGRAM_CHAT_ID

Viết hàm send_telegram_alert() và tích hợp vào track_prices().
Giữ nguyên email notification — có cả 2 cách.

Sau bài 4.7 và 4.8 bạn đã có trong tay 3 automation tools thực sự có giá trị: Auto Login Fetcher (thu thập data sau login), Price Tracker (theo dõi giá + alert), và Form Auto-Filler (điền form hàng loạt). Mỗi tool có thể tùy chỉnh cho nhiều use case khác nhau và bán được ngay.

Zalo: 0898 619 966 Z Gọi: 0898 619 966