190 lines
6.7 KiB
Python
190 lines
6.7 KiB
Python
import json
|
||
import random
|
||
import re
|
||
from typing import Any
|
||
|
||
SALARY_PATTERNS = [
|
||
r"ral\s*(?:lorda|annua|annuale)?\s*[:\-]?\s*",
|
||
r"retribuzione\s*(?:annua\s*)?lorda\s*[:\-]?\s*",
|
||
r"stipendio\s*(?:annuo|lordo)?\s*[:\-]?\s*",
|
||
r"compensation\s*[:\-]?\s*",
|
||
r"salary\s*(?:range)?\s*[:\-]?\s*",
|
||
r"ctc\s*[:\-]?\s*",
|
||
r"package\s*[:\-]?\s*",
|
||
]
|
||
|
||
NUMBER = r"(?:€|eur|euro)?\s*(\d{1,3}(?:[.\s]\d{3})+|\d{2,6})\s*(?:€|eur|k|mila)?"
|
||
RANGE = (
|
||
r"(?:tra|from|between)?\s*"
|
||
+ NUMBER
|
||
+ r"\s*(?:-|–|—|/|e|and|to|a)\s*"
|
||
+ NUMBER
|
||
)
|
||
|
||
BANDS = [
|
||
(0, 25000, "Less than 25k"),
|
||
(25000, 30000, "Between 25k and 30k"),
|
||
(30000, 35000, "Between 30k and 35k"),
|
||
(35000, 40000, "Between 35k and 40k"),
|
||
(40000, 45000, "Between 40k and 45k"),
|
||
(45000, 50000, "Between 45k and 50k"),
|
||
(50000, 55000, "Between 50k and 55k"),
|
||
(55000, 60000, "Between 55k and 60k"),
|
||
(60000, 70000, "Between 60k and 70k"),
|
||
(70000, 80000, "Between 70k and 80k"),
|
||
(80000, 90000, "Between 80k and 90k"),
|
||
(90000, 100000, "Between 90k and 100k"),
|
||
(100000, 120000, "Between 100k and 120k"),
|
||
(120000, 150000, "Between 120k and 150k"),
|
||
(150000, 10**9, "Above 150k"),
|
||
]
|
||
|
||
|
||
def parse_amount(raw: str) -> int:
|
||
compact = raw.lower().replace(" ", "").replace("€", "").replace("eur", "")
|
||
multiplier = 1000 if compact.endswith("k") or compact.endswith("mila") else 1
|
||
compact = compact.replace("k", "").replace("mila", "").replace(".", "")
|
||
digits = re.sub(r"[^\d]", "", compact)
|
||
if not digits:
|
||
return 0
|
||
value = int(digits)
|
||
if multiplier == 1000 and value < 1000:
|
||
value *= 1000
|
||
if 1000 <= value < 10000 and not raw.lower().endswith(("k", "mila")):
|
||
# 35.000 style already handled; 35000 stays
|
||
pass
|
||
return value
|
||
|
||
|
||
def extract_salary(text: str) -> dict[str, int | None]:
|
||
blob = text.lower().replace("\xa0", " ")
|
||
ral_min = ral_max = ral_exact = None
|
||
|
||
for match in re.finditer(RANGE, blob, flags=re.I):
|
||
a, b = parse_amount(match.group(1)), parse_amount(match.group(2))
|
||
if 15000 <= a <= 400000 and 15000 <= b <= 400000:
|
||
ral_min, ral_max = min(a, b), max(a, b)
|
||
break
|
||
|
||
if ral_min is None:
|
||
labeled = re.search(
|
||
r"(?:ral|retribuzione|salary|stipendio|package)[^\n]{0,40}" + NUMBER,
|
||
blob,
|
||
flags=re.I,
|
||
)
|
||
if labeled:
|
||
value = parse_amount(labeled.group(1))
|
||
if 15000 <= value <= 400000:
|
||
ral_exact = value
|
||
|
||
if ral_min is None and ral_exact is None:
|
||
above = re.search(r"(?:above|oltre|maggiore di|>)\s*" + NUMBER, blob, flags=re.I)
|
||
if above:
|
||
value = parse_amount(above.group(1))
|
||
if 15000 <= value <= 400000:
|
||
ral_min = value
|
||
ral_max = value + 20000
|
||
less = re.search(r"(?:less than|fino a|inferiore a|<)\s*" + NUMBER, blob, flags=re.I)
|
||
if less and ral_min is None:
|
||
value = parse_amount(less.group(1))
|
||
if 15000 <= value <= 400000:
|
||
ral_min = max(15000, value - 15000)
|
||
ral_max = value
|
||
|
||
return {"ral_min": ral_min, "ral_max": ral_max, "ral_exact": ral_exact}
|
||
|
||
|
||
def hide_salary(text: str) -> str:
|
||
cleaned = text
|
||
replacements = [
|
||
RANGE,
|
||
r"(?:ral|retribuzione\s*annua\s*lorda|stipendio|salary|compensation|ctc)\s*[:\-]?\s*(?:€|eur|euro)?\s*\d[\d.\sKkmila€euro\-–toaeand/]{1,48}",
|
||
r"(?:€|eur)\s*\d{2,3}(?:[.\s]\d{3})+(?:\s*(?:-|–|to|a)\s*(?:€|eur)?\s*\d{2,3}(?:[.\s]\d{3})+)?",
|
||
r"\b\d{2,3}\s*k(?:\s*(?:-|–|to|a)\s*\d{2,3}\s*k)?\b",
|
||
r"\b\d{5,6}\s*(?:€|eur|euro)?\b",
|
||
]
|
||
for pattern in replacements:
|
||
cleaned = re.sub(pattern, "[RAL HIDDEN]", cleaned, flags=re.I)
|
||
cleaned = re.sub(r"(\[RAL HIDDEN\]\s*){2,}", "[RAL HIDDEN] ", cleaned)
|
||
return cleaned.strip()
|
||
|
||
|
||
def midpoint(job: Any) -> int:
|
||
if job.ral_exact:
|
||
return job.ral_exact
|
||
if job.ral_min and job.ral_max:
|
||
return (job.ral_min + job.ral_max) // 2
|
||
return job.ral_min or job.ral_max or 40000
|
||
|
||
|
||
def correct_band(value: int) -> tuple[int, int, str]:
|
||
for low, high, label in BANDS:
|
||
if low <= value < high:
|
||
return low, high, label
|
||
return BANDS[-1]
|
||
|
||
|
||
def generate_options(job: Any) -> tuple[list[dict], str]:
|
||
value = midpoint(job)
|
||
idx = next(i for i, (low, high, _) in enumerate(BANDS) if low <= value < high)
|
||
|
||
start = max(0, idx - 2)
|
||
end = min(len(BANDS), start + 4)
|
||
start = max(0, end - 4)
|
||
chosen = BANDS[start:end]
|
||
if len(chosen) < 4:
|
||
chosen = BANDS[:4]
|
||
|
||
options = []
|
||
correct = ""
|
||
for low, high, label in chosen:
|
||
if high >= 10**9:
|
||
key = f"above_{low}"
|
||
text = f"Above {low // 1000}k"
|
||
elif low == 0:
|
||
key = f"less_{high}"
|
||
text = f"Less than {high // 1000}k"
|
||
else:
|
||
key = f"between_{low}_{high}"
|
||
text = f"Between {low // 1000}k and {high // 1000}k"
|
||
is_correct = low <= value < high
|
||
if is_correct:
|
||
correct = key
|
||
options.append({"key": key, "label": text})
|
||
|
||
random.shuffle(options)
|
||
return options, correct
|
||
|
||
|
||
def option_payload(job: Any) -> str:
|
||
options, correct = generate_options(job)
|
||
return json.dumps({"options": options, "correct": correct})
|
||
|
||
|
||
def detect_work_type(text: str) -> str:
|
||
blob = text.lower()
|
||
if any(w in blob for w in ["full remote", "fully remote", "100% remote", "full-remote", "da remoto"]):
|
||
return "remote"
|
||
if any(w in blob for w in ["hybrid", "ibrido", "smart working"]):
|
||
return "hybrid"
|
||
if any(w in blob for w in ["on-site", "onsite", "in sede", "in office", "presenza"]):
|
||
return "onsite"
|
||
return "hybrid"
|
||
|
||
|
||
def detect_subject(title: str, description: str) -> str:
|
||
blob = f"{title} {description}".lower()
|
||
mapping = {
|
||
"technology": ["developer", "engineer", "software", "data", "devops", "it ", "python", "java", "cloud"],
|
||
"financial": ["bank", "finance", "fintech", "accounting", "auditor", "investment"],
|
||
"business": ["sales", "account manager", "business", "marketing", "product manager"],
|
||
"healthcare": ["nurse", "medical", "pharma", "clinic", "health"],
|
||
"legal": ["lawyer", "legal", "compliance", "avvocato"],
|
||
"hr": ["recruiter", "human resources", "hr ", "people"],
|
||
"design": ["designer", "ux", "ui", "graphic"],
|
||
}
|
||
for subject, words in mapping.items():
|
||
if any(w in blob for w in words):
|
||
return subject
|
||
return "other"
|