vehiq: ratsit-telefonkonnektor — företagsnummer via residensiell stealth (57% träff, kraschtålig, resume)

This commit is contained in:
Simon Axelsson
2026-09-07 22:24:43 +02:00
parent 6b3d4fb45d
commit 39e33ccac7
2 changed files with 122 additions and 0 deletions
@@ -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/<orgnr>, 2 workers á 4s väntan + 2.5-5s jitter.
- Extraktion: `Telefon</h2>`/`Telefon:</td>`-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
@@ -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</h2> eller Telefon:</td>-blocket."""
for anchor in ("Telefon</h2>", "Telefon:</td>", ">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())