Download script/scrape_shl.py from kshitiz14/SHL: direct link, hf CLI and curl.
- Browser
- Download file 10.7 kB
-
https://huggingface.co/spaces/kshitiz14/SHL/resolve/main/script/scrape_shl.py
- Command line
-
hf download hf://spaces/kshitiz14/SHL/script/scrape_shl.py
-
curl -L -o scrape_shl.py https://huggingface.co/spaces/kshitiz14/SHL/resolve/main/script/scrape_shl.py
10.7 kB
| """ | |
| SHL Catalog Scraper | |
| ==================== | |
| Scrapes the SHL product catalog and saves to data/shl_catalog.json | |
| matching the schema expected by the retrieval module: | |
| { | |
| "name": str, | |
| "url": str, | |
| "description": str, | |
| "test_type": str, # single letter code e.g. "A", "K", "P", "B", "S" | |
| "keys": [str], | |
| "duration": str, | |
| "languages": [str], | |
| "job_levels": [str] | |
| } | |
| SHL exposes a paginated JSON API used by its catalog page. | |
| We query that directly instead of parsing HTML (which is JS-rendered). | |
| """ | |
| import json | |
| import os | |
| import re | |
| import time | |
| import requests | |
| from pathlib import Path | |
| # -- Output path | |
| HERE = Path(__file__).resolve().parent | |
| OUTPUT_PATH = HERE.parent / "data" / "shl_catalog.json" | |
| # -- SHL catalog API | |
| # SHL's catalog page calls this JSON endpoint for the product grid. | |
| CATALOG_API = "https://www.shl.com/api/product-catalog/" | |
| HEADERS = { | |
| "User-Agent": ( | |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " | |
| "AppleWebKit/537.36 (KHTML, like Gecko) " | |
| "Chrome/124.0.0.0 Safari/537.36" | |
| ), | |
| "Accept": "application/json, text/plain, */*", | |
| "Referer": "https://www.shl.com/products/product-catalog/", | |
| } | |
| # -- Test-type letter mapping (from SHL docs / existing catalog) | |
| TEST_TYPE_MAP = { | |
| "ability & aptitude": "A", | |
| "biodata & situational judgment": "B", | |
| "competencies": "C", | |
| "development & 360": "D", | |
| "knowledge & skills": "K", | |
| "personality & behavior": "P", | |
| "simulations": "S", | |
| "assessment exercises": "E", | |
| } | |
| def _letter_from_keys(keys): | |
| """Derive the primary test_type letter from a list of category keys.""" | |
| for k in keys: | |
| code = TEST_TYPE_MAP.get(k.lower().strip()) | |
| if code: | |
| return code | |
| return "K" # default | |
| def fetch_catalog_via_api(start=0, limit=100): | |
| """ | |
| Try to fetch a page of products from SHL JSON catalog API. | |
| Returns parsed JSON dict, or None on failure. | |
| """ | |
| params = { | |
| "start": start, | |
| "limit": limit, | |
| "type": "", | |
| "industry": "", | |
| "jobLevel": "", | |
| } | |
| try: | |
| resp = requests.get(CATALOG_API, headers=HEADERS, params=params, timeout=20) | |
| resp.raise_for_status() | |
| return resp.json() | |
| except Exception as e: | |
| print(f" [API] Error at start={start}: {e}") | |
| return None | |
| def scrape_via_api(): | |
| """Collect all products using the JSON API endpoint.""" | |
| catalog = [] | |
| start = 0 | |
| limit = 100 | |
| while True: | |
| print(f" Fetching API page: start={start} ...") | |
| data = fetch_catalog_via_api(start, limit) | |
| if data is None: | |
| print(" API request failed -- stopping.") | |
| break | |
| products = ( | |
| data.get("products") | |
| or data.get("results") | |
| or data.get("items") | |
| or data.get("data") | |
| or [] | |
| ) | |
| if not products: | |
| print(f" No products returned at start={start}. Done.") | |
| break | |
| for p in products: | |
| item = normalize_api_product(p) | |
| if item: | |
| catalog.append(item) | |
| print(f" Got {len(products)} products (total so far: {len(catalog)})") | |
| if len(products) < limit: | |
| break | |
| start += limit | |
| time.sleep(1) | |
| return catalog | |
| def normalize_api_product(p): | |
| """Normalize a raw API product dict into our catalog schema.""" | |
| name = p.get("name") or p.get("title") or "" | |
| if not name: | |
| return None | |
| url = p.get("url") or p.get("link") or p.get("href") or "" | |
| if url and not url.startswith("http"): | |
| url = "https://www.shl.com" + url | |
| description = p.get("description") or p.get("summary") or p.get("body") or "" | |
| raw_keys = ( | |
| p.get("keys") | |
| or p.get("categories") | |
| or p.get("tags") | |
| or p.get("types") | |
| or [] | |
| ) | |
| if isinstance(raw_keys, str): | |
| raw_keys = [k.strip() for k in raw_keys.split(",") if k.strip()] | |
| keys = [str(k) for k in raw_keys] | |
| test_type = ( | |
| p.get("test_type") | |
| or p.get("testType") | |
| or p.get("type") | |
| or _letter_from_keys(keys) | |
| ) | |
| if len(str(test_type)) > 2: | |
| test_type = TEST_TYPE_MAP.get(str(test_type).lower().strip(), _letter_from_keys(keys)) | |
| duration = p.get("duration") or p.get("time") or p.get("timing") or "" | |
| if isinstance(duration, (int, float)): | |
| duration = f"{duration} minutes" | |
| raw_langs = p.get("languages") or p.get("language") or [] | |
| if isinstance(raw_langs, str): | |
| raw_langs = [l.strip() for l in raw_langs.split(",") if l.strip()] | |
| languages = [str(l) for l in raw_langs] | |
| raw_levels = p.get("job_levels") or p.get("jobLevels") or p.get("levels") or [] | |
| if isinstance(raw_levels, str): | |
| raw_levels = [lv.strip() for lv in raw_levels.split(",") if lv.strip()] | |
| job_levels = [str(lv) for lv in raw_levels] | |
| return { | |
| "name": str(name).strip(), | |
| "url": str(url).strip(), | |
| "description": str(description).strip(), | |
| "test_type": str(test_type).strip(), | |
| "keys": keys, | |
| "duration": str(duration).strip(), | |
| "languages": languages, | |
| "job_levels": job_levels, | |
| } | |
| def scrape_via_html_sitemap(): | |
| """ | |
| Fallback: collect product URLs from sitemap.xml. | |
| Returns a list of product page URLs. | |
| """ | |
| SITEMAP_URL = "https://www.shl.com/sitemap.xml" | |
| urls = [] | |
| try: | |
| resp = requests.get(SITEMAP_URL, headers=HEADERS, timeout=20) | |
| resp.raise_for_status() | |
| pattern = re.compile( | |
| r"<loc>(https://www\.shl\.com/products/product-catalog/view/[^<]+)</loc>" | |
| ) | |
| urls = pattern.findall(resp.text) | |
| print(f" Found {len(urls)} product URLs in sitemap.") | |
| except Exception as e: | |
| print(f" Sitemap fetch failed: {e}") | |
| return urls | |
| def scrape_product_page(url): | |
| """ | |
| Scrape an individual SHL product page using BeautifulSoup. | |
| Uses multiple selector strategies to handle different page layouts. | |
| """ | |
| from bs4 import BeautifulSoup | |
| try: | |
| resp = requests.get(url, headers=HEADERS, timeout=20) | |
| resp.raise_for_status() | |
| except Exception as e: | |
| print(f" Error fetching {url}: {e}") | |
| return None | |
| soup = BeautifulSoup(resp.text, "html.parser") | |
| # -- Name: SHL uses <h1> | |
| name_tag = soup.find("h1") or soup.find( | |
| "h2", class_=lambda c: c and "title" in c.lower() if c else False | |
| ) | |
| name = name_tag.get_text(strip=True) if name_tag else "" | |
| # -- Description | |
| desc_tag = ( | |
| soup.find("div", class_=lambda c: c and "description" in c.lower() if c else False) | |
| or soup.find("div", class_=lambda c: c and "body" in c.lower() if c else False) | |
| or soup.find("div", class_=lambda c: c and "content" in c.lower() if c else False) | |
| ) | |
| if desc_tag: | |
| description = desc_tag.get_text(separator=" ", strip=True) | |
| else: | |
| first_p = soup.find("p") | |
| description = first_p.get_text(strip=True) if first_p else "" | |
| # -- Metadata: try <dl>, <table>, then labelled containers | |
| meta = {} | |
| for dl in soup.find_all("dl"): | |
| terms = dl.find_all("dt") | |
| defs = dl.find_all("dd") | |
| for dt, dd in zip(terms, defs): | |
| key = dt.get_text(strip=True).lower().rstrip(":") | |
| vals = [v.strip() for v in dd.get_text(separator=",").split(",") if v.strip()] | |
| meta[key] = vals | |
| for table in soup.find_all("table"): | |
| for row in table.find_all("tr"): | |
| cells = row.find_all(["td", "th"]) | |
| if len(cells) >= 2: | |
| key = cells[0].get_text(strip=True).lower().rstrip(":") | |
| vals = [v.strip() for v in cells[1].get_text(separator=",").split(",") if v.strip()] | |
| meta[key] = vals | |
| for container in soup.find_all( | |
| ["div", "section", "li"], | |
| class_=lambda c: c and any( | |
| x in c.lower() for x in ["detail", "spec", "meta", "attr", "info"] | |
| ) if c else False, | |
| ): | |
| label_el = container.find(["label", "span", "strong", "b", "dt"]) | |
| value_el = container.find(["p", "span", "dd"]) | |
| if label_el and value_el and label_el != value_el: | |
| key = label_el.get_text(strip=True).lower().rstrip(":") | |
| vals = [v.strip() for v in value_el.get_text(separator=",").split(",") if v.strip()] | |
| if key and vals: | |
| meta.setdefault(key, vals) | |
| def _get(keys_list): | |
| for k in keys_list: | |
| if k in meta: | |
| return meta[k] | |
| return [] | |
| keys_val = _get(["assessment type", "keys", "categories", "type", "solution type"]) | |
| test_type = _letter_from_keys(keys_val) | |
| duration_vals = _get(["duration", "timing", "time limit", "test duration"]) | |
| duration = duration_vals[0] if duration_vals else "" | |
| languages = _get(["languages", "language", "available languages"]) | |
| job_levels = _get(["job levels", "job level", "levels", "level"]) | |
| if not name: | |
| slug = url.rstrip("/").split("/")[-1] | |
| name = slug.replace("-", " ").title() | |
| return { | |
| "name": name, | |
| "url": url, | |
| "description": description, | |
| "test_type": test_type, | |
| "keys": keys_val, | |
| "duration": duration, | |
| "languages": languages, | |
| "job_levels": job_levels, | |
| } | |
| def main(): | |
| print("=" * 60) | |
| print("SHL Catalog Scraper") | |
| print("=" * 60) | |
| catalog = [] | |
| # Step 1: Try the JSON API | |
| print("\n[Step 1] Trying SHL JSON API ...") | |
| catalog = scrape_via_api() | |
| if catalog: | |
| print(f"\nAPI scrape succeeded: {len(catalog)} products collected.") | |
| else: | |
| # Step 2: Fallback -- sitemap + individual page scraping | |
| print("\n[Step 2] API returned nothing. Falling back to sitemap + HTML scraping ...") | |
| product_urls = scrape_via_html_sitemap() | |
| if not product_urls: | |
| print(" No URLs found in sitemap either. Aborting.") | |
| return | |
| total = len(product_urls) | |
| for i, url in enumerate(product_urls, 1): | |
| print(f" [{i}/{total}] Scraping: {url}") | |
| item = scrape_product_page(url) | |
| if item: | |
| catalog.append(item) | |
| time.sleep(1.5) | |
| print(f"\nHTML scrape complete: {len(catalog)} products collected.") | |
| # Save output | |
| OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) | |
| with open(OUTPUT_PATH, "w", encoding="utf-8") as f: | |
| json.dump(catalog, f, indent=2, ensure_ascii=False) | |
| print(f"\nSaved {len(catalog)} products to: {OUTPUT_PATH}") | |
| print("=" * 60) | |
| if __name__ == "__main__": | |
| main() | |