From 39e33ccac7c7c724fe299654a2bba95dfaea7658 Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Mon, 7 Sep 2026 22:24:43 +0200 Subject: [PATCH] =?UTF-8?q?vehiq:=20ratsit-telefonkonnektor=20=E2=80=94=20?= =?UTF-8?q?f=C3=B6retagsnummer=20via=20residensiell=20stealth=20(57%=20tr?= =?UTF-8?q?=C3=A4ff,=20krascht=C3=A5lig,=20resume)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/workers/marketmute/README-VEHIQ.md | 13 +++ .../workers/marketmute/ratsit_connector.py | 109 ++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 services/api/workers/marketmute/ratsit_connector.py diff --git a/services/api/workers/marketmute/README-VEHIQ.md b/services/api/workers/marketmute/README-VEHIQ.md index df49a58..e74d1ff 100644 --- a/services/api/workers/marketmute/README-VEHIQ.md +++ b/services/api/workers/marketmute/README-VEHIQ.md @@ -30,3 +30,16 @@ VehIQ-marknadsdata från vehiq-db (server10, 98M-fordonstabellen): - `market_value_se.jsonl` — 10 511 märke×modell×år-prisposter (median/p10-p90/annonser) → `vehiq_market_context.json` - `fleet_composition_se.jsonl` — bestånd per märke (Volvo 1,62M) och topp 1 500 modeller - Fordonsägare per bolag kräver fortfarande Transportstyrelsen-prenumeration (ägaruppgifter ingår inte i registerkällan) + +## Telefonnummer via ratsit.se (Simon beslut 2026-09-07) + +`ratsit_connector.py` — kör LOKALT från ASUS (residensiellt IP krävs; Hetzner-IP är +CF-bannad på mrkoll, ratsit 403 från datacenter). Headless Chromium, endast +företagssidor ratsit.se/, 2 workers á 4s väntan + 2.5-5s jitter. + +- Extraktion: `Telefon`/`Telefon:`-blocket, primärt `href="tel:..."`-länk +- Kraschtålig: browser-omstart per worker vid EPIPE/fel, resume via done.txt +- Mål: vehiq_bilbransch prioriterat (bilhandlare → verkstad → leasing → reservdelar → transportör, störst omsättning först) +- Utdata: telefon_ratsit.jsonl {orgnr, telefon, status, kalla, hamtat} +- Hittills: ~57% telefonträff, 0 blockeringar; toppsegment ~1 dygn, hela listan ~3 dygn +- hitta.se: telefon bakom "Visa nummer"-betallösning → ej använd; mrkoll.se: CF-bannad IP diff --git a/services/api/workers/marketmute/ratsit_connector.py b/services/api/workers/marketmute/ratsit_connector.py new file mode 100644 index 0000000..dc6bf2b --- /dev/null +++ b/services/api/workers/marketmute/ratsit_connector.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Ratsit-telefonkonnektor: företagsnummer för VehIQ/MarketMute-mål. +Kör från residensiellt IP (ASUS). Endast företagssidor — inga personuppgifter.""" +import asyncio, json, re, random, os, time, sys +from playwright.async_api import async_playwright +from datetime import datetime, timezone + +IN = sys.argv[2] if len(sys.argv) > 2 else "/home/simon/vehiq-telefon/targets.jsonl" # prioritet 1: bilbranschen +OUT = "/home/simon/vehiq-telefon/telefon_ratsit.jsonl" +DONE = "/home/simon/vehiq-telefon/done.txt" +WORKERS = 2 +MIN_DELAY, MAX_DELAY = 2.5, 5.0 + +SEGM_PRIO = {"bilhandlare": 0, "bilverkstad": 1, "uthyrning_leasing": 2, "reservdelar_dack": 3, "entreprenad": 4, "motorcykel": 5, "transportor": 6} + +def load_targets(): + rows = [] + for line in open(IN): + t = json.loads(line) + rows.append(t) + # prioritet: segment-vikt sedan omsättning (störst först) + rows.sort(key=lambda x: (SEGM_PRIO.get(x.get("segment"), 9), -(x.get("oms") or 0))) + return rows + +def norm_phone(s): + d = re.sub(r"\D", "", s) + if d.startswith("46") and len(d) >= 10: d = "0" + d[2:] + return d if len(d) >= 8 else None + +def extract_phone(html): + """Företagets egen telefon: Telefon eller Telefon:-blocket.""" + for anchor in ("Telefon", "Telefon:", ">Telefon<"): + i = html.find(anchor) + if i > 0: + seg = html[i:i+1200] + m = re.search(r'href="tel:([0-9+-]{8,15})"', seg) + if not m: + m = re.search(r"0[1-9]\d{1,2}[\s-]?\d{2}\s?\d{2,3}(?!\d)", re.sub(r"<[^>]+>", " ", seg)) + if m: + p = norm_phone(m.group(1) if m.lastindex else m.group(0)) + if p: return p + return None + +async def worker(p, wid, queue, outf, done, lock): + while not queue.empty(): + b = None + try: + b = await p.chromium.launch(headless=True, args=["--disable-blink-features=AutomationControlled"]) + pg = await b.new_page(user_agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", viewport={"width":1440,"height":900}, locale="sv-SE") + while True: + try: + t = queue.get_nowait() + except asyncio.QueueEmpty: + return + orgnr = t["orgnr"]; digits = orgnr.replace("-", "") + if digits in done: + queue.task_done(); continue + phone = None; status = "ok" + try: + r = await pg.goto(f"https://www.ratsit.se/{digits}", wait_until="domcontentloaded", timeout=30000) + await pg.wait_for_timeout(4000) + html = await pg.content() + if r.status == 200: + phone = extract_phone(html) + if not phone: status = "ingen_telefon" + elif r.status == 403: + status = "blockerad" + await asyncio.sleep(180) + else: + status = f"http_{r.status}" + except Exception as e: + status = f"fel_{str(e)[:40]}" + raise # trigga omstart av browser + async with lock: + rec = {"orgnr": orgnr, "segment": t.get("segment"), "namn": t.get("namn"), + "postort": t.get("postort"), "telefon": phone, "status": status, + "kalla": f"ratsit.se/{digits}", + "hamtat": datetime.now(timezone.utc).isoformat()} + outf.write(json.dumps(rec, ensure_ascii=False) + "\n"); outf.flush() + done.add(digits) + with open(DONE, "a") as df: df.write(digits + "\n") + queue.task_done() + await asyncio.sleep(MIN_DELAY + random.random() * (MAX_DELAY - MIN_DELAY)) + except Exception as e: + print(f"[worker {wid}] omstart efter fel: {str(e)[:60]}", flush=True) + await asyncio.sleep(5) + finally: + if b: + try: await b.close() + except: pass + +async def main(): + limit = int(sys.argv[1]) if len(sys.argv) > 1 else 999999 + targets = load_targets()[:limit] + done = set(open(DONE).read().split()) if os.path.exists(DONE) else set() + queue = asyncio.Queue() + for t in targets: + if t["orgnr"].replace("-", "") not in done: + queue.put_nowait(t) + print(f"[INFO] {queue.qsize():,} att hämta ({len(done):,} klara), {WORKERS} workers", flush=True) + lock = asyncio.Lock() + outf = open(OUT, "a") + async with async_playwright() as p: + await asyncio.gather(*[worker(p, i, queue, outf, done, lock) for i in range(WORKERS)]) + outf.close() + print("[KLAR]", flush=True) + +if __name__ == "__main__": + asyncio.run(main())