System Design Document · v1.0

Lead Enrichment
Pipeline System

Hệ thống tự động crawl website công ty, extract email và thông tin liên hệ, ghi ngược lại Google Sheet. Scale 1,000–5,000 leads/batch với chi phí gần bằng 0.

Platform Mac mini M4 · Python
Primary Tool Jina Reader API
Target Scale 1,000 – 5,000 domains
Est. Cost / 5k leads ~$0.07 – $0.54
Hit Rate ~45–55%
01

Tech Stack & Lý do chọn

Runtime
Mac mini M4 — self-hosted, không tốn VPS, chạy như daemon hoặc manual. Xử lý async 50–100 concurrent requests dễ dàng.
HTTP Client
httpx — async HTTP library. Dùng cho HEAD check (layer pre-filter) trước khi gọi Jina. asyncio + httpx.AsyncClient cho concurrency.
Web Reader
Jina Reader APIr.jina.ai/{url}. Convert URL → clean Markdown. Handle JS rendering, không cần tự chạy Playwright. Free 1M tokens on signup.
JS Fallback
Playwright — chỉ dùng cho ~10% sites bị block hoàn toàn. Chậm hơn nhưng thorough. Chạy trên Mac mini M4.
Extraction
Regex + BeautifulSoup — extract email/phone/social từ clean Markdown. Bao gồm decode Cloudflare email obfuscation (XOR algorithm).
Sheet Sync
gspread — Python library ghi ngược về Google Sheet. Batch write để tránh quota limit của Sheets API.
DNS Check
dnspython — verify domain còn sống trước khi crawl. Tiết kiệm ~15–20% token từ dead domains.
Tại sao không 80legs
G2 profile inactive 1+ năm. Crawl jobs bị queue mà không chạy. Free tier 10K URLs/job không đủ. Datacenter IPs dễ bị Cloudflare block. Service có thể chết bất kỳ lúc nào.
Tại sao Jina Reader
Single HTTP call → clean Markdown. Handle JS rendering server-side. Proxy pool với residential IPs (x-proxy: auto). Open source, active dev. Free tier đủ để test; top-up 1B tokens chỉ ~$18.
Tại sao Mac mini M4
Đã có sẵn, $0 infrastructure cost. M4 chip xử lý async 50+ requests/giây. Có thể chạy Playwright headless. Không phụ thuộc VPS hay cloud quota.
02

Pipeline 6 bước

00
Market Detection 0 token BƯỚC MỚI

Detect thị trường/ngôn ngữ từ Sheet column hoặc TLD domain để chọn đúng URL pattern set. Tăng hit rate 20–30% trên non-English markets.

# Priority 1: Đọc từ Sheet column market = row.get("country") → "VN", "KR", "DE"... # Priority 2: Infer từ TLD TLD_MAP = { ".vn": "vietnamese", ".kr": "korean", ".jp": "japanese", ".de": "german", ".fr": "french", ".com": "english", } # Priority 3: Detect từ homepage content (fallback .com) signals = ["liên hệ", "연락처", "impressum"...]
01
DNS Check ~3ms SKIP nếu fail

Verify domain còn sống trước mọi thứ. Không tốn token. Lọc ~15–20% dead domains ngay từ đầu.

import socket def check_dns(domain): try: socket.getaddrinfo(domain, 80, timeout=3) return True # ✓ Domain sống except socket.gaierror: return False # ✗ DNS không resolve → SKIP toàn bộ domain
02
Homepage Quick Scan 1 Jina call

Fetch homepage trước tiên. ~20% sites publish email ngay homepage. Nếu tìm được → done, không cần crawl thêm. Cũng detect parked domain từ bước này.

# Fetch homepage qua Jina result = await jina_fetch(f"https://{domain}") # Check parked domain if is_parked(result): return status="parked" # "domain for sale", < 300 chars... # Extract email ngay emails = extract_emails(result) if emails: return emails # ✓ Done!
03
HEAD Check per URL 0 token TOKEN SAVER

Trước khi gọi Jina cho từng pattern URL, dùng HEAD request kiểm tra URL có tồn tại không. 404 → skip ngay. Tiết kiệm ~60% Jina calls so với không có bước này.

async def head_check(url) → (exists, status): r = await client.head(url, timeout=5) if r.status_code == 405: # HEAD not allowed r = await client.get(url) # fallback GET # Decision: 200✓ gọi Jina 301/302✓ follow redirect, gọi Jina 403⚠ thử Jina (có proxy bypass) 404✗ skip, thử pattern tiếp 429⏳ wait 30s, retry 5xx✗ skip pattern này
04
Jina Fetch + Extract ~1,500 tokens

Gọi Jina Reader với browser engine forced để đảm bảo JS chạy. Nhận clean Markdown → chạy regex extractor. Handle Cloudflare email obfuscation tự động.

# Jina call với headers tối ưu headers = { "Authorization": f"Bearer {JINA_KEY}", "X-Engine": "browser", # Force Chrome, handle JS "X-Proxy": "auto", # Rotate residential IPs "X-Return-Format": "markdown" } # Cloudflare email decode (XOR algorithm) def decode_cf_email(encoded): b = bytes.fromhex(encoded) key = b[0] return ''.join(chr(c ^ key) for c in b[1:]) # data-cfemail="a3c2c1c0..." → [email protected]
05
Validate & Write Sheet DNS MX check

Validate email bằng DNS MX check (không tốn API, chỉ verify domain có mail server). Ghi kết quả về Google Sheet kèm status column để tracking.

# Validate email domain có MX record import dns.resolver def validate_email(email): domain = email.split("@")[1] try: dns.resolver.resolve(domain, "MX"); return True except: return False # Status values ghi vào Sheet "ok" → email found ✓ "dead" → DNS failed "parked" → domain đang bán "no_email_found" → site sống nhưng dùng form "blocked_403" → bị block hoàn toàn "ok_redirected" → domain đã đổi
03

Decision Tree đầy đủ

Domain từ Google Sheet │ ├─[Step 0] Market Detection → chọn URL pattern set theo market │ ├─[Step 1] DNS Check │ ├── FAIL ──────────────→ status: "dead" │ SKIP toàn bộ domain (0 token) │ └── OK │ │ ├─[Step 2] Homepage Jina fetch │ ├── Parked signals ────→ status: "parked" │ SKIP │ ├── Content < 300 chars → status: "empty" │ SKIP │ ├── Email found ────────→ status: "ok" │ WRITE → DONE ✓ │ └── No email → tiếp tục... │ │ └─[Step 3+4] Loop URL patterns │ ├── HEAD check (per URL, 0 token) │ ├── 404/5xx ──────────→ next pattern │ ├── 403 ─────────────→ thử Jina (proxy) │ ├── 429 ─────────────→ wait 30s, retry │ └── 200/301 ─────────→ tiếp tục │ │ ├── Jina fetch (~1,500 tokens) │ ├── Timeout ─────────→ retry ×2 → next pattern │ └── OK │ │ ├── Extract email │ ├── Found ───────────→ validate MX → WRITE → DONE ✓ │ └── Not found ────→ next pattern │ └── Hết patterns ──────→ status: "no_email_found"
04

URL Pattern Sets theo Market

🇻🇳 Vietnamese
/lien-he /lien-he-voi-chung-toi /ve-chung-toi /gioi-thieu /thong-tin-lien-he /ban-lanh-dao /contact /about
🇬🇧 English (International)
/contact /contact-us /about /about-us /team /our-team /leadership /pages/contact /get-in-touch
🇩🇪 German ⚡ Impressum bắt buộc theo luật EU → hit rate rất cao
/impressum /kontakt /ueber-uns /contact /unternehmen /ansprechpartner
🇫🇷 French ⚡ Mentions légales bắt buộc theo luật FR
/mentions-legales /contact /contactez-nous /nous-contacter /qui-sommes-nous
🇰🇷 Korean
/contact /about /company /contact-us /inquiry /contactus
🇯🇵 Japanese
/contact /about /company /inquiry /corporate/contact /company/profile

* Non-English markets luôn append English patterns làm fallback — nhiều công ty KR/JP dùng EN URLs.

05

Xử lý Cloudflare Email Obfuscation

Cơ chế encode

Cloudflare encode email bằng XOR với byte đầu tiên làm key. Browser decode qua JS khi render. Crawler nhận raw HTML thấy data-cfemail="a3c2..." thay vì email thật.

# HTML thực tế nhận được <a class="__cf_email__" data-cfemail="a3c2c1c0e3c4ce..."> # Decode: byte[0] = key, XOR với các bytes còn lại def decode_cf_email(encoded): b = bytes.fromhex(encoded) key = b[0] # = 0xa3 return ''.join(chr(c ^ key) for c in b[1:]) # → "[email protected]"

3 tình huống & cách xử lý

Tình huống Xử lý
Raw HTML crawl Decode XOR function, 5 dòng code
Jina + browser engine Chrome decode tự động ✓
Jina + curl mode Force X-Engine: browser
06

Xử lý Dead Domains & Error Cases

Status HTTP Code Nguyên nhân Hành động Token tốn
dns_failed Domain expire, không còn tồn tại Skip toàn bộ domain 0
parked 200 "Domain for sale", GoDaddy parking Skip, ghi nhận ~500
404 404 URL pattern không tồn tại Thử pattern tiếp 0 (HEAD check)
blocked_403 403 Bot bị block, nhưng page tồn tại Thử Jina với x-proxy ~1,500
rate_limited 429 Target site giới hạn requests Wait 30s, retry ×1 ~1,500
server_down 5xx Server lỗi, Cloudflare origin down Skip pattern 0 (HEAD check)
ok_redirected 301/302 Công ty đổi domain mới Follow, ghi domain mới ~1,500
no_email_found 200 Site sống, chỉ dùng contact form Ghi nhận, không có email ~7,500
ok 200 Email extracted thành công Write to Sheet ✓ ~1,500–7,500

Parked Domain Signals

PARKED_SIGNALS = [ # Content keywords "domain for sale", "buy this domain", "this domain is parked", # Known parking services "godaddy.com/domains", "dan.com", "sedo.com", "hugedomains.com", # Content too short len(content) < 300 # chars ]

Redirect Tracking

# Detect domain đã đổi tên original = extract_domain(url) final = extract_domain(final_url) if original != final: # Ghi domain mới vào Sheet row["new_domain"] = final status = "ok_redirected" # Giới hạn redirect hops max_redirects = 3
07

Ước tính Hit Rate & Chi phí

Hit Rate theo Market

🇩🇪 Đức (DE)
~75%
🇫🇷 Pháp (FR)
~70%
🇬🇧 English
~65%
🇻🇳 Việt Nam
~65%
🇰🇷 Hàn Quốc
~55%
🇯🇵 Nhật Bản
~50%

* DE/FR cao vì Impressum/Mentions Légales là bắt buộc theo luật

Phân bổ 5,000 domains (VN mix)

Email found ✓
~2,500
No email (form)
~1,250
Dead domain
~850
Parked
~250
Blocked
~150

Chi phí Jina Reader Token

Free Welcome Tokens
1M
tokens khi đăng ký. Đủ test ~133 domains × 5 URLs.
Top-up nhỏ nhất
$18
cho 1B tokens. Đủ crawl 133,000 domains × 5 URLs.
Chi phí 5,000 leads
$0.07
với pre-filtering (DNS + HEAD check). Gần như miễn phí.
── Token calculation (5,000 domains × 5 URLs) ────────────────────────── Không có pre-filter: 25,000 Jina calls × 1,500 tokens = 37.5M tokens (~$0.68) Với DNS filter (-18%): 20,500 Jina calls Với HEAD filter (-60%): ~10,000 Jina calls × 1,500 tokens = ~15M tokens (~$0.27) Rate limit: 100 RPM (free) = 100 requests/phút Time: 10,000 requests ÷ 100 RPM = 100 phút = ~1.7 giờ chạy
08

Jina Reader — Cơ chế & Giới hạn

Cơ chế hoạt động

r.jina.ai/https://example.com/contact │ ├── Thử curl-impersonate (fast, TLS fingerprint) │ → OK? → clean, return Markdown │ └── Fail → spin headless Chrome → render JS, wait DOM → strip nav/ads/footer → return clean Markdown

Headers quan trọng

X-Engine: browser # Force Chrome X-Proxy: auto # Rotate residential IPs X-Proxy: vn # Pin country (VN IP) X-No-Cache: true # Bypass Jina cache X-Return-Format: markdown

Rate Limits

TierRPMConcurrentCost
Free 100 2 $0
Paid 500 50 Token-based
Premium 5,000 500 Custom

Giới hạn cần biết

Single URL per request (không crawl toàn site) Không bypass Cloudflare Bot Mgmt cứng Output là raw Markdown (tự parse) Active dev (re-sync tháng 4/2026) Open source, self-hostable Handle PDF, Office docs từ URL 1M free tokens on signup
09

Google Sheet Schema

── Input columns (đã có) ────────────────────────────────────────────── company_name │ website │ country (optional) │ market (optional) ── Output columns (enriched) ────────────────────────────────────────── email │ email_2 (secondary) │ phone │ facebook_url linkedin_url │ zalo_link │ new_domain │ market_detected ── System columns (tracking) ────────────────────────────────────────── status │ last_checked │ crawl_source_url
Company Website Email Status Last Checked
Công ty ABC abc.vn [email protected] ok 2026-05-28
XYZ Corp xyz.com dead 2026-05-28
DEF Solutions def.vn parked 2026-05-28
GHI Ltd ghi.com → newghi.com [email protected] ok_redirected 2026-05-28
JKL Company jkl.vn no_email_found 2026-05-28
10

Cách chạy & Dependencies

Dependencies

# requirements.txt httpx[asyncio]>=0.27 gspread>=6.0 beautifulsoup4>=4.12 dnspython>=2.6 google-auth>=2.28 python-dotenv>=1.0

Config

# config.yaml jina_api_key: "jina_xxxxx" sheet_id: "1BxiMV..." concurrency: 50 # requests song song max_urls_per_domain: 5 timeout_jina: 30 # seconds timeout_head: 5 retry_max: 2 batch_write_size: 50 # ghi Sheet mỗi 50 rows

Chạy script

# Chạy lần đầu python main.py --sheet "SHEET_ID" # Resume nếu bị interrupt python main.py --sheet "SHEET_ID" --resume # Chỉ chạy 1 market cụ thể python main.py --market vn --sheet "SHEET_ID" # Dry run (không ghi Sheet) python main.py --dry-run --limit 100

File structure

lead-enricher/ ├── main.py # Orchestrator ├── crawler.py # Async crawler + HEAD check ├── extractor.py # Email/phone/social regex ├── market.py # Market detection + patterns ├── sheets_sync.py # Google Sheets read/write ├── validator.py # DNS MX email validation ├── config.yaml └── requirements.txt