MarketMute cold call-intelligence pipeline: SCB SNI/reklamspärr-enrichment, kontaktpersoner v2 (96% täckning), GLEIF LEI, VIES momsvalidering, viktad nyckelords+SNI-scoring med compliance-filter

This commit is contained in:
Simon Axelsson
2026-09-07 19:59:45 +02:00
parent 6a6a96e1c2
commit 6b9ec04240
9 changed files with 953 additions and 0 deletions
+34
View File
@@ -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
```
@@ -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()
@@ -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()
@@ -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}")
@@ -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}")
@@ -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}")
@@ -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()
@@ -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}")
@@ -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}")