2026-03-20 19:53:16 +01:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
|
|
|
|
Convert salary data from Excel to JSON format.
|
|
|
|
|
|
|
|
|
|
This script converts an Excel file containing company salary data
|
|
|
|
|
into the JSON format expected by salary_lookup.py.
|
|
|
|
|
|
|
|
|
|
Prerequisites:
|
|
|
|
|
pip install openpyxl
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
python tools/convert_salary_excel.py <path-to-excel-file>
|
|
|
|
|
python tools/convert_salary_excel.py <path-to-excel-file> --source "My Union Stats 2025"
|
|
|
|
|
python tools/convert_salary_excel.py <path-to-excel-file> --baseline 100 --baseline-desc "Index 100 = median salary"
|
|
|
|
|
|
|
|
|
|
The output file (salary_data.json) will be written to the repository root.
|
|
|
|
|
|
|
|
|
|
Expected Excel format:
|
|
|
|
|
- A header row with column names
|
|
|
|
|
- A "Company" or "Firma" column (required)
|
|
|
|
|
- An optional "City" or "By" column
|
|
|
|
|
- Any number of numeric data columns (salary index, count, etc.)
|
|
|
|
|
|
|
|
|
|
The script auto-detects the header row and column layout. For Excel files
|
|
|
|
|
with paired count/index columns per category, it groups them automatically.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import sys
|
|
|
|
|
import argparse
|
2026-07-08 23:11:03 +08:00
|
|
|
import re
|
2026-03-20 19:53:16 +01:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
import openpyxl
|
|
|
|
|
except ImportError:
|
2026-07-08 23:11:03 +08:00
|
|
|
openpyxl = None
|
2026-03-20 19:53:16 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# Column name patterns for auto-detection
|
|
|
|
|
COMPANY_PATTERNS = {"firma", "company", "virksomhed", "employer", "arbejdsgiver"}
|
|
|
|
|
CITY_PATTERNS = {"by", "city", "kommune", "location", "lokation", "sted"}
|
|
|
|
|
COUNT_PATTERNS = {"antal", "count", "number", "n", "employees", "medarbejdere"}
|
|
|
|
|
INDEX_PATTERNS = {"indeks", "index", "idx", "salary", "løn", "median", "average", "gennemsnit"}
|
2026-07-10 08:06:05 +02:00
|
|
|
# "Compound" tokens: pattern words allowed to match as a substring of a larger
|
|
|
|
|
# header token, for languages that glue words together (e.g. Danish "lønindeks"
|
|
|
|
|
# -> løn + indeks). Languages that write headers as separate words need none.
|
|
|
|
|
# Ships populated for this repo's Danish demonstration data; a fork targeting
|
|
|
|
|
# another locale edits this constant.
|
|
|
|
|
COMPOUND_PATTERNS = {"antal", "indeks", "løn", "gennemsnit", "medarbejdere"}
|
2026-07-14 19:11:11 +01:00
|
|
|
# Identifier columns (employee id, Danish "personnummer", etc.) are never salary
|
|
|
|
|
# data. They are dropped at classification so they are not mistaken for a salary
|
|
|
|
|
# category. Matched as whole tokens only, like other pattern sets.
|
|
|
|
|
ID_PATTERNS = {"id", "personnummer"}
|
2026-03-20 19:53:16 +01:00
|
|
|
|
|
|
|
|
|
2026-08-02 20:15:09 +01:00
|
|
|
def parse_numeric_cell(value):
|
|
|
|
|
"""Parse numeric Excel values, including localized string cells."""
|
|
|
|
|
if isinstance(value, (int, float)):
|
|
|
|
|
return float(value)
|
|
|
|
|
if not isinstance(value, str):
|
|
|
|
|
raise ValueError("not numeric")
|
|
|
|
|
|
|
|
|
|
text = value.strip().replace("\u00a0", " ").replace(" ", "")
|
|
|
|
|
if not text:
|
|
|
|
|
raise ValueError("not numeric")
|
|
|
|
|
if "," in text and "." in text:
|
2026-08-19 19:48:50 +02:00
|
|
|
# The separator that appears last is the decimal separator: European
|
|
|
|
|
# "1.234,56" and US "1,234.56" are both unambiguous here, unlike the
|
|
|
|
|
# single-separator cases below.
|
|
|
|
|
if text.rfind(",") > text.rfind("."):
|
|
|
|
|
text = text.replace(".", "").replace(",", ".")
|
|
|
|
|
else:
|
|
|
|
|
text = text.replace(",", "")
|
2026-08-02 20:15:09 +01:00
|
|
|
elif "," in text:
|
|
|
|
|
if re.fullmatch(r"[+-]?\d+,\d{3}", text):
|
|
|
|
|
raise ValueError("ambiguous comma separator")
|
|
|
|
|
text = text.replace(",", ".")
|
2026-08-14 03:51:06 -05:00
|
|
|
elif "." in text:
|
|
|
|
|
if re.fullmatch(r"[+-]?\d+\.\d{3}", text):
|
|
|
|
|
raise ValueError("ambiguous dot separator")
|
2026-08-02 20:15:09 +01:00
|
|
|
return float(text)
|
|
|
|
|
|
|
|
|
|
|
2026-07-08 23:11:03 +08:00
|
|
|
def header_matches(header, patterns):
|
2026-07-10 08:06:05 +02:00
|
|
|
"""Return True when a header contains a meaningful pattern match.
|
|
|
|
|
|
|
|
|
|
Patterns match whole tokens; any pattern also listed in
|
|
|
|
|
``COMPOUND_PATTERNS`` may additionally match as a substring, to handle
|
|
|
|
|
languages that form compound words.
|
|
|
|
|
"""
|
2026-07-08 23:11:03 +08:00
|
|
|
h = header.lower().strip()
|
|
|
|
|
tokens = set(re.findall(r"[a-zæøåöäü0-9]+", h))
|
|
|
|
|
|
|
|
|
|
for p in patterns:
|
2026-07-08 22:12:36 +03:00
|
|
|
if p in tokens:
|
|
|
|
|
return True
|
2026-07-10 08:06:05 +02:00
|
|
|
if p in COMPOUND_PATTERNS and p in h:
|
2026-07-08 23:11:03 +08:00
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def strip_type_patterns(header, patterns):
|
2026-08-19 19:48:50 +02:00
|
|
|
"""Remove count/index words from a header to derive a category name.
|
|
|
|
|
|
|
|
|
|
Mirrors ``header_matches``: patterns strip as whole tokens, and any
|
|
|
|
|
pattern also listed in ``COMPOUND_PATTERNS`` additionally strips as a
|
|
|
|
|
substring - otherwise a compound header like "Lønindeks alle" keeps the
|
|
|
|
|
type word in its category name and can never pair with "Antal alle".
|
|
|
|
|
"""
|
2026-07-08 23:11:03 +08:00
|
|
|
name = header.lower()
|
|
|
|
|
for p in patterns:
|
2026-07-08 22:12:36 +03:00
|
|
|
name = re.sub(rf"(?<![a-zæøåöäü0-9]){re.escape(p)}(?![a-zæøåöäü0-9])", "", name)
|
2026-08-19 19:48:50 +02:00
|
|
|
if p in COMPOUND_PATTERNS:
|
|
|
|
|
name = name.replace(p, "")
|
2026-07-08 23:11:03 +08:00
|
|
|
return name.strip(" _-")
|
|
|
|
|
|
|
|
|
|
|
2026-03-20 19:53:16 +01:00
|
|
|
def detect_column_type(header):
|
|
|
|
|
"""Detect whether a column header refers to count or index data."""
|
2026-07-08 23:11:03 +08:00
|
|
|
if header_matches(header, COUNT_PATTERNS):
|
|
|
|
|
return "count"
|
|
|
|
|
if header_matches(header, INDEX_PATTERNS):
|
|
|
|
|
return "index"
|
2026-03-20 19:53:16 +01:00
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_sheet(ws, sheet_label=None):
|
|
|
|
|
"""Parse a single worksheet into a list of company entries and detected categories."""
|
|
|
|
|
# Find header row
|
|
|
|
|
header_row = None
|
|
|
|
|
for row_idx, row in enumerate(ws.iter_rows(min_row=1, max_row=10, values_only=False), start=1):
|
|
|
|
|
for cell in row:
|
2026-07-14 13:35:45 +01:00
|
|
|
if cell.value and header_matches(str(cell.value), COMPANY_PATTERNS):
|
2026-03-20 19:53:16 +01:00
|
|
|
header_row = row_idx
|
|
|
|
|
break
|
|
|
|
|
if header_row:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if header_row is None:
|
|
|
|
|
print(f"Warning: Could not find header row in sheet '{ws.title}'. Skipping.", file=sys.stderr)
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
# Read headers
|
|
|
|
|
headers = []
|
|
|
|
|
for cell in ws[header_row]:
|
|
|
|
|
headers.append(str(cell.value).strip() if cell.value else "")
|
|
|
|
|
|
|
|
|
|
# Find company and city columns
|
|
|
|
|
company_col = None
|
|
|
|
|
city_col = None
|
|
|
|
|
for i, h in enumerate(headers):
|
2026-07-14 13:35:45 +01:00
|
|
|
if header_matches(h, COMPANY_PATTERNS):
|
2026-03-20 19:53:16 +01:00
|
|
|
company_col = i
|
2026-07-20 13:20:53 -05:00
|
|
|
elif header_matches(h, CITY_PATTERNS):
|
2026-03-20 19:53:16 +01:00
|
|
|
city_col = i
|
|
|
|
|
|
|
|
|
|
if company_col is None:
|
|
|
|
|
print(f"Warning: Could not find company column in sheet '{ws.title}'.", file=sys.stderr)
|
|
|
|
|
return []
|
|
|
|
|
|
2026-07-14 19:11:11 +01:00
|
|
|
# Identify data columns (everything that's not company/city or an identifier)
|
2026-03-20 19:53:16 +01:00
|
|
|
data_cols = []
|
|
|
|
|
for i, h in enumerate(headers):
|
|
|
|
|
if i == company_col or i == city_col or not h:
|
|
|
|
|
continue
|
2026-07-14 19:11:11 +01:00
|
|
|
if header_matches(h, ID_PATTERNS):
|
|
|
|
|
continue
|
2026-03-20 19:53:16 +01:00
|
|
|
data_cols.append((i, h))
|
|
|
|
|
|
2026-07-22 13:34:48 -05:00
|
|
|
# Group data columns by detected type and derive category names
|
|
|
|
|
count_cols = []
|
|
|
|
|
index_cols = []
|
|
|
|
|
untyped_cols = []
|
|
|
|
|
|
|
|
|
|
for col_idx, col_header in data_cols:
|
2026-03-20 19:53:16 +01:00
|
|
|
col_type = detect_column_type(col_header)
|
2026-07-22 13:34:48 -05:00
|
|
|
if col_type == "count":
|
|
|
|
|
cat_name = strip_type_patterns(col_header, COUNT_PATTERNS)
|
|
|
|
|
count_cols.append((col_idx, col_header, cat_name))
|
|
|
|
|
elif col_type == "index":
|
|
|
|
|
cat_name = strip_type_patterns(col_header, INDEX_PATTERNS)
|
|
|
|
|
index_cols.append((col_idx, col_header, cat_name))
|
|
|
|
|
else:
|
|
|
|
|
untyped_cols.append((col_idx, col_header))
|
|
|
|
|
|
|
|
|
|
# Pair count/index columns by matching category name
|
|
|
|
|
categories = []
|
|
|
|
|
used_counts = set()
|
|
|
|
|
used_indexes = set()
|
2026-03-20 19:53:16 +01:00
|
|
|
|
2026-07-22 13:34:48 -05:00
|
|
|
for ci, (c_idx, c_header, c_cat) in enumerate(count_cols):
|
|
|
|
|
for ii, (i_idx, i_header, i_cat) in enumerate(index_cols):
|
|
|
|
|
if ii in used_indexes:
|
2026-03-20 19:53:16 +01:00
|
|
|
continue
|
2026-07-22 13:34:48 -05:00
|
|
|
if c_cat and i_cat and c_cat == i_cat:
|
|
|
|
|
cat_name = c_cat.replace(" ", "_").replace("-", "_")
|
2026-03-20 19:53:16 +01:00
|
|
|
categories.append({
|
|
|
|
|
"name": cat_name,
|
2026-07-22 13:34:48 -05:00
|
|
|
"count_col": c_idx,
|
|
|
|
|
"index_col": i_idx,
|
2026-03-20 19:53:16 +01:00
|
|
|
})
|
2026-07-22 13:34:48 -05:00
|
|
|
used_counts.add(ci)
|
|
|
|
|
used_indexes.add(ii)
|
|
|
|
|
break
|
|
|
|
|
|
2026-07-23 01:32:22 -07:00
|
|
|
# Remaining unmatched count columns become standalone. They are still count
|
|
|
|
|
# data, so tag them as such — otherwise a lone headcount would be emitted as
|
|
|
|
|
# a salary index and rendered with a meaningless "vs baseline" percentage.
|
2026-07-22 13:34:48 -05:00
|
|
|
for ci, (c_idx, c_header, _) in enumerate(count_cols):
|
|
|
|
|
if ci not in used_counts:
|
2026-07-23 01:32:22 -07:00
|
|
|
categories.append(
|
|
|
|
|
{"name": c_header.lower().replace(" ", "_"), "value_col": c_idx, "field": "count"}
|
|
|
|
|
)
|
2026-07-22 13:34:48 -05:00
|
|
|
|
|
|
|
|
# Remaining unmatched index columns become standalone (use original header)
|
|
|
|
|
for ii, (i_idx, i_header, _) in enumerate(index_cols):
|
|
|
|
|
if ii not in used_indexes:
|
|
|
|
|
categories.append({"name": i_header.lower().replace(" ", "_"), "value_col": i_idx})
|
2026-03-20 19:53:16 +01:00
|
|
|
|
2026-07-22 13:34:48 -05:00
|
|
|
# Untyped columns become standalone
|
|
|
|
|
for col_idx, col_header in untyped_cols:
|
|
|
|
|
categories.append({"name": col_header.lower().replace(" ", "_"), "value_col": col_idx})
|
2026-03-20 19:53:16 +01:00
|
|
|
|
|
|
|
|
# Parse data rows
|
|
|
|
|
companies = []
|
|
|
|
|
for row in ws.iter_rows(min_row=header_row + 1, values_only=True):
|
2026-07-29 22:50:40 +05:30
|
|
|
if company_col >= len(row) or not row[company_col]:
|
2026-03-20 19:53:16 +01:00
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
company_name = str(row[company_col]).strip()
|
2026-07-29 22:50:40 +05:30
|
|
|
if city_col is not None and city_col < len(row) and row[city_col]:
|
|
|
|
|
city_name = str(row[city_col]).strip()
|
|
|
|
|
else:
|
|
|
|
|
city_name = ""
|
2026-03-20 19:53:16 +01:00
|
|
|
|
|
|
|
|
entry = {
|
|
|
|
|
"company": company_name,
|
|
|
|
|
"city": city_name,
|
|
|
|
|
"categories": {},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for cat in categories:
|
|
|
|
|
cat_name = cat["name"]
|
|
|
|
|
if "count_col" in cat and "index_col" in cat:
|
|
|
|
|
count_val = None
|
|
|
|
|
index_val = None
|
|
|
|
|
if cat["count_col"] < len(row) and row[cat["count_col"]] is not None:
|
|
|
|
|
try:
|
2026-08-02 20:15:09 +01:00
|
|
|
count_val = int(parse_numeric_cell(row[cat["count_col"]]))
|
2026-03-20 19:53:16 +01:00
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
pass
|
|
|
|
|
if cat["index_col"] < len(row) and row[cat["index_col"]] is not None:
|
|
|
|
|
try:
|
2026-08-02 20:15:09 +01:00
|
|
|
index_val = parse_numeric_cell(row[cat["index_col"]])
|
2026-03-20 19:53:16 +01:00
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
pass
|
2026-07-14 19:11:11 +01:00
|
|
|
# A count/index pair that is entirely empty for this row carries
|
|
|
|
|
# no salary information, so skip it rather than emit nulls.
|
|
|
|
|
if count_val is None and index_val is None:
|
|
|
|
|
continue
|
2026-03-20 19:53:16 +01:00
|
|
|
entry["categories"][cat_name] = {"count": count_val, "index": index_val}
|
|
|
|
|
elif "value_col" in cat:
|
|
|
|
|
if cat["value_col"] < len(row) and row[cat["value_col"]] is not None:
|
|
|
|
|
val = row[cat["value_col"]]
|
|
|
|
|
try:
|
2026-08-02 20:15:09 +01:00
|
|
|
val = parse_numeric_cell(val)
|
2026-03-20 19:53:16 +01:00
|
|
|
except (ValueError, TypeError):
|
2026-07-14 19:11:11 +01:00
|
|
|
# Non-numeric standalone value (e.g. a free-text "Notes"
|
|
|
|
|
# column) is not salary data; skip it for this row.
|
|
|
|
|
continue
|
2026-07-23 01:32:22 -07:00
|
|
|
field = cat.get("field", "index")
|
|
|
|
|
entry["categories"][cat_name] = {field: int(val) if field == "count" else val}
|
2026-03-20 19:53:16 +01:00
|
|
|
|
|
|
|
|
companies.append(entry)
|
|
|
|
|
|
|
|
|
|
return companies
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
description="Convert salary Excel data to JSON"
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument("excel_file", help="Path to the Excel file with salary data")
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--output", default=None,
|
|
|
|
|
help="Output JSON file path (default: salary_data.json in repo root)",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--source", default=None,
|
|
|
|
|
help="Name of the data source (e.g., 'Union Statistics 2025')",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--baseline", type=float, default=100,
|
|
|
|
|
help="Baseline value for index comparison (default: 100)",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--baseline-desc", default=None,
|
|
|
|
|
help="Description of what the baseline means (e.g., 'Index 100 = median salary')",
|
|
|
|
|
)
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
excel_path = Path(args.excel_file)
|
|
|
|
|
if not excel_path.exists():
|
|
|
|
|
print(f"Error: File not found: {excel_path}", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
2026-07-08 23:11:03 +08:00
|
|
|
if openpyxl is None:
|
|
|
|
|
print("Error: openpyxl is required. Install it with: pip install openpyxl", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
2026-03-20 19:53:16 +01:00
|
|
|
output_path = Path(args.output) if args.output else Path(__file__).parent.parent / "salary_data.json"
|
|
|
|
|
|
|
|
|
|
print(f"Reading: {excel_path}")
|
|
|
|
|
wb = openpyxl.load_workbook(excel_path, read_only=True, data_only=True)
|
|
|
|
|
|
|
|
|
|
all_companies = []
|
|
|
|
|
for sheet_name in wb.sheetnames:
|
|
|
|
|
print(f" Parsing sheet: {sheet_name}")
|
|
|
|
|
ws = wb[sheet_name]
|
|
|
|
|
companies = parse_sheet(ws, sheet_label=sheet_name)
|
|
|
|
|
all_companies.extend(companies)
|
|
|
|
|
|
|
|
|
|
wb.close()
|
|
|
|
|
|
|
|
|
|
if not all_companies:
|
|
|
|
|
print("Error: No data could be parsed from the Excel file.", file=sys.stderr)
|
|
|
|
|
print("Make sure the Excel file has a header row with a 'Company'/'Firma' column.", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
# Build output
|
|
|
|
|
output = {
|
|
|
|
|
"metadata": {
|
|
|
|
|
"source": args.source or excel_path.stem,
|
|
|
|
|
"index_baseline": args.baseline,
|
|
|
|
|
"index_label": "Index",
|
|
|
|
|
"baseline_description": args.baseline_desc or f"Index {args.baseline} = baseline",
|
|
|
|
|
},
|
|
|
|
|
"companies": all_companies,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
|
|
|
json.dump(output, f, ensure_ascii=False, indent=2)
|
|
|
|
|
|
|
|
|
|
print(f"\nDone! Wrote {len(all_companies)} company entries to {output_path}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|