From 6b9ec04240457bff5e293550164465cceee7bf4d Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Mon, 7 Sep 2026 19:59:45 +0200 Subject: [PATCH] =?UTF-8?q?MarketMute=20cold=20call-intelligence=20pipelin?= =?UTF-8?q?e:=20SCB=20SNI/reklamsp=C3=A4rr-enrichment,=20kontaktpersoner?= =?UTF-8?q?=20v2=20(96%=20t=C3=A4ckning),=20GLEIF=20LEI,=20VIES=20momsvali?= =?UTF-8?q?dering,=20viktad=20nyckelords+SNI-scoring=20med=20compliance-fi?= =?UTF-8?q?lter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/api/workers/marketmute/README.md | 34 ++ .../marketmute/build_car_dealer_intel.py | 299 ++++++++++++++++++ .../workers/marketmute/build_marketmute_v2.py | 192 +++++++++++ .../marketmute/build_scb_enrichment.py | 48 +++ .../api/workers/marketmute/gleif_fetch2.py | 51 +++ .../workers/marketmute/kontaktpersoner_v2.py | 96 ++++++ .../api/workers/marketmute/marketmute_v3.py | 148 +++++++++ services/api/workers/marketmute/vies_extra.py | 45 +++ .../api/workers/marketmute/vies_targets.py | 40 +++ 9 files changed, 953 insertions(+) create mode 100644 services/api/workers/marketmute/README.md create mode 100644 services/api/workers/marketmute/build_car_dealer_intel.py create mode 100644 services/api/workers/marketmute/build_marketmute_v2.py create mode 100644 services/api/workers/marketmute/build_scb_enrichment.py create mode 100644 services/api/workers/marketmute/gleif_fetch2.py create mode 100644 services/api/workers/marketmute/kontaktpersoner_v2.py create mode 100644 services/api/workers/marketmute/marketmute_v3.py create mode 100644 services/api/workers/marketmute/vies_extra.py create mode 100644 services/api/workers/marketmute/vies_targets.py diff --git a/services/api/workers/marketmute/README.md b/services/api/workers/marketmute/README.md new file mode 100644 index 0000000..61b7059 --- /dev/null +++ b/services/api/workers/marketmute/README.md @@ -0,0 +1,34 @@ +# MarketMute Cold Call-Intelligence Pipeline + +Identifierar svenska bolag som bedriver cold calling / telefonförsäljning. + +## Pipeline (i körordning) + +1. `build_scb_enrichment.py` — SCB Företagsdatabas (scb_bulkfil.zip) → per-orgnr: SNI1-5, reklamspärr, jurform, adress → `scb_fdb.jsonl` (1,82M orgnr) +2. `kontaktpersoner_v2.py` — extraherar styrelse/VD/undertecknare ur alla Underskrift*-concepts i iXBRL-korpuset (revisorer exkluderade) → `kontaktpersoner_v2.jsonl` (405k orgnr, 96% täckning) +3. `gleif_fetch2.py` — alla svenska LEI via GLEIF API (datum-split för att undvika 10k djup-pagineringsgräns) → `gleif_se.jsonl` (95k LEI) +4. `vies_targets.py` / `vies_extra.py` — VIES-momsvalidering för målbolag (**SE + orgnr + "01"** är korrekt momsnummer-format) → `vies_targets.jsonl` +5. `marketmute_v2.py` — nyckelords-scoring (viktade + staplande: STRONG 40p / MEDIUM 25p / WEAK 10p, mangled-encoding-varianter) +6. `marketmute_v3.py` — slutlig rebuild: SNI (starkast: 82200/73200/82910 = +50p, sektor = +30p) + nyckelord + finans + VIES, **reklamspärr = hårt exkluderingsfilter**, avregistrerade filtreras + +## Leverans + +`marketmute_targets_v3.jsonl`: 18 854 mål (3 766 Hög + 15 088 Medium), 98% kontakttäckning, 4 774 med VD direkt. + +## Efterlevnad + +- Reklamspärr (SCB Reklamsparrtyp=2) → exkluderas och märks "Exkluderad (reklamspärr)" +- Avregistrerade bolag → "Ej mål (avregistrerat)" +- Inga personuppgifter utöver företagskopplade styrelseposter (GDPR-ok) +- Telefonnummer: kräver licensierad källa (Teleadress/Krux) — skrapning av hitta/ratsit/mrkoll är förbjuden + +## Körning (server8) + +```bash +/opt/0pt-venv/bin/python build_scb_enrichment.py +/opt/0pt-venv/bin/python kontaktpersoner_v2.py +/opt/0pt-venv/bin/python gleif_fetch2.py +/opt/0pt-venv/bin/python vies_targets.py +/opt/0pt-venv/bin/python build_marketmute_v2.py +/opt/0pt-venv/bin/python marketmute_v3.py +``` diff --git a/services/api/workers/marketmute/build_car_dealer_intel.py b/services/api/workers/marketmute/build_car_dealer_intel.py new file mode 100644 index 0000000..b57d2b3 --- /dev/null +++ b/services/api/workers/marketmute/build_car_dealer_intel.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +""" +Bygger 'Car Dealer Intelligence' dataset från alla tillgängliga källor. +Extraherar allt intressant för bilhandlare: ekonomi, leasing, IT, fordonsflotta, kreditrisk. +""" +import sys, json, gzip, csv, io, re, zipfile, requests +from pathlib import Path +from collections import defaultdict + +# --- Ladda Bolagsverket bulkfil (orgnr -> postadress, verksamhet, form) --- +def load_bulkfil(bulkfil_path): + """Laddar Bolagsverket bulkfil (semikolon-separerad txt).""" + org_data = {} + with zipfile.ZipFile(bulkfil_path) as z: + for name in z.namelist(): + if name.endswith('.txt'): + with z.open(name) as f: + content = f.read().decode('latin-1') + reader = csv.DictReader(io.StringIO(content), delimiter=';') + for row in reader: + orgnr_full = row.get('organisationsidentitet', '').strip() + # Format: "5561233775$ORGNR-IDORG" + orgnr_match = re.match(r'^(\d{10})\$', orgnr_full) + if orgnr_match: + orgnr = orgnr_match.group(1) + orgnr_fmt = f"{orgnr[:6]}-{orgnr[6:]}" + + # Parsa postadress: "Street$$CITY$POSTNR$SE-LAND" + postadress = (row.get('postadress') or '').strip() + street = city = postnr = '' + if postadress: + parts = postadress.split('$$') + if len(parts) >= 2: + street = parts[0] + city_post = parts[1].split('$') + if len(city_post) >= 2: + city = city_post[0] + postnr = city_post[1] + + org_data[orgnr_fmt] = { + 'orgnr': orgnr_fmt, + 'namn': row.get('organisationsnamn', '').split('$')[0], + 'form': row.get('organisationsform', ''), + 'reg_datum': row.get('registreringsdatum', ''), + 'avreg_datum': row.get('avregistreringsdatum', ''), + 'avreg_orsak': row.get('avregistreringsorsak', ''), + 'verksamhet': row.get('verksamhetsbeskrivning', ''), + 'street': street, + 'city': city, + 'postnr': postnr, + } + print(f"[Bulkfil] Laddade {len(org_data):,} företag") + return org_data + +# --- SCB postnummer -> kommun/län --- +def load_scb_postnummer(): + url = "https://www.scb.se/contentassets/ca21efb41fee4a82ad3bee3d2d4a4b00/postnummer_till_kommun_lan.csv" + try: + r = requests.get(url, timeout=30) + r.raise_for_status() + reader = csv.DictReader(io.StringIO(r.text)) + mapping = {} + for row in reader: + pnr = row.get("Postnummer", "").strip() + if pnr: + mapping[pnr] = { + "postort": row.get("Postort", "").strip(), + "kommun": row.get("Kommun", "").strip(), + "lan": row.get("Lan", "").strip(), + "lan_kod": row.get("LanKod", "").strip() + } + print(f"[SCB Postnummer] {len(mapping)} postnummer") + return mapping + except Exception as e: + print(f"[SCB Postnummer] Fel: {e}") + return {} + +# --- VIES (momsregistret) - batch via async --- +def check_vies_batch(orgnrs, batch_size=100): + """Kontrollerar VIES för batch av orgnr.""" + results = {} + for i in range(0, len(orgnrs), batch_size): + batch = orgnrs[i:i+batch_size] + # VIES SOAP är komplex, vi markerar för efterföljande async jobb + for orgnr in batch: + results[orgnr] = {"vies_valid": None, "status": "pending"} + return results + +# --- GLEIF LEI --- +def check_lei_batch(orgnrs, batch_size=100): + results = {} + for i in range(0, len(orgnrs), batch_size): + batch = orgnrs[i:i+batch_size] + for orgnr in batch: + results[orgnr] = {"lei": None, "lei_status": None, "status": "pending"} + return results + +# --- Extrahera nyckeltal från iXBRL-fakta --- +def extract_ratios(facts): + """Beräknar finansiella nyckeltal från fakta.""" + # Samla senaste värden per concept + latest = {} + for f in facts: + qname = f.get('concept_qname', '') + val = f.get('numeric') + period_end = f.get('period_end') + instant = f.get('instant') + date = period_end or instant + if val is not None and date: + if qname not in latest or date > latest[qname]['date']: + latest[qname] = {'value': val, 'date': date, 'unit': f.get('unitRef')} + + # Hjälpfunktion + def get(qname): + return latest.get(qname, {}).get('value') + + ratios = {} + + # Grundläggande + omsattning = get('se-gen-base:Nettoomsattning') or get('se-cd-base:Nettoomsattning') + resultat = get('se-gen-base:AretsResultat') or get('se-gen-base:ResultatEfterFinansiellaPoster') + soliditet = get('se-gen-base:Soliditet') + eget_kapital = get('se-gen-base:EgetKapital') + tillgangar = get('se-gen-base:Tillgangar') + skulder = get('se-gen-base:Skulder') or (get('se-gen-base:LangfristigaSkulder') or 0) + (get('se-gen-base:KortfristigaSkulder') or 0) + kassa = get('se-gen-base:KassaBank') + anstallda = get('se-gen-base:MedelantaletAnstallda') + + # Leasing / hyra (från anmärkningar) + leasing_kostnad = get('se-gen-base:Hyresleasingkostnader') or get('se-gen-base:Leasingkostnader') + leasing_forpliktelser = get('se-gen-base:Leasingforpliktelser') or get('se-gen-base:Hyresforpliktelser') + + # IT / mjukvara + it_kostnader = get('se-gen-base:ITkostnader') or get('se-gen-base:Mjukvarukostnader') or get('se-gen-base:Datorkostnader') + avskrivningar = get('se-gen-base:AvskrivningarNedskrivningarMateriellaImmateriellaAnlaggningstillgangar') + immateriella = get('se-gen-base:ImmateriellaAnlaggningstillgangar') + + # Fordon (om specificerat) + fordon = get('se-gen-base:MaskinerInventarierFordon') or get('se-gen-base:Fordon') + + # Beräknade ratios + if omsattning and omsattning > 0: + ratios['omsattning'] = omsattning + ratios['vinstmarginal'] = (resultat / omsattning * 100) if resultat else None + ratios['omsattning_per_anstalld'] = (omsattning / anstallda) if anstallda else None + + if tillgangar and tillgangar > 0: + ratios['soliditet_beraknad'] = (eget_kapital / tillgangar * 100) if eget_kapital else None + ratios['skuldsattning'] = (skulder / tillgangar * 100) if skulder else None + + if eget_kapital: + ratios['eget_kapital'] = eget_kapital + ratios['roa'] = (resultat / tillgangar * 100) if resultat and tillgangar else None + ratios['roe'] = (resultat / eget_kapital * 100) if resultat else None + + if kassa and skulder: + ratios['likviditet'] = (kassa / skulder * 100) if skulder else None + ratios['kassa_till_kortfristig'] = (kassa / get('se-gen-base:KortfristigaSkulder') * 100) if get('se-gen-base:KortfristigaSkulder') else None + + if anstallda: + ratios['anstallda'] = anstallda + ratios['resultat_per_anstalld'] = (resultat / anstallda) if resultat else None + + # Leasing-indikatorer (viktigt för bilhandlare) + if leasing_kostnad: + ratios['leasing_kostnad'] = leasing_kostnad + ratios['leasing_andel_oms'] = (leasing_kostnad / omsattning * 100) if omsattning else None + if leasing_forpliktelser: + ratios['leasing_forpliktelser'] = leasing_forpliktelser + + # IT/Tech-indikatorer + if it_kostnader: + ratios['it_kostnader'] = it_kostnader + ratios['it_andel_oms'] = (it_kostnader / omsattning * 100) if omsattning else None + if avskrivningar: + ratios['avskrivningar'] = avskrivningar + ratios['avskrivnings_andel_oms'] = (avskrivningar / omsattning * 100) if omsattning else None + if immateriella: + ratios['immateriella_tillgangar'] = immateriella + ratios['immateriella_andel_eget_kapital'] = (immateriella / eget_kapital * 100) if eget_kapital else None + + # Fordon + if fordon: + ratios['fordon_bokfort'] = fordon + + # Kreditrisk-indikatorer + ratios['risk_soliditet_low'] = soliditet is not None and soliditet < 10 + ratios['risk_negativt_ek'] = eget_kapital is not None and eget_kapital < 0 + ratios['risk_negativt_resultat'] = resultat is not None and resultat < -100000 + ratios['risk_hog_skuldsattning'] = (ratios.get('skuldsattning') or 0) > 80 + ratios['risk_lag_likviditet'] = (ratios.get('likviditet') or 100) < 50 + + # Sammanfattad risk-score (0-100) + risk_score = 0 + if ratios['risk_soliditet_low']: risk_score += 25 + if ratios['risk_negativt_ek']: risk_score += 25 + if ratios['risk_negativt_resultat']: risk_score += 20 + if ratios['risk_hog_skuldsattning']: risk_score += 15 + if ratios['risk_lag_likviditet']: risk_score += 15 + ratios['kreditrisk_score'] = min(risk_score, 100) + ratios['kreditrisk_klass'] = 'Hög' if risk_score >= 60 else 'Medium' if risk_score >= 30 else 'Låg' + + # Bransch (från verksamhetsbeskrivning) + return ratios + +def enrich_company(rec, org_data, scb_postnr): + """Anrikar en bolagspost med all intelligens.""" + orgnr = rec.get('orgnr') + if not orgnr: + return rec + + # 1. Bolagsverket data + if orgnr in org_data: + bd = org_data[orgnr] + rec['bolagsverket'] = bd + # Postnummer -> kommun/län + if bd.get('postnr') and bd['postnr'] in scb_postnr: + p = scb_postnr[bd['postnr']] + rec['kommun'] = p['kommun'] + rec['lan'] = p['lan'] + rec['lan_kod'] = p['lan_kod'] + rec['postort'] = p['postort'] + + # 2. Finansiella ratios från iXBRL + ratios = extract_ratios(rec.get('facts', [])) + if ratios: + rec['finansiella_ratios'] = ratios + + # 3. VIES/LEI placeholders (fylls async) + rec['vies'] = {"vies_valid": None, "status": "pending"} + rec['lei'] = {"lei": None, "lei_status": None, "status": "pending"} + rec['sanctioned'] = False + rec['pep'] = False + + # 4. Bransch-kod från SNI (om tillgänglig i facts) + for f in rec.get('facts', []): + if 'SNI' in f.get('concept_qname', '') or 'Bran' in f.get('concept_qname', ''): + rec['sni_kod'] = f.get('value') + break + + return rec + +def main(): + in_path = sys.argv[1] if len(sys.argv) > 1 else "/data/0pt/output/arsredovisningar_enriched.jsonl" + out_path = sys.argv[2] if len(sys.argv) > 2 else "/data/0pt/output/bilhandlare_intelligence.jsonl" + bulkfil = sys.argv[3] if len(sys.argv) > 3 else "/data/0pt/landing/bolagsverket/bolagsverket_bulkfil.zip" + + print(f"[INFO] Laddar Bolagsverket bulkfil...") + org_data = load_bulkfil(bulkfil) + + print(f"[INFO] Laddar SCB postnummer...") + scb_postnr = load_scb_postnummer() + + # Läs alla orgnr för VIES/LEI + print(f"[INFO] Läser orgnr från {in_path}...") + orgnrs = [] + with open(in_path, "r") as f: + for line in f: + rec = json.loads(line) + if rec.get("orgnr"): + orgnrs.append(rec["orgnr"]) + orgnrs = list(set(orgnrs)) + print(f" Unika orgnr: {len(orgnrs):,}") + + # VIES/LEI placeholders + vies_map = check_vies_batch(orgnrs) + lei_map = check_lei_batch(orgnrs) + + # Streaming enrichment + print(f"[INFO] Skriver Car Dealer Intelligence till {out_path}...") + count = 0 + with_ratios = 0 + with_bolag = 0 + with_kommun = 0 + + with open(in_path, "r") as inf, open(out_path, "w") as outf: + for line in inf: + rec = json.loads(line) + rec = enrich_company(rec, org_data, scb_postnr) + + if rec.get('finansiella_ratios'): + with_ratios += 1 + if rec.get('bolagsverket'): + with_bolag += 1 + if rec.get('kommun'): + with_kommun += 1 + + outf.write(json.dumps(rec, ensure_ascii=False) + "\n") + count += 1 + if count % 100000 == 0: + print(f" {count:,} poster (ratios:{with_ratios}, bolag:{with_bolag}, kommun:{with_kommun})") + + print(f"[KLAR] {count:,} poster") + print(f" Med finansiella ratios: {with_ratios:,}") + print(f" Med Bolagsverket data: {with_bolag:,}") + print(f" Med kommun/län: {with_kommun:,}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/services/api/workers/marketmute/build_marketmute_v2.py b/services/api/workers/marketmute/build_marketmute_v2.py new file mode 100644 index 0000000..452174e --- /dev/null +++ b/services/api/workers/marketmute/build_marketmute_v2.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""MarketMute Intelligence v2: viktade + staplande nyckelord, breda cold call-sektorer.""" +import sys, json, csv, io, re, zipfile +from pathlib import Path + +# NYCKELORD MED VIKT — staplas alla matchningar +# STRONG (40p): explicit telemarketing / telefonförsäljning +KEYWORDS_STRONG = [ + 'telemarketing', 'telesales', 'telefonförsäljning', 'telefonmarknadsföring', + 'kallcenter', 'callcenter', 'call center', 'kundcenter', 'utgående samtal', + 'outbound', 'cold call', 'telemarknadsföring', 'direktförsäljning', + 'kalla upp', 'telefonintervjuer', 'telefonkonsultation', +] +# MEDIUM (25p): branscher som är kända cold call-sektorer i Sverige +KEYWORDS_MEDIUM = [ + 'elavtal', 'elhandel', 'elleverantör', 'elavtalsförsäljning', 'elmarknadsföring', + 'solceller', 'solpaneler', 'solel', 'solcellsanläggningar', + 'värmepump', 'bergvärme', 'luftvärmepump', 'luft/vatten-värmepump', + 'försäkringsförsäljning', 'försäkringsförmedling', 'försäkringsmäklare', + 'inkasso', 'skuldsanering', 'fordringshantering', 'betalningsanmärkning', + 'bredband', 'mobilabonnemang', 'tv-paket', 'telefoni', 'telekommunikation', + 'pension', 'premiepension', 'fondsparande', 'kapitalförsäkring', + 'fastighetsförmedling', 'bostadsförmedling', 'mäklare', + 'leadgenerering', 'lead generation', 'kvalificerade leads', 'mötessättning', + 'appointment setting', 'sales development', 'prospecting', + 'affärssystem', 'crmsystem', 'saas', 'mjukvaruförsäljning', + 'utbildningsförsäljning', 'kursförsäljning', 'studiemedel', 'yrkesutbildning', +] +# WEAK (10p): generiska försäljningsord — bara stack +KEYWORDS_WEAK = [ + 'aktiv försäljning', 'aktiv marknadsföring', 'direktmarknadsföring', + 'b2b försäljning', 'b2c försäljning', 'tjänsteförsäljning', 'avtalsförsäljning', +] + +# MANGLED VERSIONER (latin-1 läst som utf-8: å=Ã¥, ä=ä, ö=ö) +def mangle(w): + try: return w.encode('utf-8').decode('latin-1') + except Exception: return w + +KEYWORDS_STRONG_M = [mangle(w) for w in KEYWORDS_STRONG] +KEYWORDS_MEDIUM_M = [mangle(w) for w in KEYWORDS_MEDIUM] +KEYWORDS_WEAK_M = [mangle(w) for w in KEYWORDS_WEAK] + +EXCLUDE_KEYWORDS = ['endast inkommande', 'kundsupport', 'kundtjänst', 'vi ringer inte', 'inget kallcenter'] +EXCLUDE_M = [mangle(w) for w in EXCLUDE_KEYWORDS] + +NAME_KEYWORDS = ['telemarketing', 'telesales', 'kallcenter', 'callcenter', 'outbound', + 'leads', 'sälj', 'försälj', 'inkasso', 'elavtal', 'elhandel', + 'solcell', 'värmepump', 'telekom', 'breddb', 'mäklar', 'lead'] +NAME_M = [mangle(w) for w in NAME_KEYWORDS] + +def load_bulkfil(bulkfil_path): + org_data = {} + with zipfile.ZipFile(bulkfil_path) as z: + for name in z.namelist(): + if name.endswith('.txt'): + with z.open(name) as f: + content = f.read().decode('latin-1') + reader = csv.DictReader(io.StringIO(content), delimiter=';') + for row in reader: + orgnr_full = row.get('organisationsidentitet') or '' + m = re.match(r'^(\d{10})\$', orgnr_full.strip()) + if m: + orgnr = m.group(1) + fmt = f"{orgnr[:6]}-{orgnr[6:]}" + postadress = (row.get('postadress') or '').strip() + street = city = postnr = '' + if postadress: + parts = postadress.split('$$') + if len(parts) >= 2: + street = parts[0] + cp = parts[1].split('$') + if len(cp) >= 2: + city = cp[0]; postnr = cp[1] + org_data[fmt] = { + 'namn': (row.get('organisationsnamn') or '').split('$')[0], + 'form': row.get('organisationsform') or '', + 'reg_datum': row.get('registreringsdatum') or '', + 'avreg_datum': row.get('avregistreringsdatum') or '', + 'verksamhet': (row.get('verksamhetsbeskrivning') or '').split('$')[0], + 'street': street, 'city': city, 'postnr': postnr, + } + print(f"[Bulkfil] {len(org_data):,} företag") + return org_data + +def score_cold_calling(rec, org_data): + score = 0 + reasons = [] + bd = org_data.get(rec.get('orgnr'), {}) + + # fix encoding + def fix(t): + if not t: return '' + try: return t.encode('latin-1').decode('utf-8').lower() + except Exception: return t.lower() + + verk = fix(bd.get('verksamhet')) + verk_raw = (bd.get('verksamhet') or '').lower() + + # 1. Nyckelord — stapla alla matchningar + for w in KEYWORDS_STRONG: + if w in verk: + score += 40; reasons.append(f"STRONG: '{w}'"); break + if not any(w in verk for w in KEYWORDS_STRONG): + # prova mangled versioner i rå text + for w in KEYWORDS_STRONG_M: + if w in verk_raw: + score += 40; reasons.append(f"STRONG: '{w}' (mangled)"); break + for w in KEYWORDS_MEDIUM: + if w in verk: + score += 25; reasons.append(f"MEDIUM: '{w}'"); break + for w in KEYWORDS_MEDIUM_M: + if w in verk_raw: + score += 25; reasons.append(f"MEDIUM: '{w}' (mangled)"); break + for w in KEYWORDS_WEAK: + if w in verk: + score += 10; reasons.append(f"WEAK: '{w}'"); break + for w in KEYWORDS_WEAK_M: + if w in verk_raw: + score += 10; reasons.append(f"WEAK: '{w}' (mangled)"); break + + # Exkludera + for w in EXCLUDE_M + EXCLUDE_KEYWORDS: + if w in verk_raw or w in verk: + score = max(0, score - 40); reasons.append(f"Exkluderat: '{w}'"); break + + # 2. Namn + namn_raw = (bd.get('namn') or '').lower() + namn = fix(bd.get('namn')) + for w in NAME_KEYWORDS: + if w in namn: + score += 15; reasons.append(f"Namn: '{w}'"); break + for w in NAME_M: + if w in namn_raw: + score += 15; reasons.append(f"Namn: '{w}' (mangled)"); break + + # 3. Finansiella mönster + r = rec.get('finansiella_ratios', {}) + oms, anst = r.get('omsattning'), r.get('anstallda') + if oms and anst: + opa = oms / anst + if opa > 10000000: + score += 15; reasons.append(f"oms/anst {opa:,.0f} (extrem)") + elif opa > 3000000: + score += 10; reasons.append(f"oms/anst {opa:,.0f}") + res = r.get('resultat') or r.get('eget_kapital') + if oms and oms > 5000000 and (r.get('vinstmarginal') or 0) < -20: + score += 10; reasons.append("Förbränner kapital (vinstmarginal<-20% med hög oms)") + + # 4. Form/ålder + if bd.get('form') in ('ENSKILD', 'HB', 'KB'): + score += 5; reasons.append(f"Form: {bd.get('form')}") + reg = bd.get('reg_datum', '') + if reg >= '2022-01-01': + score += 10; reasons.append(f"Nystart 2022+: {reg}") + elif reg >= '2020-01-01': + score += 5; reasons.append(f"Nystart 2020+: {reg}") + avreg = bd.get('avreg_datum', '') + if avreg: + score -= 10; reasons.append(f"Avregistrerat: {avreg}") + + score = max(0, min(score, 100)) + if score >= 60: klass = 'Hög sannolikhet' + elif score >= 35: klass = 'Medium sannolikhet' + elif score >= 15: klass = 'Låg sannolikhet' + else: klass = 'Osannolikt' + return {'cold_call_score': score, 'cold_call_klass': klass, 'cold_call_reasons': reasons, + 'verksamhet': bd.get('verksamhet', ''), 'namn': bd.get('namn', '')} + +def main(): + in_path = sys.argv[1] if len(sys.argv) > 1 else "/data/0pt/output/bilhandlare_intelligence.jsonl" + out_path = sys.argv[2] if len(sys.argv) > 2 else "/data/0pt/output/marketmute_intelligence.jsonl" + bulkfil = sys.argv[3] if len(sys.argv) > 3 else "/data/0pt/landing/bolagsverket/bolagsverket_bulkfil.zip" + org_data = load_bulkfil(bulkfil) + print(f"[INFO] Analyserar {in_path} -> {out_path}") + from collections import Counter + klass_c = Counter(); total = 0 + with open(in_path) as inf, open(out_path, "w") as outf: + for line in inf: + rec = json.loads(line) + rec['marketmute'] = score_cold_calling(rec, org_data) + klass_c[rec['marketmute']['cold_call_klass']] += 1 + outf.write(json.dumps(rec, ensure_ascii=False) + "\n") + total += 1 + if total % 100000 == 0: + print(f" {total:,} (Hög:{klass_c['Hög sannolikhet']}, Med:{klass_c['Medium sannolikhet']}, Låg:{klass_c['Låg sannolikhet']})") + print(f"[KLAR] {total:,}") + for k, c in klass_c.most_common(): + print(f" {k}: {c:,} ({c/total*100:.1f}%)") + +if __name__ == "__main__": + main() diff --git a/services/api/workers/marketmute/build_scb_enrichment.py b/services/api/workers/marketmute/build_scb_enrichment.py new file mode 100644 index 0000000..69a2f77 --- /dev/null +++ b/services/api/workers/marketmute/build_scb_enrichment.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Steg 1: SCB FDB -> orgnr -> {SNI1-5, reklamsparr, jurform, adress} -> scb_fdb.jsonl""" +import zipfile, csv, io, json, sys + +OUT = "/data/0pt/output/scb_fdb.jsonl" +JURFORM_MAP = { + "10": "Enskild firma", "12": "Bolagsman", "20": "Handelsbolag", "21": "Kommanditbolag", + "22": "Kommanditbolag", "30": "Ekonomisk förening", "31": "Ekonomisk förening", + "40": "Ideell förening", "47": "Bostadsrättsförening", "49": "Aktiebolag", + "50": "Aktiebolag", "51": "Aktiebolag", "54": "Aktiebolag", "55": "Aktiebolag", + "56": "Aktiebolag", "59": "Partrederi", "60": "Stiftelse", "63": "Stiftelse", + "66": "Stiftelse", "71": "Stiftelse", "72": "Stiftelse", "75": "Stiftelse", + "77": "Stiftelse", "80": "Sparbank", "83": "Enskild firma", "90": "Utländsk", + "91": "Utländsk", "92": "Utländsk", "96": "Övrig", "99": "Okänd", +} + +n = 0 +with zipfile.ZipFile("/data/0pt/landing/bolagsverket/scb_bulkfil.zip") as z, open(OUT, "w") as out: + with z.open(z.namelist()[0]) as f: + reader = csv.DictReader(io.TextIOWrapper(f, encoding="latin-1"), delimiter="\t") + for row in reader: + orgnr = (row.get("PeOrgNr") or "").strip() + if not orgnr or len(orgnr) < 10: + continue + digits = orgnr[:10] + fmt = f"{digits[:6]}-{digits[6:]}" if not digits.startswith("16") else digits + ng = [(row.get(f"Ng{i}") or "").strip() for i in range(1, 6)] + ng = [x for x in ng if x and x != "00000"] + rp = (row.get("Reklamsparrtyp") or "").strip() + rec = { + "orgnr": fmt, + "orgnr_digits": digits, + "sni": ng, + "sni1": ng[0] if ng else None, + "jurform_kod": (row.get("JurForm") or "").strip(), + "jurform": JURFORM_MAP.get((row.get("JurForm") or "").strip()), + "reklamsparr_typ": rp, + "reklamsparr": rp == "2", + "gatuadress": (row.get("Gatuadress") or "").strip(), + "postnr": (row.get("PostNr") or "").strip(), + "postort": (row.get("PostOrt") or "").strip(), + "reg_datum": (row.get("RegDatKtid") or "").strip(), + } + out.write(json.dumps(rec, ensure_ascii=False) + "\n") + n += 1 + if n % 200000 == 0: + print(f" {n:,}...", flush=True) +print(f"[KLAR] {n:,} -> {OUT}") diff --git a/services/api/workers/marketmute/gleif_fetch2.py b/services/api/workers/marketmute/gleif_fetch2.py new file mode 100644 index 0000000..964c9e0 --- /dev/null +++ b/services/api/workers/marketmute/gleif_fetch2.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Steg 3 fixad: GLEIF via initialRegistrationDate-split (undviker 10k djup-gräns).""" +import json, time, requests + +OUT = "/data/0pt/output/gleif_se.jsonl" +sess = requests.Session() +total = 0 +YEARS = [(f"{y}-01-01", f"{y}-12-31") for y in range(2012, 2027)] +with open(OUT, "w") as out: + for y0, y1 in YEARS: + page = 1 + while True: + ok = False + for attempt in range(5): + try: + r = sess.get("https://api.gleif.org/api/v1/lei-records", params={ + "filter[entity.legalAddress.country]": "SE", + "filter[registration.initialRegistrationDate]": f"{y0}..{y1}", + "page[size]": 200, "page[number]": page, + }, timeout=30) + if r.status_code == 429: + time.sleep(5); continue + r.raise_for_status() + ok = True; break + except Exception as e: + if attempt == 4: + print(f" FAIL {y0} p{page}: {e}", flush=True) + break + time.sleep(2 ** attempt) + if not ok: break + d = r.json() + items = d.get("data", []) + if not items: break + for it in items: + a = it.get("attributes", {}) + e = a.get("entity", {}) + reg = a.get("registration", {}) + out.write(json.dumps({ + "lei": a.get("lei"), + "orgnr": (e.get("registeredAs") or "").strip(), + "namn": (e.get("legalName") or {}).get("name"), + "status": e.get("status"), + "reg_status": reg.get("status"), + }, ensure_ascii=False) + "\n") + total += 1 + meta = d.get("meta", {}).get("pagination", {}) + if page >= meta.get("lastPage", 1): break + page += 1 + time.sleep(0.3) + print(f" {y0[:4]}: klart (totalt {total:,})", flush=True) +print(f"[KLAR] {total:,} LEI -> {OUT}") diff --git a/services/api/workers/marketmute/kontaktpersoner_v2.py b/services/api/workers/marketmute/kontaktpersoner_v2.py new file mode 100644 index 0000000..158efd4 --- /dev/null +++ b/services/api/workers/marketmute/kontaktpersoner_v2.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Steg 2: Full kontaktperson-extraktion från alla Underskrift-concepts i dedup JSONL.""" +import json, sys + +IN = "/data/0pt/output/arsredovisningar_dedup.jsonl" +OUT = "/data/0pt/output/kontaktpersoner_v2.jsonl" + +# SIGNATUR-CONCEPTS (revisorer EXKLUDERAS) +NAME_TRIPLES = [ + # (tilltalsnamn, efternamn, roll) — triples per signatursystem + ("UnderskriftFaststallelseintygForetradareTilltalsnamn", "UnderskriftFaststallelseintygForetradareEfternamn", "UnderskriftFaststallelseintygForetradareForetradarroll"), + ("UnderskriftArsredovisningForetradareTilltalsnamn", "UnderskriftArsredovisningForetradareEfternamn", "UnderskriftArsredovisningForetradareForetradarroll"), + ("UnderskriftHandlingTilltalsnamn", "UnderskriftHandlingEfternamn", "UnderskriftHandlingRoll"), +] +# Extra titel-concept per system +TITEL = { + "UnderskriftFaststallelseintygForetradareTilltalsnamn": "UnderskriftFaststallelseintygForetradareForetradarroll", + "UnderskriftArsredovisningForetradareTilltalsnamn": "UnderskriftArsredovisningForetradareForetradarroll", + "UnderskriftHandlingTilltalsnamn": "UnderskriftHandlingTitel", +} +# Namn på prefix +PREFIX = ("se-comp-base:", "se-gen-base:", "se-bol-base:", "se-ar-base:") + +def norm_role(roll): + r = (roll or "").lower() + if not r: + return "underskriftsansvarig", 3 + if "verks" in r and "direkt" in r or r.strip() in ("vd", "verkställande direktör", "v.d."): + return "verkställande direktör", 0 + if "ordförand" in r or "ordforand" in r or "företrädande" in r.replace("företagande", ""): + return "styrelseordförande", 1 + if "ledamot" in r: + return "styrelseledamot", 2 + if "stiftare" in r: + return "stiftare", 2 + if "innehavare" in r or "ägare" in r: + return "innehavare", 0 + return "underskriftsansvarig", 3 + +def clean(s): + return (s or "").strip() + +org_persons = {} +n = 0 +for line in open(IN): + rec = json.loads(line) + n += 1 + orgnr = rec.get("orgnr") + if not orgnr: + continue + # bygga lookup senaste-värde per concept + vals = {} + for f in rec.get("facts", []): + q = f.get("concept_qname", "") + v = clean(f.get("value")) + if v and q.endswith(("Tilltalsnamn", "Efternamn", "Foretradarroll", "Roll", "Titel")) and "Revisor" not in q and "Revisorsp" not in q: + key = q.split(":")[-1] + if key not in vals: # första förekomsten räcker per år — ta senaste helt: skriv över + vals[key] = v + # prova alla triples — maska suffix-prefix (HandlingRoll vs Foretradarroll) + persons = org_persons.setdefault(orgnr, {}) + for tnam, enam, rnam in NAME_TRIPLES: + tn = vals.get(tnam.split(":")[-1]) + en = vals.get(enam.split(":")[-1]) + if not tn and not en: + continue + namn = f"{tn} {en}".strip() + if not namn: + continue + roll_raw = vals.get(rnam.split(":")[-1]) or vals.get("UnderskriftHandlingRoll") or "" + roll, prio = norm_role(roll_raw) + prev = persons.get(namn) + if prev is None or prio < prev["prio"]: + persons[namn] = {"namn": namn, "roll": roll, "prio": prio, "kalla": rnam.split(":")[-1]} + if n % 100000 == 0: + print(f" {n:,} bolag...", flush=True) + +# serialisera +count = 0 +with_persons = 0 +with open(OUT, "w") as out: + for orgnr, persons in org_persons.items(): + lst = sorted(persons.values(), key=lambda p: p["prio"]) + has_vd = any(p["prio"] == 0 for p in lst) + rec = { + "orgnr": orgnr, + "orgnr_digits": orgnr.replace("-", ""), + "personer": [{"namn": p["namn"], "roll": p["roll"]} for p in lst], + "antal": len(lst), + "har_vd": has_vd, + } + out.write(json.dumps(rec, ensure_ascii=False) + "\n") + count += 1 + if lst: + with_persons += 1 +print(f"[KLAR] {count:,} orgnr, {with_persons:,} med personer -> {OUT}") diff --git a/services/api/workers/marketmute/marketmute_v3.py b/services/api/workers/marketmute/marketmute_v3.py new file mode 100644 index 0000000..9e3e644 --- /dev/null +++ b/services/api/workers/marketmute/marketmute_v3.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Steg 5: Slutgiltig MarketMute-rebuild med SNI, reklamspärr, kontakter, LEI, moms.""" +import json, sys +from collections import defaultdict + +# --- SNI 2007 för cold call-sektorer --- +SNI_STRONG = { # direkt cold call-verksamhet + "82200": "Callcenter/telemarketing", "73200": "Marknadsundersökningar", + "73110": "Reklambyråer", "73120": "Mediabyråer", "73130": "Reklambyråer", + "82910": "Inkasso/kreditupplysning", "82990": "Övriga företagstjänster (leadgen)", +} +SNI_MEDIUM = { # kända telefonförsäljningssektorer + "65110": "Livförsäkring", "65120": "Skadeförsäkring", "65200": "Återförsäkring", + "66210": "Försäkringsriskbedömning", "66220": "Försäkringsmäklare", + "64920": "Övrig kreditgivning (sms-lån)", "66190": "Övriga finansiella tjänster", + "35120": "Elhandel", "35140": "Elhandel", "35110": "Elproduktion", + "43220": "Installationer (solcell/värmepump)", "43290": "Övriga installationer", + "43990": "Övrig specialiserad bygg", "68310": "Fastighetsmäklare", + "68320": "Fastighetsförvaltning", "58290": "Programvaruutgivning", + "62010": "Programmering", "62020": "IT-konsult", "62090": "Övrig IT", + "61100": "Telekommunikation", "61200": "Mobiltelefoni", "61300": "Satellit", "61900": "Övrig tele", + "85510": "Sport/yrkesutbildning", "85520": "Kulturu(t)bildning", "85590": "Övrig utbildning", + "80200": "Gymnasial utbildning", "80300": "Högskoleutbildning", "47910": "E-handel", +} +SNI_PREFIX_MEDIUM = ("651", "652", "662", "649", "661", "351", "432", "439", "683", "582", "620", "611", "612", "619", "855", "731", "732", "822", "829") + +def score_v3(rec, scb, vies_rec): + score = 0; reasons = [] + m = rec.get("marketmute", {}) + # 1. SNI (starkast) + sni1 = (scb.get("sni1") or "") if scb else "" + if sni1 in SNI_STRONG: + score += 50; reasons.append(f"SNI {sni1}: {SNI_STRONG[sni1]}") + elif sni1 in SNI_MEDIUM: + score += 30; reasons.append(f"SNI {sni1}: {SNI_MEDIUM[sni1]}") + elif sni1[:3] in SNI_PREFIX_MEDIUM and sni1: + score += 15; reasons.append(f"SNI-prefix {sni1[:3]}") + # 2. Nyckelord från v2 (återanvänd) + kw_reasons = [r for r in m.get("cold_call_reasons", []) if r.startswith(("STRONG", "MEDIUM", "WEAK", "Namn"))] + for r in kw_reasons: + if r.startswith("STRONG"): score += 40 + elif r.startswith("MEDIUM"): score += 25 + elif r.startswith("WEAK"): score += 10 + else: score += 15 + reasons.extend(kw_reasons) + # 3. Finansiellt + for r in m.get("cold_call_reasons", []): + if "oms/anst" in r: score += 15 if "extrem" in r else 10; reasons.append(r) + if "Förbränner" in r: score += 10; reasons.append(r) + # 4. Ålder/form + for r in m.get("cold_call_reasons", []): + if r.startswith("Nystart"): score += 10 if "2022+" in r else 5; reasons.append(r) + if r.startswith("Form:"): score += 5; reasons.append(r) + # 5. Moms-registrerad (riktigt bolag) + if vies_rec and vies_rec.get("moms_valid") is True: + score += 5; reasons.append("Momsregistrerad (VIES)") + # Avregistrerat — exkludera helt + bd_reasons = m.get("cold_call_reasons", []) + avreg = next((r for r in bd_reasons if r.startswith("Avregistrerat")), None) + if avreg: + return 0, "Ej mål (avregistrerat)", [avreg], sni1 + score = max(0, min(score, 100)) + if score >= 60: klass = "Hög sannolikhet" + elif score >= 35: klass = "Medium sannolikhet" + elif score >= 15: klass = "Låg sannolikhet" + else: klass = "Osannolikt" + return score, klass, reasons, sni1 + +def main(): + # ladda sidotabeller + scb_map = {} + for line in open("/data/0pt/output/scb_fdb.jsonl"): + r = json.loads(line) + scb_map[r["orgnr_digits"]] = r + scb_map[r["orgnr"]] = r + print(f"[SCB] {len(scb_map):,}", flush=True) + contacts = {} + for line in open("/data/0pt/output/kontaktpersoner_v2.jsonl"): + r = json.loads(line) + contacts[r["orgnr_digits"]] = r + print(f"[Kontakter] {len(contacts):,}", flush=True) + lei_map = {} + for line in open("/data/0pt/output/gleif_se.jsonl"): + r = json.loads(line) + o = (r.get("orgnr") or "").replace("-", "").strip() + if o: lei_map[o] = r + print(f"[LEI] {len(lei_map):,}", flush=True) + vies_map = {} + for line in open("/data/0pt/output/vies_targets.jsonl"): + r = json.loads(line) + vies_map[r.get("orgnr_fmt", "")] = r + print(f"[VIES] {len(vies_map):,}", flush=True) + + # bygga utökad mållista från HELA korpusen (v3-nycklar: SNI kan träffa nya bolag) + from collections import Counter + klass_c = Counter(); sparr_c = 0; n = 0 + targets = [] + for line in open("/data/0pt/output/bilhandlare_intelligence.jsonl"): + rec = json.loads(line) + n += 1 + orgnr = rec.get("orgnr", "") + digits = orgnr.replace("-", "") + scb = scb_map.get(digits) or scb_map.get(orgnr) + vies_rec = vies_map.get(orgnr) + score, klass, reasons, sni1 = score_v3(rec, scb, vies_rec) + # reklamspärr = hårt exkluderingsfilter (efterlevnad) + sparrad = bool(scb and scb.get("reklamsparr")) + if sparrad and klass in ("Hög sannolikhet", "Medium sannolikhet"): + sparr_c += 1 + klass = "Exkluderad (reklamspärr)" + klass_c[klass] += 1 + contact = contacts.get(digits) + lei = lei_map.get(digits) + if klass in ("Hög sannolikhet", "Medium sannolikhet"): + targets.append({ + "orgnr": orgnr, + "cold_call_score": score, "cold_call_klass": klass, + "cold_call_reasons": reasons, + "sni": scb.get("sni") if scb else [], "sni1": sni1, + "namn": (scb or {}).get("namn") or rec.get("marketmute", {}).get("namn", ""), + "jurform": (scb or {}).get("jurform"), + "gatuadress": (scb or {}).get("gatuadress"), "postnr": (scb or {}).get("postnr"), + "postort": (scb or {}).get("postort"), + "reklamsparr_typ": (scb or {}).get("reklamsparr_typ"), + "moms_valid": (vies_rec or {}).get("moms_valid"), + "lei": (lei or {}).get("lei"), "lei_status": (lei or {}).get("status"), + "kontaktpersoner": (contact or {}).get("personer", []), + "har_kontakt": bool(contact and contact.get("personer")), + "har_vd": bool(contact and contact.get("har_vd")), + "finansiella_ratios": rec.get("finansiella_ratios", {}), + "kreditrisk_klass": rec.get("finansiella_ratios", {}).get("kreditrisk_klass"), + "verksamhet": rec.get("marketmute", {}).get("verksamhet", ""), + }) + if n % 200000 == 0: + print(f" {n:,} (mål hittills: {len(targets):,}, spärrade: {sparr_c:,})", flush=True) + + targets.sort(key=lambda x: -x["cold_call_score"]) + with open("/data/0pt/output/marketmute_targets_v3.jsonl", "w") as f: + for t in targets: + f.write(json.dumps(t, ensure_ascii=False) + "\n") + print(f"[KLAR] {n:,} skannade -> {len(targets):,} mål") + for k, c in klass_c.most_common(): + print(f" {k}: {c:,}") + kp = sum(1 for t in targets if t["har_kontakt"]) + print(f" Mål med kontaktperson: {kp:,} ({kp/max(len(targets),1)*100:.0f}%)") + +if __name__ == "__main__": + main() diff --git a/services/api/workers/marketmute/vies_extra.py b/services/api/workers/marketmute/vies_extra.py new file mode 100644 index 0000000..77c3ce2 --- /dev/null +++ b/services/api/workers/marketmute/vies_extra.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""VIES-utökning: alla targets som saknar moms-status.""" +import json, time, requests + +IN = "/data/0pt/output/marketmute_targets_v3.jsonl" +OUT = "/data/0pt/output/vies_extra.jsonl" +have = set() +for line in open("/data/0pt/output/vies_targets.jsonl"): + have.add(json.loads(line)["orgnr"]) +targets = [] +for line in open(IN): + t = json.loads(line) + org = t["orgnr"].replace("-", "") + if org not in have: + targets.append(org) +print(f"[INFO] {len(targets):,} targets saknar VIES", flush=True) +sess = requests.Session() +n = 0; valid = 0 +with open(OUT, "w") as out: + for i, org in enumerate(targets): + vat = org + "01" + ok = False + for attempt in range(4): + try: + r = sess.get(f"https://ec.europa.eu/taxation_customs/vies/rest-api/ms/SE/vat/{vat}", timeout=15) + if r.status_code == 429: + time.sleep(5); continue + r.raise_for_status() + d = r.json() + out.write(json.dumps({ + "orgnr": org, "orgnr_fmt": f"{org[:6]}-{org[6:]}", + "momsnr": f"SE{vat}", "moms_valid": d.get("isValid"), + "moms_namn": (d.get("name") or "").strip() or None, + }, ensure_ascii=False) + "\n") + if d.get("isValid"): valid += 1 + ok = True; break + except Exception: + time.sleep(2 ** attempt) + if not ok: + out.write(json.dumps({"orgnr": org, "orgnr_fmt": f"{org[:6]}-{org[6:]}", "moms_valid": None, "error": True}, ensure_ascii=False) + "\n") + n += 1 + if n % 250 == 0: + print(f" {n:,}/{len(targets):,} (valid: {valid:,})", flush=True) + time.sleep(0.25) +print(f"[KLAR] {n:,} kontrollerade, {valid:,} valid -> {OUT}") diff --git a/services/api/workers/marketmute/vies_targets.py b/services/api/workers/marketmute/vies_targets.py new file mode 100644 index 0000000..1934581 --- /dev/null +++ b/services/api/workers/marketmute/vies_targets.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Steg 4 fixad: VIES med korrekt momsnummer (SE + orgnr + 01).""" +import json, time, requests + +IN = "/data/0pt/output/marketmute_targets_dedup.jsonl" +OUT = "/data/0pt/output/vies_targets.jsonl" +sess = requests.Session() +n = 0; valid = 0 +targets = [json.loads(l)["orgnr"].replace("-", "") for l in open(IN)] +print(f"[INFO] {len(targets):,} målbolag (momsnr = SE{targets[0]}01-format)", flush=True) +with open(OUT, "w") as out: + for i, org in enumerate(targets): + vat = org + "01" + ok = False + for attempt in range(4): + try: + r = sess.get(f"https://ec.europa.eu/taxation_customs/vies/rest-api/ms/SE/vat/{vat}", timeout=15) + if r.status_code == 429: + time.sleep(5); continue + r.raise_for_status() + d = r.json() + out.write(json.dumps({ + "orgnr": org, + "orgnr_fmt": f"{org[:6]}-{org[6:]}", + "momsnr": f"SE{vat}", + "moms_valid": d.get("isValid"), + "moms_namn": (d.get("name") or "").strip() or None, + }, ensure_ascii=False) + "\n") + if d.get("isValid"): valid += 1 + ok = True + break + except Exception: + time.sleep(2 ** attempt) + if not ok: + out.write(json.dumps({"orgnr": org, "orgnr_fmt": f"{org[:6]}-{org[6:]}", "moms_valid": None, "error": True}, ensure_ascii=False) + "\n") + n += 1 + if n % 250 == 0: + print(f" {n:,}/{len(targets):,} (moms-valid: {valid:,})", flush=True) + time.sleep(0.25) +print(f"[KLAR] {n:,} kontrollerade, {valid:,} moms-registrerade -> {OUT}")