1"""JobsDB Hong Kong job-listing scraper — standard library only.
2
3Source: JobsDB's public search JSON (hk.jobsdb.com/api/jobsearch/v5/search).
4No login and no API key.
5
6Paging stops at page 1,000: page 1,001 answers HTTP 400 whatever the page
7size, so one query reaches at most 30,000 of the board's ~33,000 listings.
8Larger runs are split into one query per top-level industry classification;
9every listing has exactly one, and the biggest holds under 5,000.
10
11Employer-side listings only: company, title, salary band, district, industry
12classification and posting date. No candidate data and no personal data.
13"""
14
15from __future__ import annotations
16
17import gzip
18import http.client
19import json
20import time
21import urllib.error
22import urllib.parse
23import urllib.request
24from dataclasses import asdict, dataclass, field
25from datetime import datetime, timezone
26from typing import Iterator
27
28BASE = "https://hk.jobsdb.com/api/jobsearch/v5/search"
29UA = (
30 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
31 "(KHTML, like Gecko) Chrome/140.0 Safari/537.36"
32)
33PAGE_SIZE = 30
34MAX_PAGE = 1000
35QUERY_CAP = MAX_PAGE * PAGE_SIZE
36
37
38
39
40CLASSIFICATIONS = {
41 "1200": "Accounting",
42 "6251": "Administration & Office Support",
43 "6304": "Advertising, Arts & Media",
44 "1203": "Banking & Financial Services",
45 "1204": "Call Centre & Customer Service",
46 "7019": "CEO & General Management",
47 "6163": "Community Services & Development",
48 "1206": "Construction",
49 "6076": "Consulting & Strategy",
50 "6263": "Design & Architecture",
51 "6123": "Education & Training",
52 "1209": "Engineering",
53 "6205": "Farming, Animals & Conservation",
54 "1210": "Government & Defence",
55 "1211": "Healthcare & Medical",
56 "1212": "Hospitality & Tourism",
57 "6317": "Human Resources & Recruitment",
58 "6281": "Information & Communication Technology",
59 "1214": "Insurance & Superannuation",
60 "1216": "Legal",
61 "6092": "Manufacturing, Transport & Logistics",
62 "6008": "Marketing & Communications",
63 "6058": "Mining, Resources & Energy",
64 "1220": "Real Estate & Property",
65 "6043": "Retail & Consumer Products",
66 "6362": "Sales",
67 "1223": "Science & Technology",
68 "6261": "Self Employment",
69 "6246": "Sport & Recreation",
70 "1225": "Trades & Services",
71}
72
73
74@dataclass
75class Job:
76 """One job listing, flattened to a stable schema."""
77
78 job_id: str
79 title: str
80 company: str
81 salary_label: str | None
82 salary_min: int | None
83 salary_max: int | None
84 salary_period: str | None
85 currency: str
86 districts: list[str] = field(default_factory=list)
87 district_normalised: list[str] = field(default_factory=list)
88 classification: str | None = None
89 subclassification: str | None = None
90 work_types: list[str] = field(default_factory=list)
91 work_arrangements: list[str] = field(default_factory=list)
92 listing_date: str | None = None
93 teaser: str | None = None
94 url: str | None = None
95 scraped_at: str = ""
96
97 def to_dict(self) -> dict:
98 return asdict(self)
99
100
101class RateLimiter:
102 """Polite fixed-delay limiter — one request per `delay` seconds."""
103
104 def __init__(self, delay: float = 1.0):
105 self.delay = delay
106 self._last = 0.0
107
108 def wait(self) -> None:
109 gap = time.monotonic() - self._last
110 if gap < self.delay:
111 time.sleep(self.delay - gap)
112 self._last = time.monotonic()
113
114
115def _http_json(url: str, *, timeout: int = 25, retries: int = 3) -> dict:
116 headers = {
117 "User-Agent": UA,
118 "Accept": "application/json, text/plain, */*",
119 "Accept-Encoding": "gzip",
120 "Accept-Language": "zh-HK,zh;q=0.9,en;q=0.8",
121 "Referer": "https://hk.jobsdb.com/",
122 }
123 last_err: Exception | None = None
124 for attempt in range(retries):
125 try:
126 req = urllib.request.Request(url, headers=headers)
127 with urllib.request.urlopen(req, timeout=timeout) as resp:
128 raw = resp.read()
129 if resp.headers.get("Content-Encoding") == "gzip":
130 raw = gzip.decompress(raw)
131 return json.loads(raw.decode("utf-8"))
132
133
134 except (OSError, ValueError, http.client.HTTPException) as exc:
135 last_err = exc
136 if attempt < retries - 1:
137 time.sleep(2 ** attempt)
138 raise RuntimeError(f"request failed after {retries} attempts: {last_err}")
139
140
141def parse_salary(label: str | None) -> tuple[int | None, int | None, str | None]:
142 """Parse a salary label into (min, max, period).
143
144 JobsDB employers use several formats, all of which appear in live data:
145 '$25,000 – $30,000 per month' -> (25000, 30000, 'month')
146 'HKD 30000 - 35000 per month' -> (30000, 35000, 'month')
147 'HK$18,000 monthly' -> (18000, 18000, 'month')
148 '' / None / 'Negotiable' -> (None, None, None)
149 """
150 if not label:
151 return None, None, None
152 import re
153
154 period = None
155 low = label.lower()
156 for word, canon in (
157 ("per month", "month"), ("monthly", "month"), ("/month", "month"), ("p.m.", "month"),
158 ("per year", "year"), ("per annum", "year"), ("annually", "year"), ("yearly", "year"),
159 ("per hour", "hour"), ("hourly", "hour"),
160 ("per day", "day"), ("daily", "day"),
161 ("per week", "week"), ("weekly", "week"),
162 ):
163 if word in low:
164 period = canon
165 break
166
167
168
169
170 nums: list[int] = []
171 for m in re.finditer(r"(?:hk\$|hkd|\$)?\s?(\d[\d,]*)\s?(k\b)?", low):
172 raw = m.group(1).replace(",", "")
173 if not raw:
174 continue
175 try:
176 val = int(raw)
177 except ValueError:
178 continue
179 if m.group(2):
180 val *= 1000
181 floor = 10 if period in ("hour", "day") else 1000
182 if floor <= val <= 10_000_000:
183 nums.append(val)
184
185 if not nums:
186 return None, None, period
187 if len(nums) == 1:
188 return nums[0], nums[0], period
189 return min(nums), max(nums), period
190
191
192def normalise_district(label: str) -> str:
193 """Collapse JobsDB's inconsistent location labels onto the 18 HK districts.
194
195 JobsDB emits both "Central and Western District" and "Central, Central and
196 Western District" for the same place, which splits one district into two
197 buckets and wrecks any per-district aggregate. The official district name
198 is always the LAST comma-separated component; a handful of labels name a
199 region instead of a district, so those map explicitly.
200 """
201 if not label:
202 return ""
203 tail = label.split(",")[-1].strip()
204 if tail.endswith(" District"):
205 tail = tail[: -len(" District")].strip()
206 region_aliases = {
207 "Hong Kong Island": "Hong Kong Island",
208 "Kowloon": "Kowloon",
209 "New Territories": "New Territories",
210 "Hong Kong": "Hong Kong",
211 }
212 return region_aliases.get(tail, tail)
213
214
215def _parse_job(raw: dict, now: str) -> Job:
216 cls = (raw.get("classifications") or [{}])[0]
217 salary_label = raw.get("salaryLabel")
218 lo, hi, period = parse_salary(salary_label)
219 job_id = str(raw.get("id") or "")
220 districts = [loc.get("label") for loc in (raw.get("locations") or []) if loc.get("label")]
221 return Job(
222 job_id=job_id,
223 title=raw.get("title") or "",
224 company=raw.get("companyName") or (raw.get("advertiser") or {}).get("description") or "",
225 salary_label=salary_label,
226 salary_min=lo,
227 salary_max=hi,
228 salary_period=period,
229 currency="HKD",
230 districts=districts,
231 district_normalised=sorted({d for d in (normalise_district(x) for x in districts) if d}),
232 classification=(cls.get("classification") or {}).get("description"),
233 subclassification=(cls.get("subclassification") or {}).get("description"),
234 work_types=list(raw.get("workTypes") or []),
235 work_arrangements=[
236 w.get("label", {}).get("text") if isinstance(w, dict) else str(w)
237 for w in (raw.get("workArrangements") or {}).get("data", [])
238 ] if isinstance(raw.get("workArrangements"), dict) else [],
239 listing_date=raw.get("listingDate"),
240 teaser=(raw.get("teaser") or "").strip() or None,
241 url=f"https://hk.jobsdb.com/job/{job_id}" if job_id else None,
242 scraped_at=now,
243 )
244
245
246def _walk(
247 params: dict,
248 limiter: RateLimiter,
249 seen: set[str],
250 max_items: int,
251 now: str,
252 start_page: int = 1,
253) -> Iterator[Job]:
254 """Page through one query, never past MAX_PAGE, until rows or budget run out.
255
256 Also stops when a page brings nothing new, so an API that repeats itself
257 ends the walk instead of spinning to the page cap.
258 """
259 page = start_page
260 while len(seen) < max_items and page <= MAX_PAGE:
261 limiter.wait()
262 query = {**params, "page": page, "pageSize": PAGE_SIZE}
263 rows = _http_json(f"{BASE}?{urllib.parse.urlencode(query)}").get("data") or []
264 if not rows:
265 return
266 fresh = 0
267 for raw in rows:
268 job = _parse_job(raw, now)
269 if not job.job_id or job.job_id in seen:
270 continue
271 seen.add(job.job_id)
272 fresh += 1
273 yield job
274 if len(seen) >= max_items:
275 return
276 if fresh == 0:
277 return
278 page += 1
279
280
281def _count(params: dict) -> int:
282 query = {**params, "page": 1, "pageSize": 1}
283 return int(_http_json(f"{BASE}?{urllib.parse.urlencode(query)}").get("totalCount") or 0)
284
285
286def scrape_jobs(
287 *,
288 keywords: str | None = None,
289 classification: str | None = None,
290 max_items: int = 300,
291 delay: float = 1.0,
292 start_page: int = 1,
293) -> Iterator[Job]:
294 """Yield `Job` records from JobsDB HK, paginating politely.
295
296 keywords: free-text query ('engineer'); None = whole board.
297 classification: JobsDB classification id (e.g. '1209' Engineering).
298 max_items: hard stop so a runaway run can't hammer the source.
299
300 Up to 30,000 results come from one query in the source's own order. A
301 larger run over a larger result set is split by classification.
302 """
303 limiter = RateLimiter(delay)
304 now = datetime.now(timezone.utc).isoformat()
305 seen: set[str] = set()
306 base: dict = {"siteKey": "HK-Main"}
307 if keywords:
308 base["keywords"] = keywords
309
310 if classification or max_items <= QUERY_CAP or start_page > 1:
311 if classification:
312 base["classification"] = classification
313 yield from _walk(base, limiter, seen, max_items, now, start_page)
314 return
315
316 limiter.wait()
317 if _count(base) <= QUERY_CAP:
318 yield from _walk(base, limiter, seen, max_items, now)
319 return
320 for cid in CLASSIFICATIONS:
321 yield from _walk({**base, "classification": cid}, limiter, seen, max_items, now)
322
323
324def total_count(*, keywords: str | None = None, classification: str | None = None) -> int:
325 params: dict = {"siteKey": "HK-Main"}
326 if keywords:
327 params["keywords"] = keywords
328 if classification:
329 params["classification"] = classification
330 return _count(params)