"""
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"