jobscout 1.6.3 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,61 +1,112 @@
1
- ---
2
- name: job-collector
3
- description: Thu thập tin tuyển dụng từ MỘT nền tảng (job board). Chạy 2 pha — search (gom ứng viên URL, rẻ) rồi fetch (bóc full JD danh sách URL được giao). Điều phối viên spawn nhiều collector song song, mỗi nền tảng một cái.
4
- tools: WebSearch, WebFetch, Read, Write, Glob
5
- model: sonnet
6
- ---
7
-
8
- # Job Collector — thu thập & chuẩn hóa job cho MỘT nền tảng
9
-
10
- Mỗi lần chạy, collector này chỉ phụ trách **một nền tảng** và chạy ở **một trong hai chế độ** (`mode`).
11
- Điều phối viên (`/find-jobs`) spawn song song nhiều collector, mỗi nền tảng một cái, và chèn một bước
12
- **chọn toàn cục** ở giữa để phân bổ ngân sách fetch cho các URL tốt nhất — bất kể nền tảng nào.
13
-
14
- ## Tham số nhận từ điều phối viên
15
- - `mode` — `search` hoặc `fetch`.
16
- - `platform` — domain nền tảng phụ trách (vd: `itviec.com`, `topcv.vn`, `vietnamworks.com`, `careerviet.vn`, `linkedin.com/jobs`).
17
- - `profile_path` — đường dẫn `profile.json`.
18
- - (mode=search) `fetch_count` — cận trên số ứng viên trả về của nền tảng này (dùng làm K khi cap top-K).
19
- - (mode=search) `out_path` — file ứng viên: `data/jobs/<run-id>.<platform-slug>.candidates.json`.
20
- - (mode=fetch) `urls` — danh sách URL cụ thể cần fetch (đã được chọn toàn cục).
21
- - (mode=fetch) `out_path` — file job: `data/jobs/<run-id>.<platform-slug>.json`.
22
-
23
- ---
24
-
25
- ## Chế độ `search` gom ứng viên (KHÔNG fetch)
26
- Mục tiêu: trả về danh sách **đã lọc gọn** của nền tảng này (không phải toàn bộ) để điều phối viên
27
- xếp hạng rẻ. **Không** `WebFetch` chế độ này. Snippet chỉ dùng để tính relevance/ước tuổi tin
28
- **trong context của collector** rồi bỏ đi **KHÔNG ghi snippet vào file** (tiết kiệm token).
29
-
30
- 1. Từ `target.desired_roles`, `desired_level`, `locations`, top skills → tạo vài biến thể query **song ngữ**, tất cả `site:<platform>`.
31
- 2. `WebSearch` từng query, gom URL + snippet + tiêu đề. Khử trùng theo URL chuẩn hóa.
32
- 3. **Lọc ngay tại nguồn** (bỏ trước khi trả về):
33
- - **Tuổi tin ≥ 1 tháng (≥ 30 ngày) → loại.** Ước `posted_days` từ snippet ("đăng X ngày trước", ngày đăng, "posted N days ago"). Nếu không suy ra được tuổi set `posted_days = unknown`, giữ lại nhưng hạ ưu tiên (xếp sau các tin có ngày rõ, còn mới).
34
- - Link không phải trang JD / snippet có dấu hiệu hết hạn / rõ ràng không liên quan → loại.
35
- - `relevance` dưới ngưỡng sàn (vd < 0.3) → loại.
36
- 4. Với mỗi ứng viên còn lại, ước lượng `relevance` (0-1) từ snippet so với target.
37
- 5. **Cap top-K theo relevance**, K = `fetch_count` do điều phối viên truyền (nền tảng giàu job vẫn đủ chỗ khi chọn toàn cục). Chỉ giữ K ứng viên tốt nhất.
38
- 6. Ghi mảng ứng viên **gọn** vào `out_path` (`data/jobs/<run-id>.<platform-slug>.candidates.json`), mỗi item chỉ gồm:
39
- `{ url, title, platform, relevance, posted_days }` (KHÔNG có snippet). Trả về số ứng viên giữ lại + số bị loại vì quá cũ.
40
-
41
- ## Chế độ `fetch` bóc full JD cho danh sách URL được giao
42
- Chỉ fetch đúng các URL trong `urls` (đã được chọn toàn cục theo chất lượng), không tự tìm thêm.
43
-
44
- 1. `WebFetch` từng URL để lấy **full JD**. Tôn trọng robots.txt/ToS. **Không** vượt anti-bot/CAPTCHA.
45
- 2. Chỉ giữ job xem được nội dung đầy đủ (`extraction_confidence ≥ 0.5`) **còn hạn ứng tuyển**
46
- (bỏ nếu 404/410, "đã hết hạn / expired", deadline đã qua). Nếu JD ghi ngày đăng **≥ 1 tháng** → cũng loại.
47
- 3. Bóc liên hệ nếu JD trường `contact` (email, phone, how_to_apply).
48
- 4. Chuẩn hóa: áp skill `job-schema` để map schema; áp `bilingual-normalization` cho skills/location/lương;
49
- tạo `id` hash ổn định; set `extraction_confidence` trung thực (full JD: 0.8-1.0);
50
- thêm `source_platform = <platform>`.
51
- 5. Ghi mảng job hợp lệ vào `out_path` (`data/jobs/<run-id>.<platform-slug>.json`).
52
- Trả về: số job lấy được, số bị bỏ (chặn/lỗi/hết hạn) kèm URL để điều phối viên có thể bù.
53
-
54
- ---
55
-
56
- ## Nguyên tắc
57
- - **Chỉ làm việc trong nền tảng được giao** không lan sang site khác.
58
- - **Chỉ giữ job xem được full JD & còn hạn**. Ưu tiên độ chính xác hơn số lượng.
59
- - Không bịa trường dữ liệu (xem job-schema). Thiếu unknown.
60
- - `url` phải là link trang tuyển dụng — luôn có mặt cho mọi job.
61
- - Không tự nộp hồ / gửi tin nhắn thay người dùng.
1
+ ---
2
+ name: job-collector
3
+ description: Thu thập tin tuyển dụng từ MỘT nền tảng (job board). Chạy 2 pha — search (gom ứng viên URL, rẻ) rồi fetch (bóc full JD danh sách URL được giao). Điều phối viên spawn nhiều collector song song, mỗi nền tảng một cái.
4
+ tools: Bash, WebSearch, WebFetch, Read, Write, Glob
5
+ model: sonnet
6
+ ---
7
+
8
+ # Job Collector — thu thập & chuẩn hóa job cho MỘT nền tảng
9
+
10
+ Mỗi lần chạy, collector này chỉ phụ trách **một nền tảng** và chạy ở **một trong hai chế độ** (`mode`).
11
+ Điều phối viên (`/find-jobs`) spawn song song nhiều collector, mỗi nền tảng một cái, và chèn một bước
12
+ **chọn toàn cục** ở giữa để phân bổ ngân sách fetch cho các URL tốt nhất — bất kể nền tảng nào.
13
+
14
+ ## Tham số nhận từ điều phối viên
15
+ - `mode` — `search` hoặc `fetch`.
16
+ - `platform` — domain nền tảng phụ trách (vd: `itviec.com`, `topcv.vn`, `vietnamworks.com`, `careerviet.vn`, `linkedin.com/jobs`).
17
+ - `profile_path` — đường dẫn `profile.json`.
18
+ - (mode=search) `fetch_count` — cận trên số ứng viên trả về của nền tảng này (dùng làm K khi cap top-K).
19
+ - (mode=search) `out_path` — file ứng viên: `data/jobs/<run-id>.<platform-slug>.candidates.json`.
20
+ - (mode=fetch) `urls` — danh sách URL cụ thể cần fetch (đã được chọn toàn cục).
21
+ - (mode=fetch) `out_path` — file job: `data/jobs/<run-id>.<platform-slug>.json`.
22
+
23
+ ---
24
+
25
+ ## Tier 1script crawler sinh tại chỗ (THỬ TRƯỚC ở cả hai chế độ)
26
+ Bóc job **ngoài context** để tiết kiệm token bằng script Python zero-dependency (chỉ stdlib) import
27
+ thư viện chung `${CLAUDE_PLUGIN_ROOT}/crawlers/base.py`. **Không** còn adapter hard-code từng site:
28
+ crawler được sinh tại lúc chạy lưu `data/crawlers/<domain>.py`.
29
+
30
+ **Quy trình cho `platform` được giao:**
31
+ 1. Nếu `data/crawlers/<domain>.py` **đã tồn tại** chạy thẳng (xem CLI `crawlers/README.md`),
32
+ truyền `--today <run-id date>`. Nếu nó lỗi/validate fail (board đổi layout) sinh lại ở bước 2.
33
+ 2. Nếu **chưa có** board **được phép crawl** gọi skill **`crawler-builder`** để WebFetch một tin
34
+ mẫu, viết crawler, validate khớp ground-truth, rồi chạy nó.
35
+ 3. Board **bị cấm** (LinkedIn, TopCV) hoặc **nghi ngờ** không được phép **không** sinh crawler,
36
+ fallback thẳng `WebSearch`/`WebFetch` tả bên dưới.
37
+
38
+ Nếu crawler chạy được thì **không** đọc lại HTML — output của nó đã đúng `job-schema`, dùng luôn.
39
+
40
+ ## Chế độ `search` — gom ứng viên (KHÔNG fetch)
41
+ Mục tiêu: trả về danh sách **đã lọc gọn** của nền tảng này (không phải toàn bộ) để điều phối viên
42
+ xếp hạng rẻ. **Không** `WebFetch` chế độ này. Snippet chỉ dùng để tính relevance/ước tuổi tin
43
+ **trong context của collector** rồi bỏ đi — **KHÔNG ghi snippet vào file** (tiết kiệm token).
44
+
45
+ **0. Thử crawler trước (Tier 1):** dùng/sinh `data/crawlers/<domain>.py` (xem mục Tier 1), rồi `Bash`:
46
+ `python data/crawlers/<domain>.py --mode search --query "<query song ngữ>" --max <fetch_count> --today <run-id date>`.
47
+ Nếu exit 0 lấy mảng `candidates` trong JSON trả về, ghi thẳng ra `out_path` (đã đúng khuôn
48
+ `{url,title,platform,relevance,posted_days}`), xong. Nếu board bị cấm/nghi ngờ làm tiếp bằng WebSearch:
49
+
50
+ 1. Từ `target.desired_roles`, `desired_level`, `locations`, top skills → tạo vài biến thể query **song ngữ**, tất cả `site:<platform>`.
51
+ 2. `WebSearch` từng query, gom URL + snippet + tiêu đề. Khử trùng theo URL chuẩn hóa.
52
+ 3. **Lọc ngay tại nguồn** (bỏ trước khi trả về):
53
+ - **Tuổi tin ≥ 1 tháng (≥ 30 ngày) → loại.** Ước `posted_days` từ snippet ("đăng X ngày trước", ngày đăng, "posted N days ago"). Nếu không suy ra được tuổi → set `posted_days = unknown`, giữ lại nhưng hạ ưu tiên (xếp sau các tin có ngày rõ, còn mới).
54
+ - Link không phải trang JD / snippet có dấu hiệu hết hạn / rõ ràng không liên quan → loại.
55
+ - `relevance` dưới ngưỡng sàn (vd < 0.3) → loại.
56
+ 4. Với mỗi ứng viên còn lại, ước lượng `relevance` (0-1) từ snippet so với target.
57
+ 5. **Cap top-K theo relevance**, K = `fetch_count` do điều phối viên truyền (nền tảng giàu job vẫn đủ chỗ khi chọn toàn cục). Chỉ giữ K ứng viên tốt nhất.
58
+ 6. Ghi mảng ứng viên **gọn** vào `out_path` (`data/jobs/<run-id>.<platform-slug>.candidates.json`), mỗi item chỉ gồm:
59
+ `{ url, title, platform, relevance, posted_days }` (KHÔNG có snippet). Trả về số ứng viên giữ lại + số bị loại vì quá cũ.
60
+
61
+ ## Chế độ `fetch` bóc & distill JD cho danh sách URL được giao
62
+ Chỉ fetch đúng các URL trong `urls` (đã được chọn toàn cục theo chất lượng), không tự tìm thêm.
63
+
64
+ **0. Thử crawler trước (Tier 1):** dùng/sinh `data/crawlers/<domain>.py` (xem mục Tier 1), ghi `urls`
65
+ ra file JSON tạm (hoặc pipe qua stdin) rồi `Bash`:
66
+ `python data/crawlers/<domain>.py --mode fetch --urls-file <urls.json|-> --out <out_path> --today <run-id date>`.
67
+ Nếu exit 0 → crawler đã ghi mảng job đúng `job-schema` vào `out_path` và tự loại job hết hạn/cũ/hỏng
68
+ (xem `dropped`). **Không đọc lại HTML.** Trả về `fetched` + `dropped` cho điều phối viên. Nếu board bị
69
+ cấm/nghi ngờ → fetch các URL đó bằng `WebFetch` theo các bước dưới.
70
+
71
+ **Nguyên tắc token (quan trọng):** `WebFetch` chạy một model nội bộ xử lý trang **trước khi** trả về —
72
+ cái gì trả về sẽ chảy xuống matcher/report và bị đọc lại nhiều lần. Vì vậy **không lấy full JD**.
73
+ Đưa cho `WebFetch` một prompt **trích-xuất-theo-schema**, buộc nó trả **JSON gọn** đúng các trường bên dưới,
74
+ **không chép nguyên văn JD**. Trang HTML thô nhờ vậy chỉ được đọc một lần ở bước xử lý rẻ của WebFetch và
75
+ không bao giờ vào context của collector.
76
+
77
+ 1. `WebFetch` từng URL với prompt trích xuất (song ngữ Việt–Anh), yêu cầu **CHỈ trả JSON** đúng khuôn:
78
+ ```
79
+ Trích tin tuyển dụng này thành JSON, KHÔNG chép nguyên văn JD, KHÔNG kèm giải thích ngoài JSON:
80
+ { "expired": <true nếu trang 404/410, "đã hết hạn/expired", hoặc deadline đã qua; ngược lại false>,
81
+ "title","company","location",
82
+ "remote": onsite|hybrid|remote|unknown,
83
+ "posted_date":"YYYY-MM-DD|unknown", "application_deadline":"YYYY-MM-DD|unknown",
84
+ "employment_type","language": vi|en|mixed,
85
+ "requirements": { "must_have_skills":[...], "nice_to_have_skills":[...], "min_years":<num|null>,
86
+ "seniority": intern|junior|mid|senior|lead|manager|director|unknown, "education" },
87
+ "salary": { "min","max","currency": VND|USD|unknown, "period": month|year|unknown, "negotiable" },
88
+ "industry","company_size": startup|sme|enterprise|unknown,
89
+ "contact": { "email","phone","zalo","form_url","how_to_apply" },
90
+ "summary": "≤ 50 từ: chỉ trách nhiệm/yêu cầu chính CHƯA nằm trong các field trên",
91
+ "full_jd_visible": <true nếu đọc được toàn bộ JD, false nếu chỉ thấy một phần> }
92
+ Trường không có bằng chứng trong trang → "unknown"/null/[]. KHÔNG bịa.
93
+ ```
94
+ Tôn trọng robots.txt/ToS. **Không** vượt anti-bot/CAPTCHA.
95
+ 2. Bỏ ngay nếu `expired = true` hoặc trang không phải JD. Chỉ giữ job đọc được nội dung đầy đủ
96
+ (`full_jd_visible = true`) và **còn hạn**. Nếu `posted_date` cho thấy tin **≥ 1 tháng** → cũng loại.
97
+ 3. Chuẩn hóa bản JSON đã trả (KHÔNG cần đọc lại trang): áp `bilingual-normalization` cho skills/location/lương;
98
+ map `summary` → `description` (giữ nguyên, **cap cứng ≤ 60 từ**; đừng phình lại thành JD); áp `job-schema`
99
+ để hoàn thiện; tạo `id` hash ổn định; set `extraction_confidence` trung thực (đọc đủ JD: 0.8–1.0);
100
+ thêm `source = <platform-slug>`, `collected_at`.
101
+ 4. Ghi mảng job hợp lệ vào `out_path` (`data/jobs/<run-id>.<platform-slug>.json`).
102
+ **Không ghi nguyên văn JD** vào bất kỳ trường nào — chỉ structured fields + `description` ngắn.
103
+ Trả về: số job lấy được, số bị bỏ (chặn/lỗi/hết hạn) kèm URL để điều phối viên có thể bù.
104
+
105
+ ---
106
+
107
+ ## Nguyên tắc
108
+ - **Chỉ làm việc trong nền tảng được giao** — không lan sang site khác.
109
+ - **Chỉ giữ job xem được full JD & còn hạn**. Ưu tiên độ chính xác hơn số lượng.
110
+ - Không bịa trường dữ liệu (xem job-schema). Thiếu → unknown.
111
+ - `url` phải là link trang tuyển dụng — luôn có mặt cho mọi job.
112
+ - Không tự nộp hồ sơ / gửi tin nhắn thay người dùng.
@@ -22,11 +22,19 @@ Mục tiêu: **chỉ tốn tối đa `fetch_count` (≤20) lần fetch** nhưng
22
22
 
23
23
  **Pha 2a — Search (rẻ, song song).** Với mỗi nền tảng trong `platforms` (đã chốt ở Bước 1), **spawn một `job-collector` `mode=search` song song trong CÙNG một message**, truyền `fetch_count`. Mỗi cái chỉ `WebSearch` (không fetch) và **lọc gọn ngay tại nguồn**: bỏ tin đăng **≥ 1 tháng**, bỏ link không phải JD / hết hạn / relevance thấp, cap top-`fetch_count` theo relevance, rồi ghi bản **gọn (KHÔNG snippet)** ra `data/jobs/<run-id>.<platform-slug>.candidates.json` (`{url, title, platform, relevance, posted_days}`).
24
24
 
25
- **Pha 2b — Chọn toàn cục (điều phối viên, không spawn).** Đọc tất cả file `*.candidates.json` (chỉ là các dòng gọn, nhẹ token), gộp, khử trùng theo URL chuẩn hóa, **xếp hạng toàn cục theo `relevance` + `posted_days` (tin mới ưu tiên)** — KHÔNG giới hạn theo nền tảng. Chọn **top `fetch_count` URL** làm danh sách fetch (một nền tảng giàu job có thể chiếm phần lớn slot). Lấy dư một ít (buffer ~30%, nhưng tổng ≤ 20 + buffer) để bù URL hỏng/hết hạn.
25
+ **Pha 2b — Chọn toàn cục + kiểm tra cache (điều phối viên, không spawn).**
26
+ 1. Đọc tất cả file `*.candidates.json` (gọn, nhẹ token), gộp, khử trùng theo URL chuẩn hóa.
27
+ 2. Xếp hạng toàn cục theo `relevance` + `posted_days` (tin mới ưu tiên) — KHÔNG giới hạn theo nền tảng. Chọn **top `fetch_count` URL + buffer ~30%** (tổng ≤ 20 + buffer) để bù URL hỏng/hết hạn.
28
+ 3. **Kiểm tra cache:** Glob `data/jobs/*.json` (các run trước, loại file `*.candidates.json` và file gộp run hiện tại). Với mỗi URL trong danh sách đã chọn, chuẩn hóa URL rồi tra trong tất cả job đã có (`url` hoặc `id`). Nếu tìm thấy **và** `posted_date` của job cached cho thấy tin **còn trong vòng 30 ngày** → đánh dấu `cached = true`, dùng lại bản đó (không fetch lại). URL còn lại (`cached = false`) → đưa vào danh sách fetch thật.
29
+ 4. **Thông báo ngắn** cho người dùng: "Tìm thấy X job từ cache (skip fetch), sẽ fetch Y URL mới."
26
30
 
27
- **Pha 2c — Fetch (song song).** Nhóm danh sách URL đã chọn **theo nền tảng**; với mỗi nhóm không rỗng, **spawn một `job-collector` `mode=fetch` song song**, truyền `urls` của nhóm đó, ghi `data/jobs/<run-id>.<platform-slug>.json`. Mỗi cái chỉ fetch đúng URL được giao.
31
+ **Pha 2c — Fetch (song song, chỉ URL chưa có cache).** Lấy danh sách URL `cached = false` từ pha 2b. Nhóm theo nền tảng; với mỗi nhóm không rỗng, **spawn một `job-collector` `mode=fetch` song song**, truyền `urls` của nhóm đó, ghi `data/jobs/<run-id>.<platform-slug>.json`. Nếu tất cả URL đều cache → bỏ qua pha này hoàn toàn.
28
32
 
29
- **Pha 2d — Gộp pool.** Đọc mọi file `data/jobs/<run-id>.<platform-slug>.json`, gộp, **khử trùng theo `id`/URL**, ghi `data/jobs/<run-id>.json`. Nếu sau khi bỏ job hỏng/hết hạn mà còn thiếu nhiều so với `fetch_count`, chọn thêm URL từ danh sách dự phòng (pha 2b) và fetch bù trước khi sang Bước 3. Xếp hạng/chọn 20 cuối cùng do Bước 3 (matcher) lo.
33
+ **Pha 2d — Gộp pool.** Gộp hai nguồn:
34
+ - Job fetch mới: đọc mọi `data/jobs/<run-id>.<platform-slug>.json`.
35
+ - Job từ cache: lấy trực tiếp từ object đã load ở pha 2b (không đọc lại file).
36
+
37
+ Khử trùng toàn bộ theo `id`/URL, ghi `data/jobs/<run-id>.json`. Nếu sau khi bỏ job hỏng/hết hạn mà còn thiếu nhiều so với `fetch_count`, chọn thêm URL từ danh sách dự phòng (pha 2b, ưu tiên URL chưa có cache) và fetch bù trước khi sang Bước 3. Xếp hạng/chọn 20 cuối cùng do Bước 3 (matcher) lo.
30
38
  - Chuẩn thu thập không đổi: chỉ giữ job xem được full JD & còn hạn.
31
39
 
32
40
  ## Bước 3 — Matcher (subagent `job-matcher`)
@@ -18,7 +18,7 @@
18
18
  "application_deadline": { "type": "string", "description": "YYYY-MM-DD hạn nộp nếu JD có ghi; job chỉ được giữ khi hạn >= ngày hiện tại" },
19
19
  "employment_type": { "type": "string", "enum": ["full-time", "part-time", "contract", "internship", "unknown"], "default": "unknown" },
20
20
  "language": { "type": "string", "enum": ["vi", "en", "mixed"], "default": "mixed" },
21
- "description": { "type": "string", "description": "JD gốc (rút gọn nếu quá dài)" },
21
+ "description": { "type": "string", "maxLength": 400, "description": "Tóm tắt ngắn ( 60 từ) phần trách nhiệm/yêu cầu chính CHƯA nằm trong structured fields. KHÔNG chép nguyên văn JD — giữ token thấp cho matcher/report." },
22
22
  "contact": {
23
23
  "type": "object",
24
24
  "description": "Thông tin liên hệ ứng tuyển trực tiếp nếu JD có",
@@ -0,0 +1,142 @@
1
+ ---
2
+ name: crawler-builder
3
+ description: Sinh một script crawler Python (chỉ stdlib) cho MỘT job board tại thời điểm chạy — WebFetch một tin mẫu làm ground-truth, viết crawler dựa trên cấu trúc thật của trang, validate output khớp mẫu rồi mới dùng để bóc cả batch. Dùng ở Tier 1 của job-collector khi board chưa có crawler sinh sẵn. Thay cho việc hard-code adapter từng site.
4
+ ---
5
+
6
+ # Crawler Builder — tự sinh crawler cho một board từ một tin mẫu
7
+
8
+ Thay vì hard-code adapter cho từng board, skill này dạy cách **để Agent tự viết crawler** cho một
9
+ domain cụ thể, ngay tại lúc chạy. Ý tưởng cốt lõi: **lấy một tin thật làm ground-truth**, viết
10
+ script bám đúng cấu trúc HTML/JSON-LD của board đó, rồi **so output của script với mẫu** — khớp mới dùng.
11
+
12
+ Mở rộng sang board mới = chạy lại flow này, **không cần commit code mới**.
13
+
14
+ ## Khi nào dùng
15
+ - Ở **Tier 1** của `job-collector`, cho `platform` được giao, khi **chưa** có file
16
+ `data/crawlers/<domain>.py`. Nếu file đã tồn tại và còn chạy đúng → dùng thẳng, bỏ qua skill này.
17
+ - Chỉ áp dụng cho board **được phép crawl** (xem Guardrail). Board bị cấm → **không** sinh crawler,
18
+ fallback `WebSearch` (chỉ lấy snippet công khai).
19
+
20
+ ## Thư viện dùng chung (không viết lại boilerplate)
21
+ Có sẵn `crawlers/base.py` (chỉ stdlib) — crawler sinh ra **import** nó, KHÔNG chép lại:
22
+ - HTTP: `http_get(url)` (gzip, User-Agent thật, retry, ném `FetchError` khi 404/410).
23
+ - JSON-LD: `extract_jsonld(html)`, `find_jobposting(html)`.
24
+ - Map schema.org → job.schema: `employment_type`, `salary_from_ld`, `location_from_ld`,
25
+ `remote_guess`, `language_guess`, `clean_skills`, `html_to_text`, `truncate_words(text, 60)`.
26
+ - Lọc: `is_expired(validThrough)`, `days_since(date)`, `stable_id(url)`, `lexical_relevance(query, title)`.
27
+ - **Validate: `validate_job(job)` → list lý do lỗi (rỗng = hợp lệ).** Dùng ở bước 4.
28
+
29
+ Ưu tiên đọc JSON-LD `JobPosting` (rẻ, ổn định); chỉ regex/parse HTML khi board **không** nhúng JSON-LD.
30
+
31
+ ## Flow (5 bước)
32
+
33
+ **1. Lấy MỘT tin mẫu làm ground-truth (một WebFetch duy nhất).**
34
+ Chọn một URL trang JD thật của board. `WebFetch` nó **một lần**, yêu cầu trả về **cấu trúc thô** để
35
+ viết crawler, KHÔNG phải bản tóm tắt:
36
+ ```
37
+ Trả về nguyên văn (verbatim), KHÔNG diễn giải:
38
+ 1) Có khối <script type="application/ld+json"> chứa @type "JobPosting" không? Nếu có, dán RAW JSON của nó.
39
+ 2) Nếu KHÔNG có JSON-LD: với mỗi trường (title, company, location, salary, posted_date,
40
+ application_deadline, employment_type, skills/requirements, description), dán đoạn HTML bao quanh
41
+ + CSS selector / thẻ + class để định vị nó.
42
+ 3) Trang search của board có URL/pattern gì (vd /it-jobs?query=...), và list kết quả nằm ở selector nào?
43
+ ```
44
+
45
+ **2. Tự tay dựng expected output = ground-truth.**
46
+ Từ dữ liệu bước 1, Agent map thủ công tin mẫu thành **một object đúng `job.schema`** (đây là đáp án
47
+ để đối chiếu). Trường không có bằng chứng → `unknown`/`null`/`[]`, **KHÔNG bịa**. Lưu tạm ra
48
+ `data/crawlers/.sample.<domain>.json` để so ở bước 4.
49
+
50
+ **3. Viết crawler `data/crawlers/<domain>.py`.**
51
+ Bám đúng cấu trúc quan sát ở bước 1. Định vị `crawlers/base.py` của plugin (Glob
52
+ `**/crawlers/base.py`; agent Claude có thể expand biến plugin-root nếu môi trường hỗ trợ), lấy
53
+ **đường dẫn tuyệt đối** thư mục chứa `base.py` rồi ghi cứng vào script để `import base` chạy được từ
54
+ mọi cwd. Khung tối thiểu:
55
+
56
+ ```python
57
+ #!/usr/bin/env python3
58
+ """Crawler <domain> — SINH TỰ ĐỘNG bởi crawler-builder. Chỉ stdlib."""
59
+ import argparse, json, os, sys
60
+ from datetime import date, datetime
61
+ sys.path.insert(0, r"<ĐƯỜNG_DẪN_TUYỆT_ĐỐI_TỚI/crawlers>") # thư mục chứa base.py, điền lúc sinh
62
+ from base import (FetchError, http_get, find_jobposting, extract_jsonld, employment_type,
63
+ salary_from_ld, location_from_ld, remote_guess, language_guess, clean_skills,
64
+ html_to_text, truncate_words, is_expired, days_since, stable_id,
65
+ lexical_relevance, now_iso, dedupe_by, validate_job)
66
+
67
+ PLATFORM = "<domain-slug>"
68
+ BASE = "https://<domain>"
69
+
70
+ def search(query, max_n=20, today=None):
71
+ """→ [{url, title, platform, relevance, posted_days}]. Một request search, rẻ."""
72
+ ... # bám selector/pattern quan sát ở bước 1
73
+
74
+ def fetch_one(url, today=None):
75
+ """→ dict job.schema. Ném FetchError nếu hết hạn / ≥30 ngày / không phải JD."""
76
+ html = http_get(url)
77
+ jp = find_jobposting(html) # ưu tiên JSON-LD; nếu None → parse HTML theo selector bước 1
78
+ ...
79
+ if is_expired(valid_through, today): raise FetchError(...)
80
+ if (d := days_since(date_posted, today)) is not None and d >= 30: raise FetchError(...)
81
+ return { ... } # đúng job.schema, description = truncate_words(desc, 60)
82
+
83
+ # --- CLI (giữ contract ổn định để job-collector gọi qua Bash) ---
84
+ def main():
85
+ ap = argparse.ArgumentParser()
86
+ ap.add_argument("--mode", required=True, choices=["search", "fetch"])
87
+ ap.add_argument("--query"); ap.add_argument("--max", type=int, default=20)
88
+ ap.add_argument("--urls-file"); ap.add_argument("--out"); ap.add_argument("--today")
89
+ a = ap.parse_args()
90
+ today = datetime.strptime(a.today, "%Y-%m-%d").date() if a.today else None
91
+ if a.mode == "search":
92
+ cands = dedupe_by(search(a.query, a.max, today), "url")
93
+ print(json.dumps({"platform": PLATFORM, "mode": "search", "count": len(cands),
94
+ "candidates": cands}, ensure_ascii=False)); return 0
95
+ urls = json.load(sys.stdin if a.urls_file == "-" else open(a.urls_file, encoding="utf-8"))
96
+ jobs, dropped = [], []
97
+ for u in urls:
98
+ try: jobs.append(fetch_one(u, today))
99
+ except FetchError as e: dropped.append({"url": u, "reason": str(e)})
100
+ except Exception as e: dropped.append({"url": u, "reason": f"{type(e).__name__}: {e}"})
101
+ jobs = dedupe_by(jobs, "id")
102
+ if a.out:
103
+ os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True)
104
+ json.dump(jobs, open(a.out, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
105
+ print(json.dumps({"platform": PLATFORM, "mode": "fetch", "fetched": len(jobs),
106
+ "dropped": dropped, "out": a.out, "collected_at": now_iso()}, ensure_ascii=False))
107
+ return 0
108
+
109
+ if __name__ == "__main__":
110
+ raise SystemExit(main())
111
+ ```
112
+
113
+ **4. Validate với ground-truth (KHÔNG gọi mạng lại nếu tránh được).**
114
+ ```
115
+ python data/crawlers/<domain>.py --mode fetch --urls-file - --today <run-id>
116
+ ```
117
+ cho URL mẫu, rồi:
118
+ - Chạy `base.validate_job(job)` — phải trả **list rỗng**.
119
+ - So từng field với `data/crawlers/.sample.<domain>.json` (bước 2). Lệch ở field quan trọng
120
+ (title/company/salary/skills/deadline) → **sửa script, lặp lại**, tối đa vài vòng.
121
+ - Thử thêm 1–2 URL khác cùng board để chắc không overfit đúng một trang.
122
+
123
+ **5. Bàn giao cho job-collector.**
124
+ Crawler đã pass ở `data/crawlers/<domain>.py`, gọi được cả `--mode search` và `--mode fetch`.
125
+ Lần chạy sau với cùng board: dùng thẳng file này, chỉ chạy lại skill khi board đổi layout
126
+ (validate fail) hoặc chưa có file.
127
+
128
+ ## Vị trí file
129
+ - Crawler sinh ra: `data/crawlers/<domain>.py` — **runtime artifact trong thư mục làm việc, đã gitignore**
130
+ (không commit vào plugin). Mẫu ground-truth: `data/crawlers/.sample.<domain>.json` (tạm, có thể xóa).
131
+ - `crawlers/base.py` trong plugin là **nguồn chung, chỉ đọc** — không sửa khi sinh crawler.
132
+
133
+ ## Guardrail (bắt buộc — bám ràng buộc pháp lý dự án)
134
+ - **LinkedIn:** KHÔNG sinh crawler (robots.txt + ToS cấm). Chỉ để user tự mở link thủ công.
135
+ - **TopCV:** ToS cấm scrape → **không** crawl trực tiếp; ưu tiên `WebSearch` lấy snippet công khai.
136
+ - **ITviec:** robots.txt mở = nguồn ưu tiên, nhưng T&C cấm **republish nguyên văn JD** → chỉ lấy
137
+ **dữ kiện + link**, `description` ≤ 60 từ (`truncate_words`), tuyệt đối không chép cả JD.
138
+ - **VietnamWorks / board khác:** chỉ JD **công khai** (không đăng nhập), có rate-limit (`base` đã nghỉ giữa request).
139
+ - **Chung:** tôn trọng robots.txt/ToS; **KHÔNG** vượt anti-bot/CAPTCHA; **KHÔNG** lưu dữ liệu cá nhân
140
+ ứng viên/HR (email/điện thoại HR…) — Luật 91/2025 + NĐ 13/2023; **KHÔNG bịa** field (thiếu → `unknown`,
141
+ hạ `extraction_confidence`); **không** tự nộp hồ sơ / gửi tin nhắn thay người dùng.
142
+ - Nghi ngờ một board có được phép không → **không đoán**, fallback `WebSearch`.
@@ -9,7 +9,13 @@ Nhận đường dẫn `profile.json`, `run_id` duy nhất và đường dẫn o
9
9
 
10
10
  ## Nguồn dữ liệu
11
11
 
12
- - Job boards: dùng web search/fetch sẵn của host. Chỉ giữ job đọc được JD và chưa có bằng chứng hết hạn.
12
+ - **Tier 1 crawler sinh tại chỗ (thử trước):** dùng skill `crawler-builder` để sinh
13
+ `data/crawlers/<domain>.py` (Python stdlib, import `crawlers/base.py`) bóc job ngoài context, tiết
14
+ kiệm token. Đã có file cho board → chạy thẳng; chưa có + board được phép → sinh mới; board bị cấm →
15
+ bỏ qua Tier 1. Hợp đồng CLI: `crawlers/README.md`.
16
+ - search: `python data/crawlers/<domain>.py --mode search --query "<song ngữ>" --max <N> --today <run-id>`
17
+ - fetch: `python data/crawlers/<domain>.py --mode fetch --urls-file <urls.json|-> --out data/jobs/<run_id>.<domain>.json --today <run-id>`
18
+ - **Tier 2 — web search/fetch** của host cho board bị cấm/nghi ngờ hoặc crawler chưa dựng được. Chỉ giữ job đọc được JD và chưa có bằng chứng hết hạn.
13
19
 
14
20
  ## Contract
15
21
 
@@ -35,7 +35,7 @@ Schema chính: `./schemas/job.schema.json`. Skill này hướng dẫn cách đi
35
35
 
36
36
  - Chỉ điền trường khi có bằng chứng trong dữ liệu nguồn. Không suy đoán lương/quy mô công ty nếu JD không nói → để `unknown`.
37
37
  - `url` luôn là link trang tuyển dụng gốc để người dùng bấm vào apply.
38
- - Rút gọn `description` nếu quá dài (giữ phần requirements + trách nhiệm chính).
38
+ - `description` **tóm tắt ngắn ≤ 60 từ** (cap cứng), chỉ nêu trách nhiệm/yêu cầu chính **chưa** nằm trong structured fields (`requirements`, `salary`, `remote`...). **KHÔNG chép nguyên văn JD** — nguyên văn sẽ trôi xuống matcher/report và bị đọc lại nhiều lần, tốn token. Ưu tiên để trống nếu structured fields đã đủ để chấm điểm.
39
39
 
40
40
  ## Ví dụ tin tuyển dụng chuẩn (Full JD)
41
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jobscout",
3
- "version": "1.6.3",
3
+ "version": "1.7.0",
4
4
  "description": "AI job matching — tìm và xếp hạng việc làm phù hợp CV, song ngữ Việt–Anh. Chạy trên Claude Code và Codex CLI.",
5
5
  "bin": {
6
6
  "jobscout": "bin/cli.mjs"