Initial commit: SIAX Scrape Console (migrated from laptop)

This commit is contained in:
2026-11-03 16:09:25 +01:00
commit 78146bb5ab
9 changed files with 2998 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
*.bak
*.bak-*
+7
View File
@@ -0,0 +1,7 @@
FROM python:3.12-slim
WORKDIR /app
COPY *.py ./
ENV PYTHONUNBUFFERED=1
ENV HOME=/data
EXPOSE 8899
CMD ["python3", "server.py"]
+1
View File
@@ -0,0 +1 @@
{"biluppgifter_records": {"rows": 3592, "bytes": -1, "key": "16:14914251"}, "sf_spool": {"rows": 0, "bytes": -1, "key": "0:0"}, "carinfo_trims": {"rows": 407, "bytes": -1, "key": "1791395435.6649714:141170"}, "vehicles_clean": {"rows": 119714, "bytes": -1, "key": "1786478796.0:57346606"}, "allabolag": {"rows": 204961, "bytes": -1, "key": "1786478742.0:81531189"}, "hitta": {"rows": 88104, "bytes": -1, "key": "1786478749.0:7202748"}, "ratsit": {"rows": 229349, "bytes": -1, "key": "1786478755.0:12276419"}, "bolagsfakta": {"rows": 888661, "bytes": -1, "key": "1786478747.0:18001402"}, "hitta_companies": {"rows": 8, "bytes": -1, "key": "1790959874.31363:2577"}, "queue_biluppgifter": {"rows": 1819062, "bytes": -1, "key": "1791204095.6476479:86491329"}, "queue_biluppgifter_crawled": {"rows": 213065, "bytes": -1, "key": "1791204095.6476479:9993850"}, "queue_multi_fordonssok": {"rows": 0, "bytes": -1, "key": "1791216576.2764611:0"}, "queue_multi_fordonskollen": {"rows": 0, "bytes": -1, "key": "1791216576.2764611:0"}, "queue_multi_carinfo": {"rows": 0, "bytes": -1, "key": "1791216576.2764611:0"}, "reco_companies": {"rows": 4, "bytes": -1, "key": "1790959888.311806:395"}, "hitta_person_reg": {"rows": 2000000, "bytes": -1, "key": "1791210077.6520803:305528424"}, "hitta_company_reg": {"rows": 1588677, "bytes": -1, "key": "1791219600.018239:103172372"}, "ratsit_person_reg": {"rows": 2000000, "bytes": -1, "key": "1791212987.7546084:661407740"}, "ratsit_company_reg": {"rows": 1312997, "bytes": -1, "key": "1791213028.0060341:74810637"}, "bolagsfakta_reg": {"rows": 1538781, "bytes": -1, "key": "1791217621.7285998:91414094"}, "allabolag_reg": {"rows": 1285485, "bytes": -1, "key": "1786478090.0:47562943"}, "relations": {"rows": 0, "bytes": -1, "key": "0:0"}, "queue_hitta_person": {"rows": 2000000, "bytes": -1, "key": "1791376301.2104456:198937064"}, "queue_hitta_company": {"rows": 921216, "bytes": -1, "key": "1791376445.3741066:57927832"}, "queue_ratsit_person": {"rows": 38112, "bytes": -1, "key": "1791233954.4740746:4089353"}, "queue_bolagsfakta_company": {"rows": 1520927, "bytes": -1, "key": "1791292143.7172053:90353412"}, "identified": {"rows": 0, "bytes": -1, "key": "0:0"}, "relations_v2": {"rows": 0, "bytes": -1, "key": "0:0"}}
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Anpassningsbar widget-dashboard för Scrape Console (sidan /dash).
Layouten sparas i webbläsarens localStorage. Widgets läser befintliga endpoints."""
DASH_PAGE = """<!DOCTYPE html><html lang="sv"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Scrape Console · Min dashboard</title>
<style>body{background:#0d1117;color:#e6edf3;font-family:system-ui,sans-serif;margin:0;padding:16px}
a{color:#58a6ff}h1{font-size:20px;margin:0 0 8px}
#toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;align-items:center;background:#161b22;border:1px solid #30363d;border-radius:8px;padding:10px}
select,input{background:#0d1117;color:#e6edf3;border:1px solid #30363d;border-radius:6px;padding:6px 10px;font-size:13px}
button{background:#21262d;color:#e6edf3;border:1px solid #30363d;border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px}
button:hover{background:#30363d}
#grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.box{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:12px;min-width:0}
.box.w1{grid-column:span 1}.box.w2{grid-column:span 2}.box.w3{grid-column:span 3}
.box h3{font-size:14px;margin:0 0 6px}
.tools{float:right;display:flex;gap:2px}.tools button{padding:1px 7px;font-size:11px;margin:0}
.bar{background:#30363d;border-radius:4px;height:8px;margin:4px 0 8px}.bar>i{display:block;height:8px;border-radius:4px;background:#1f6feb}
.src{font-size:12px;margin:2px 0}.src b{color:#e6edf3}.kv{font-size:12px;color:#8b949e}.kv b{color:#e6edf3}
pre{background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:8px;font-size:11px;overflow:auto;max-height:220px;white-space:pre-wrap}
table{border-collapse:collapse;width:100%;font-size:11px}th,td{border:1px solid #30363d;padding:4px 6px;text-align:left;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
th{background:#0d1117}svg{width:100%;height:50px}
textarea{width:100%;background:#0d1117;color:#e6edf3;border:1px solid #30363d;border-radius:6px;font-size:12px;min-height:60px}
@media(max-width:900px){#grid{grid-template-columns:1fr}.box.w1,.box.w2,.box.w3{grid-column:span 1}}</style></head>
<body><h1>🕷️ <a href="/">Scrape Console</a> · <a href="/data-page">Alla data</a> · Min dashboard</h1>
<div id="toolbar"><b>Lägg till box:</b><select id="wtype"></select><select id="wref"></select>
<button onclick="addBox()">+ Lägg till</button><button onclick="resetDash()">↺ Återställ</button>
<span id="ts" style="color:#8b949e;font-size:12px"></span></div>
<div id="grid"></div>
<script>
const WDEF = {
progress: {t:'Progress per källa', refs:['*']},
rate: {t:'Takt-graf', refs:['biluppgifter','fordonskollen','fordonssok','carinfo']},
log: {t:'Loggsvans', refs:['multi:SF multi','carinfo:car.info']},
dataset: {t:'Datasett-tabell', refs:[]},
status: {t:'Scraper-status', refs:['*']},
ckpt: {t:'Checkpoint', refs:['*']},
note: {t:'Anteckning (egen text)', refs:['*']},
};
let L = JSON.parse(localStorage.getItem('scrape-dash') || 'null') || [
{id:1,type:'status',ref:'*',w:3},
{id:2,type:'progress',ref:'*',w:2},
{id:3,type:'rate',ref:'fordonssok',w:1},
{id:4,type:'log',ref:'multi',w:2},
{id:5,type:'dataset',ref:'vehicles_clean',w:3},
];
let DS = [], ST = null, HIST = [];
function save(){localStorage.setItem('scrape-dash', JSON.stringify(L));}
function resetDash(){localStorage.removeItem('scrape-dash');location.reload();}
async function init(){
DS = await (await fetch('/datasets')).json();
WDEF.dataset.refs = DS.map(d=>d.id+':'+d.label.split('·').pop().trim().slice(0,28));
const t = document.getElementById('wtype'), r = document.getElementById('wref');
t.innerHTML = Object.entries(WDEF).map(([k,v])=>`<option value="${k}">${v.t}</option>`).join('');
t.onchange = ()=>{r.innerHTML = WDEF[t.value].refs.map(x=>{const [v,l]=x.split(/:(.+)/);return `<option value="${v}">${l||v}</option>`;}).join('');};
t.onchange(); render();
}
function addBox(){
const type = document.getElementById('wtype').value, ref = document.getElementById('wref').value;
let extra = {};
if(type==='note'){const txt = prompt('Text:',''); if(txt===null) return; extra={text:txt};}
L.push({id:Date.now(), type, ref, w:type==='status'||type==='dataset'?3:1, ...extra});
save(); render();
}
function boxOp(id, op){
const i = L.findIndex(b=>b.id===id); if(i<0) return;
if(op==='del') L.splice(i,1);
if(op==='left' && i>0) [L[i-1],L[i]] = [L[i],L[i-1]];
if(op==='right' && i<L.length-1) [L[i+1],L[i]] = [L[i],L[i+1]];
if(op==='w') L[i].w = L[i].w%3+1;
if(op==='edit' && L[i].type==='note'){const t=prompt('Text:',L[i].text||''); if(t!==null)L[i].text=t;}
save(); render();
}
function tools(b){
return `<span class="tools"><button onclick="boxOp(${b.id},'left')" title="flytta vänster">◀</button><button onclick="boxOp(${b.id},'right')" title="flytta höger">▶</button><button onclick="boxOp(${b.id},'w')" title="bredd">↔${b.w}</button>${b.type==='note'?`<button onclick="boxOp(${b.id},'edit')" title="redigera">✎</button>`:''}<button onclick="boxOp(${b.id},'del')" title="ta bort">✕</button></span>`;
}
async function render(){
ST = await (await fetch('/state')).json();
HIST = await (await fetch('/history')).json();
document.getElementById('ts').textContent = 'Uppdaterad ' + ST.ts + ' (auto 15 s)';
const g = document.getElementById('grid'); g.innerHTML = '';
for(const b of L){
let h = `<div class="box w${b.w}">${tools(b)}`;
if(b.type==='status'){
h += `<h3>Scraper-status</h3>` + ST.scrapers.map(c=>`<div class="src"><b>${c.label}</b> ${c.active} · ${c.mem_mb} MB · ${c.log?'':''}</div>`).join('');
} else if(b.type==='progress'){
const p = ST.scrapers[0].progress || {};
h += `<h3>Progress per källa</h3>`;
for(const [k,v] of Object.entries(p)){ if(k.startsWith('_')) continue;
const pct = 100*v.pos/v.total;
h += `<div class="src"><b>${k}</b> ${v.ok} ok/${v.fail} fail · ${(pct).toFixed(2)}%</div><div class="bar"><i style="width:${Math.min(100,pct)}%"></i></div>`; }
} else if(b.type==='rate'){
h += `<h3>Takt: ${b.ref}</h3>` + spark(b.ref);
} else if(b.type==='log'){
const [src] = b.ref.split(':');
const t = await (await fetch(`/log?src=${src}&n=12`)).text();
h += `<h3>Logg: ${b.ref}</h3><pre>${t.replace(/</g,'&lt;')}</pre>`;
} else if(b.type==='dataset'){
const d = await (await fetch(`/data?ds=${b.ref}&page=1`)).json();
const cols = (d.cols||[]).slice(0,5);
h += `<h3>${(DS.find(x=>x.id===b.ref)||{}).label||b.ref} (${d.total})</h3><table><tr>${cols.map(c=>`<th>${c}</th>`).join('')}</tr>` +
(d.rows||[]).slice(0,8).map(r=>`<tr>${cols.map(c=>`<td>${String(r[c]??'').replace(/</g,'&lt;')}</td>`).join('')}</tr>`).join('') + `</table>`;
} else if(b.type==='ckpt'){
h += `<h3>car.info checkpoint</h3><div class="src"><b>${Object.entries(ST.scrapers[1].ckpt||{}).map(([k,v])=>k+'='+v).join(' · ')}</b></div>`;
} else if(b.type==='note'){
h += `<h3>📝 Anteckning</h3><div class="src">${(b.text||'').replace(/</g,'&lt;')}</div>`;
}
g.innerHTML += h + '</div>';
}
}
function spark(src){
const pts = HIST.filter(h=>h.ok && h.ok[src]!==undefined).slice(-60);
if(pts.length < 2) return '<div class="kv">ingen historik ännu</div>';
const vals = pts.map(h=>h.ok[src]), mx = Math.max(...vals), mn = Math.min(...vals);
const W=300,H=46, path = vals.map((v,i)=>`${(i/(vals.length-1)*W).toFixed(1)},${(H-2-((v-mn)/Math.max(1,mx-mn))*(H-4)).toFixed(1)}`).join(' ');
const rate = ((vals[vals.length-1]-vals[0])/Math.max(1,(pts[pts.length-1].t-pts[0].t)/60)).toFixed(0);
return `<svg viewBox="0 0 ${W} ${H}"><polyline points="${path}" fill="none" stroke="#1f6feb" stroke-width="1.5"/></svg><div class="kv"><b>${rate}/min</b></div>`;
}
init(); setInterval(render, 15000);
</script></body></html>"""
+613
View File
@@ -0,0 +1,613 @@
#!/usr/bin/env python3
"""Data-vyer för Scrape Console: dataset-register, tabell, postdetalj, export.
Endast stdlib. Anropas från server.py (routes) och serverar /data-sidan."""
import csv, gzip, json, os, time
HOME = os.path.expanduser("~")
SF = os.path.join(HOME, "Documents/SF_Extractor")
CI = os.path.join(HOME, ".local/share/carinfo")
COUNT_CACHE = {"ts": 0, "data": {}}
def _files_jsonl_gz(d):
try:
return sorted(
[
os.path.join(d, f)
for f in os.listdir(d)
if f.endswith(".jsonl.gz") and os.path.getsize(os.path.join(d, f)) > 0
]
)
except Exception:
return []
def _iter_jsonl_gz(path):
try:
with gzip.open(path, "rt", errors="replace") as f:
for line in f:
line = line.strip()
if line:
try:
yield json.loads(line)
except Exception:
continue
except Exception:
return
def _iter_jsonl(path):
try:
with open(path, errors="replace") as f:
for line in f:
line = line.strip()
if line:
try:
yield json.loads(line)
except Exception:
continue
except Exception:
return
def _iter_csv(path):
try:
with open(path, newline="", errors="replace") as f:
for row in csv.DictReader(f):
yield row
except Exception:
return
def _count(it, cap=2000000):
n = 0
for _ in it:
n += 1
if n >= cap:
break
return n
SCRAPER_OF = {"biluppgifter_records": "sf", "sf_spool": "sf", "carinfo_trims": "carinfo",
"vehicles_clean": "sf", "allabolag": "sf", "hitta": "sf", "ratsit": "sf",
"bolagsfakta": "sf", "hitta_companies": "sf", "reco_companies": "sf",
"regnr": "sf", "queue_biluppgifter": "sf", "queue_biluppgifter_crawled": "sf",
"queue_multi_fordonssok": "sf", "queue_multi_fordonskollen": "sf",
"queue_multi_carinfo": "sf"}
DATASETS = [
{
"id": "biluppgifter_records",
"label": "SF · biluppgifter records (extraherat)",
"kind": "multi-jsonl-gz",
"dir": os.path.join(SF, "full_data/biluppgifter"),
"prefix": "records-",
"desc": "titel, tabeller, json_ld, text per fordonssida",
},
{
"id": "sf_spool",
"label": "SF · spool (råcaptures, live)",
"kind": "multi-jsonl-gz",
"dir": os.path.join(SF, "spool"),
"prefix": "multi-",
"desc": "{url, html} per hämtad sida, nyast först",
},
{
"id": "carinfo_trims",
"label": "car.info · trims/specs",
"kind": "jsonl",
"path": os.path.join(CI, "trims.jsonl"),
"desc": "{url, make, model, specs{...}}",
},
{
"id": "vehicles_clean",
"label": "SF · vehicles_clean.csv",
"kind": "csv",
"path": os.path.join(SF, "cleaned_allabolag/vehicles_clean.csv"),
"desc": "40+ fordonskolumner (regnr, vin, make, model, skatt, besiktning ...)",
},
{
"id": "allabolag",
"label": "SF · extracted_allabolag.csv",
"kind": "csv",
"path": os.path.join(SF, "cleaned_allabolag/extracted_allabolag.csv"),
"desc": "",
},
{
"id": "hitta",
"label": "SF · extracted_hitta.csv",
"kind": "csv",
"path": os.path.join(SF, "cleaned_allabolag/extracted_hitta.csv"),
"desc": "",
},
{
"id": "ratsit",
"label": "SF · extracted_ratsit.csv",
"kind": "csv",
"path": os.path.join(SF, "cleaned_allabolag/extracted_ratsit.csv"),
"desc": "",
},
{
"id": "bolagsfakta",
"label": "SF · extracted_bolagsfakta.csv",
"kind": "csv",
"path": os.path.join(SF, "cleaned_allabolag/extracted_bolagsfakta.csv"),
"desc": "",
},
{
"id": "hitta_companies",
"label": "SF · hitta companies.csv",
"kind": "csv",
"path": os.path.join(SF, "multisite_data/hitta/companies.csv"),
"desc": "",
},
{
"id": "regnr",
"label": "Register · regnr x domän (14,5M, indexerat)",
"kind": "sqlite",
"desc": "prefixsök på regnr — ms-svar via index",
},
{
"id": "queue_biluppgifter",
"label": "SF · kö: biluppgifter frontier (1,8M)",
"kind": "lines",
"path": os.path.join(SF, "full_data/biluppgifter/frontier.txt"),
"desc": "",
},
{
"id": "queue_biluppgifter_crawled",
"label": "SF · kö: biluppgifter crawled",
"kind": "lines",
"path": os.path.join(SF, "full_data/biluppgifter/crawled.txt"),
"desc": "",
},
{
"id": "queue_multi_fordonssok",
"label": "SF · kö: multi/fordonssok crawled",
"kind": "lines",
"path": os.path.join(SF, "full_data/multi/fordonssok/crawled.txt"),
"desc": "",
},
{
"id": "queue_multi_fordonskollen",
"label": "SF · kö: multi/fordonskollen crawled",
"kind": "lines",
"path": os.path.join(SF, "full_data/multi/fordonskollen/crawled.txt"),
"desc": "",
},
{
"id": "queue_multi_carinfo",
"label": "SF · kö: multi/carinfo crawled",
"kind": "lines",
"path": os.path.join(SF, "full_data/multi/carinfo/crawled.txt"),
"desc": "",
},
{
"id": "reco_companies",
"label": "SF · reco companies.csv",
"kind": "csv",
"path": os.path.join(SF, "multisite_data/reco/companies.csv"),
"desc": "",
},
{
"id": "hitta_person_reg",
"label": "Register · hitta personer (6,08M)",
"kind": "lines",
"path": os.path.join(SF, "hitta_person_urls.txt"),
"desc": "person-URL:er från hitta.se sitemaps",
},
{
"id": "hitta_company_reg",
"label": "Register · hitta företag (1,59M rena)",
"kind": "lines",
"path": os.path.join(SF, "hitta_company_clean.txt"),
"desc": "malformade sök-URL:er borttagna",
},
{
"id": "ratsit_person_reg",
"label": "Register · ratsit personer (6,2M)",
"kind": "lines",
"path": os.path.join(SF, "ratsit_person_urls.txt"),
"desc": "namn+ort+födelsedatum i URL",
},
{
"id": "ratsit_company_reg",
"label": "Register · ratsit företag (1,31M)",
"kind": "lines",
"path": os.path.join(SF, "ratsit_company_urls.txt"),
"desc": "",
},
{
"id": "bolagsfakta_reg",
"label": "Register · bolagsfakta företag (1,54M)",
"kind": "lines",
"path": os.path.join(SF, "bolagsfakta_company_urls.txt"),
"desc": "",
},
{
"id": "allabolag_reg",
"label": "Register · allabolag företag (1,29M)",
"kind": "lines",
"path": os.path.join(SF, "allabolag_urls_for_sf.txt"),
"desc": "",
},
{
"id": "relations",
"label": "VEHIQ · relationsgraf fordon→ägare→person→företag→adress",
"kind": "multi-jsonl-gz",
"dir": os.path.join(HOME, "sf-s10/full_data/relations"),
"prefix": "relations-",
"desc": "kanter: src_kind/src_id/relation/dst_kind/dst_id/weight",
},
{"id":"queue_hitta_person","label":"SF · kö: multi/hitta_person crawled","kind":"lines","path":os.path.join(SF,"full_data/multi/hitta_person/crawled.txt"),"desc":""},
{"id":"queue_hitta_company","label":"SF · kö: multi/hitta_company crawled","kind":"lines","path":os.path.join(SF,"full_data/multi/hitta_company/crawled.txt"),"desc":""},
{"id":"queue_ratsit_person","label":"SF · kö: multi/ratsit_person crawled","kind":"lines","path":os.path.join(SF,"full_data/multi/ratsit_person/crawled.txt"),"desc":""},
{"id":"queue_bolagsfakta_company","label":"SF · kö: multi/bolagsfakta_company crawled","kind":"lines","path":os.path.join(SF,"full_data/multi/bolagsfakta_company/crawled.txt"),"desc":""},
{
"id": "identified",
"label": "SF · identifierade records (_id + regnr/orgnr/personnummer)",
"kind": "multi-jsonl-gz",
"dir": os.path.join(HOME, "sf-s10/full_data/identified"),
"prefix": "identified",
"desc": "_id (vehicle:/company:/person:), id_type, regnr, orgnr, personnummer",
},
{
"id": "relations_v2",
"label": "VEHIQ · relations v2 (företag→adress, företag→person, samma adress)",
"kind": "multi-jsonl-gz",
"dir": os.path.join(HOME, "sf-s10/full_data/relations"),
"prefix": "relations_v2",
"desc": "located_at / related_person / related_company / same_address / brand / model",
},
]
def _wc(path):
try:
with open(path, "rb") as f:
return sum(1 for _ in f)
except Exception:
return -1
def _ds(dsid):
for d in DATASETS:
if d["id"] == dsid:
return d
return None
def _iter_ds(d, reverse_spool=True):
if d["kind"] == "multi-jsonl-gz":
files = [
f
for f in _files_jsonl_gz(d["dir"])
if os.path.basename(f).startswith(d.get("prefix", ""))
]
if reverse_spool:
files = sorted(files, key=os.path.getmtime, reverse=True)
for fp in files:
yield from _iter_jsonl_gz(fp)
elif d["kind"] == "jsonl":
yield from _iter_jsonl(d["path"])
elif d["kind"] == "csv":
yield from _iter_csv(d["path"])
elif d["kind"] == "lines":
yield from _iter_lines(d["path"])
def counts():
if time.time() - COUNT_CACHE["ts"] < 120:
return COUNT_CACHE["data"]
cfile = os.path.join(HOME, ".local/share/scrape-console/counts.json")
try:
saved = json.load(open(cfile))
except Exception:
saved = {}
def fkey(d):
try:
if d["kind"] in ("csv", "jsonl", "lines"):
st = os.stat(d["path"])
return "%s:%s" % (st.st_mtime, st.st_size)
fs = sorted(_files_jsonl_gz(d["dir"]))
return "%s:%s" % (len(fs), sum(os.path.getsize(f) for f in fs))
except Exception:
return "?"
out, dirty = {}, False
for d in DATASETS:
if d["kind"] == "sqlite":
out[d["id"]] = {"rows": sum(DOMAIN_COUNTS.values()), "bytes": 4100000000}
continue
key = fkey(d)
if saved.get(d["id"], {}).get("key") == key:
out[d["id"]] = saved[d["id"]]
continue
try:
if d["kind"] == "csv":
n = _count(_iter_csv(d["path"]))
elif d["kind"] == "jsonl":
n = _count(_iter_jsonl(d["path"]))
elif d["kind"] == "lines":
n = _count(_iter_lines(d["path"]))
else:
files = [
f
for f in _files_jsonl_gz(d["dir"])
if os.path.basename(f).startswith(d.get("prefix", ""))
]
n = 0
for fp in files:
n += _count(_iter_jsonl_gz(fp))
if n >= 2000000:
break
except Exception as e:
out[d["id"]] = {"rows": -1, "bytes": -1, "err": str(e)[:80]}
continue
out[d["id"]] = {"rows": n, "bytes": -1, "key": key}
saved[d["id"]] = out[d["id"]]
dirty = True
if dirty:
try:
json.dump(saved, open(cfile, "w"))
except Exception:
pass
COUNT_CACHE.update(ts=time.time(), data=out)
return out
REGDB = os.path.expanduser("~/.local/share/regnr-register/regnr.db")
DOMAIN_COUNTS = {"fordonssok": 11524863, "fordonskollen": 13366899,
"carinfo": 11075425, "minbilkoll": 98658} # snapshot 2026-10-05
def _sqlite_sightings(q="", page=1, per=50):
import sqlite3
if not os.path.exists(REGDB):
raise FileNotFoundError("registret återbyggs — klart inom ~1h")
con = sqlite3.connect("file:" + REGDB + "?mode=ro", uri=True)
cur = con.cursor()
q = (q or "").strip().upper()
if q:
cur.execute("SELECT regnr, domain, url FROM sightings WHERE regnr LIKE ? "
"ORDER BY regnr, domain LIMIT 500", (q + "%",))
rows = [{"regnr": r[0], "domain": r[1], "url": r[2]} for r in cur.fetchall()]
else:
cur.execute("SELECT regnr, domain, url FROM sightings ORDER BY regnr LIMIT 50")
rows = [{"regnr": r[0], "domain": r[1], "url": r[2]} for r in cur.fetchall()]
con.close()
return rows
def _iter_lines(path):
try:
with open(path, errors="replace") as f:
for line in f:
line = line.strip()
if line:
yield {"rad": line[:300]}
except Exception:
return
def _spool_title(html):
import re
m = re.search(r"<title>(.*?)</title>", html or "", re.S | re.I)
return (m.group(1).strip()[:140] if m else "-")
def _flat(rec):
"""Platta till en nivå för tabellvisning (kapslade -> JSON-sträng, trunkerad)."""
if not isinstance(rec, dict):
return {"value": str(rec)[:300]}
if set(rec.keys()) == {"u", "h"}:
return {"url": rec.get("u", "")[:200], "titel": _spool_title(rec.get("h", "")),
"html_kb": round(len(rec.get("h", "")) / 1024, 1)}
out = {}
for k, v in rec.items():
if isinstance(v, (dict, list)):
s = json.dumps(v, ensure_ascii=False)
out[k] = (s[:220] + "…") if len(s) > 220 else s
else:
s = str(v)
out[k] = (s[:220] + "…") if len(s) > 220 else s
return out
def query(dsid, q="", page=1, per=50, scan_cap=400000, sort=None, sdir="asc"):
d = _ds(dsid)
if not d:
return {"error": "okänt dataset"}
if d["kind"] == "sqlite":
try:
rows = _sqlite_sightings(q, page, per)
except Exception as e:
return {"error": str(e)[:120], "total": 0, "page": page, "per": per,
"pages": 1, "cols": [], "rows": [], "scanned": 0, "truncated": False}
cols = ["regnr", "domain", "url"]
if sort == "domain":
rows.sort(key=lambda r: r["domain"], reverse=(sdir == "desc"))
start = (page - 1) * per
total = len(rows) if q else sum(DOMAIN_COUNTS.values())
return {"total": total, "page": page, "per": per,
"pages": max(1, (min(total, 500) + per - 1) // per),
"cols": cols, "rows": rows[start:start + per],
"scanned": len(rows), "truncated": len(rows) >= 500,
"sort": sort, "dir": sdir}
if d["kind"] == "lines" and not q and not sort:
total = _wc(d["path"])
start = (page - 1) * per
rows, i = [], 0
for rec in _iter_lines(d["path"]):
if i >= start and len(rows) < per:
rows.append(_flat(rec))
i += 1
if i >= start + per:
break
return {"total": total if total >= 0 else 0, "page": page, "per": per,
"pages": max(1, (max(total, 0) + per - 1) // per),
"cols": ["rad"], "rows": rows, "scanned": i,
"truncated": False, "sort": sort, "dir": sdir}
q = (q or "").lower()
matched, scanned = [], 0
for rec in _iter_ds(d):
scanned += 1
if scanned > scan_cap:
break
if q and q not in json.dumps(rec, ensure_ascii=False).lower():
continue
matched.append(rec)
cols = list(_flat(matched[0]).keys())[:12] if matched else []
if sort and sort in cols:
try:
matched.sort(key=lambda r: str(_flat(r).get(sort, "")),
reverse=(sdir == "desc"))
except Exception:
pass
total = len(matched)
start = (page - 1) * per
return {
"total": total,
"page": page,
"per": per,
"pages": max(1, (total + per - 1) // per),
"cols": cols,
"rows": [_flat(r) for r in matched[start:start + per]],
"scanned": scanned,
"truncated": scanned >= scan_cap,
"sort": sort,
"dir": sdir,
}
def record_at(dsid, n, q=""):
d = _ds(dsid)
if not d:
return {"error": "okänt dataset"}
q = (q or "").lower()
i = -1
for rec in _iter_ds(d):
if q and q not in json.dumps(rec, ensure_ascii=False).lower():
continue
i += 1
if i == n:
return {"n": n, "record": rec}
if i > n + 5:
break
return {"error": "finns ej"}
def export(dsid, q="", fmt="csv", cap=100000):
d = _ds(dsid)
q = (q or "").lower()
rows, cols, n = [], [], 0
for rec in _iter_ds(d):
if q and q not in json.dumps(rec, ensure_ascii=False).lower():
continue
f = _flat(rec)
if not cols:
cols = list(f.keys())
rows.append(f)
n += 1
if n >= cap:
break
if fmt == "json":
body = json.dumps(rows, ensure_ascii=False, indent=1).encode()
return body, "application/json", f"{dsid}.json"
if fmt == "html":
h = ["<table><tr>" + "".join(f"<th>{c}</th>" for c in cols) + "</tr>"]
for r in rows:
h.append("<tr>" + "".join(f"<td>{str(r.get(c, ''))}</td>" for c in cols) + "</tr>")
h.append("</table>")
return ("".join(h)).encode(), "text/html", f"{dsid}.html"
if fmt == "xml":
import xml.etree.ElementTree as ET
root = ET.Element("rows")
for r in rows:
it = ET.SubElement(root, "row")
for c in cols:
e = ET.SubElement(it, "f", name=c)
e.text = str(r.get(c, ""))
return ET.tostring(root, encoding="unicode").encode(), "application/xml", f"{dsid}.xml"
import io
buf = io.StringIO()
w = csv.DictWriter(buf, fieldnames=cols, extrasaction="ignore")
w.writeheader()
w.writerows(rows)
return buf.getvalue().encode(), "text/csv", f"{dsid}.csv"
PAGE = """<!DOCTYPE html><html lang="sv"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Scrape Console · Alla data</title>
<style>body{background:#0d1117;color:#e6edf3;font-family:system-ui,sans-serif;margin:0;padding:16px}
a{color:#58a6ff}h1{font-size:20px;margin:0 0 8px}.bar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;align-items:center}
select,input{background:#0d1117;color:#e6edf3;border:1px solid #30363d;border-radius:6px;padding:6px 10px;font-size:13px}
button{background:#21262d;color:#e6edf3;border:1px solid #30363d;border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px}
button:hover{background:#30363d}
table{border-collapse:collapse;width:100%;font-size:12px}th,td{border:1px solid #30363d;padding:5px 8px;text-align:left;vertical-align:top;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
th{background:#161b22;position:sticky;top:0;cursor:pointer}th:first-child,td:first-child{position:sticky;left:0;background:#161b22;z-index:1}
tr:hover td{background:#1c2530;cursor:pointer}
#info{color:#8b949e;font-size:12px;margin:8px 0}
#cols{font-size:11px;color:#8b949e;display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px}
#cols label{cursor:pointer}
#modal{position:fixed;inset:0;background:rgba(0,0,0,.7);display:none;align-items:center;justify-content:center}
#mbox{background:#161b22;border:1px solid #30363d;border-radius:8px;max-width:90%;max-height:85%;overflow:auto;padding:16px}
pre{font-size:11px;white-space:pre-wrap}</style></head>
<body><h1>🕷️ <a href="/">Scrape Console</a> · <a href="/dash">Min dashboard</a> · Alla data</h1>
<div class="bar"><select id="scr"><option value="">Alla scrapers</option><option value="sf">SF multi-scrape</option><option value="carinfo">car.info</option></select><select id="ds"></select><input id="q" placeholder="sök (delsträng)…" size="28">
<button onclick="load(1)">Sök</button><button onclick="exp('csv')">CSV</button><button onclick="exp('json')">JSON</button>
<span id="info"></span></div>
<div id="cols"></div>
<div id="pg" class="bar"></div>
<table><thead id="th"></thead><tbody id="tb"></tbody></table>
<div id="modal" onclick="this.style.display='none'"><div id="mbox" onclick="event.stopPropagation()"></div></div>
<script>
let DS='',Q='',PAGEG=1,COLS=[],HIDE=new Set(),SORT=null,DIR='asc',ALLDS=[];
async function init(){
ALLDS = await (await fetch('/datasets')).json();
const prm = new URLSearchParams(location.search);
if(prm.get('scraper')) document.getElementById('scr').value = prm.get('scraper');
document.getElementById('scr').onchange = fillDS;
fillDS();
if(prm.get('ds')) document.getElementById('ds').value = prm.get('ds');
DS = document.getElementById('ds').value;
document.getElementById('q').addEventListener('keydown',e=>{if(e.key==='Enter')load(1);});
load(1);
}
function fillDS(){
const f = document.getElementById('scr').value;
const s = document.getElementById('ds');
s.innerHTML = ALLDS.filter(d=>!f||d.scraper===f).map(x=>`<option value="${x.id}">${x.label} (${x.rows})</option>`).join('');
DS = s.value;
}
async function load(p){
Q = document.getElementById('q').value; PAGEG = p;
DS = document.getElementById('ds').value;
let url = `/data?ds=${DS}&q=${encodeURIComponent(Q)}&page=${p}`;
if(SORT) url += `&sort=${encodeURIComponent(SORT)}&dir=${DIR}`;
const r = await (await fetch(url)).json();
if(r.error){document.getElementById('info').textContent=r.error;return;}
COLS = r.cols;
document.getElementById('info').textContent = `${r.total} träffar · sida ${r.page}/${r.pages}${r.truncated?' · cappad genomsökning':''}${r.sort?` · sort ${r.sort} ${r.dir}`:''}`;
document.getElementById('cols').innerHTML = 'Kolumner: ' + COLS.map(c=>`<label><input type="checkbox" ${HIDE.has(c)?'':'checked'} onchange="tglCol('${c.replace(/'/g,'')}')"> ${c}</label>`).join('');
const vis = COLS.filter(c=>!HIDE.has(c));
document.getElementById('th').innerHTML = '<tr><th>#</th>'+vis.map(c=>`<th onclick="sortBy('${c.replace(/'/g,'')}')">${c}${SORT===c?(DIR==='asc'?' ▲':' ▼'):''}</th>`).join('')+'</tr>';
document.getElementById('tb').innerHTML = r.rows.map((row,i)=>`<tr onclick="openRec(${(r.page-1)*r.per+i})"><td>${(r.page-1)*r.per+i}</td>`+vis.map(c=>`<td>${String(row[c]??'').replace(/</g,'&lt;')}</td>`).join('')+'</tr>').join('');
let pg = '';
for(const pp of [1,r.page-1,r.page,r.page+1,r.pages]) if(pp>=1&&pp<=r.pages) pg += `<button ${pp===r.page?'disabled':''} onclick="load(${pp})">${pp}</button>`;
document.getElementById('pg').innerHTML = pg;
}
function tglCol(c){HIDE.has(c)?HIDE.delete(c):HIDE.add(c);load(PAGEG);}
function sortBy(c){if(SORT===c)DIR=DIR==='asc'?'desc':'asc';else{SORT=c;DIR='asc';}load(PAGEG);}
async function openRec(n){
const r = await (await fetch(`/record?ds=${DS}&n=${n}&q=${encodeURIComponent(Q)}`)).json();
document.getElementById('mbox').innerHTML = `<h3>Post #${n} (${DS})</h3><pre>${JSON.stringify(r.record,null,1).replace(/</g,'&lt;').slice(0,60000)}</pre>`;
document.getElementById('modal').style.display = 'flex';
}
function exp(fmt){location.href = `/export?ds=${DS}&q=${encodeURIComponent(Q)}&fmt=${fmt}`;}
init();
</script></body></html>
"""
+1500
View File
File diff suppressed because it is too large Load Diff
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""Ops-vyer för Scrape Console (Octoparse-paritet): runs-historik, scheman,
proxy-pool, pipeline-steg och alerts. Endast stdlib. Endpoint: /ops."""
import json, os, re, shutil, subprocess, time
from collections import Counter
HOME = os.path.expanduser("~")
SF = os.path.join(HOME, "Documents/SF_Extractor")
CI = os.path.join(HOME, ".local/share/carinfo")
CACHE = {"ts": 0, "data": None}
UNITS = [
"sf-multi-scrape.service",
"carinfo-crawl.service",
"siax-enrich.service",
"siax-laptop-scanner.service",
]
def _runs(unit, days=3):
try:
out = subprocess.run(
[
"journalctl",
"--user",
"-u",
unit,
"--since",
f"{days} days ago",
"-o",
"short-iso",
"--no-pager",
],
capture_output=True,
text=True,
timeout=30,
).stdout.splitlines()
except Exception:
return []
runs, cur = [], None
for line in out:
m = re.match(
r"(\S+) \S+ systemd\[\d+\]: (Started|Stopped|.*Failed with result '([^']+)')",
line,
)
if not m:
continue
ts, ev = m.group(1), m.group(2)
if ev == "Started":
cur = {"start": ts[11:16], "day": ts[:10]}
elif cur is not None and (ev == "Stopped" or "Failed" in ev):
cur["end"] = ts[11:16]
cur["result"] = m.group(3) if "Failed" in ev else "ok"
runs.append(cur)
cur = None
if cur is not None:
cur["end"] = "pågår"
cur["result"] = "running"
runs.append(cur)
return runs[-12:]
def _schedules():
out = []
try:
txt = subprocess.run(
["systemctl", "--user", "list-timers", "--no-pager"],
capture_output=True,
text=True,
timeout=15,
).stdout
for line in txt.splitlines():
for key in ("p0lytics", "sf-scrape", "proton", "opencode-db", "sloso"):
if key in line:
parts = line.split()
out.append(
{
"next": parts[0] + " " + parts[1]
if len(parts) > 2
else parts[0],
"unit": next((p for p in parts if ".timer" in p), "?"),
}
)
break
except Exception:
pass
try:
cron = subprocess.run(
["crontab", "-l"], capture_output=True, text=True, timeout=10
).stdout
for line in cron.splitlines():
if (
line.strip()
and not line.startswith("#")
and any(k in line for k in ("p0lytics", "plane-sync", "valkompass"))
):
out.append({"next": "cron", "unit": line[:110]})
except Exception:
pass
return out
def _proxy():
try:
d = json.load(open(os.path.join(SF, "proxies/pool.json")))
recs = d.get("records", [])
now = time.time()
fresh = sum(
1
for r in recs
if r.get("lastChecked") and now - _ts(r["lastChecked"]) < 3600
)
healthy = sum(1 for r in recs if (r.get("consecutiveFailures") or 0) == 0)
lat = [r["latencyMsEwma"] for r in recs if r.get("latencyMsEwma")]
lat.sort()
countries = Counter(r.get("country", "?") for r in recs).most_common(5)
return {
"total": len(recs),
"fresh_1h": fresh,
"healthy": healthy,
"lat_p50": round(lat[len(lat) // 2]) if lat else -1,
"top_countries": countries,
}
except Exception as e:
return {"error": str(e)[:100]}
def _ts(iso):
try:
return (
time.mktime(time.strptime(iso[:19], "%Y-%m-%dT%H:%M:%S"))
- time.altzone * 0
+ (7200 if iso.endswith("Z") else 0)
)
except Exception:
return 0
def _wc(path):
try:
with open(path, "rb") as f:
return sum(1 for _ in f)
except Exception:
return -1
def _pipeline():
stages = {}
b = os.path.join(SF, "full_data/biluppgifter")
stages["biluppgifter frontier (väntar)"] = _wc(os.path.join(b, "frontier.txt"))
stages["biluppgifter crawled"] = _wc(os.path.join(b, "crawled.txt"))
stages["biluppgifter prio-kö"] = _wc(os.path.join(b, "pending_priority.txt"))
m = os.path.join(SF, "full_data/multi")
try:
for src in sorted(os.listdir(m)):
cp = os.path.join(m, src, "crawled.txt")
if os.path.exists(cp):
stages[f"multi/{src} crawled"] = _wc(cp)
except Exception:
pass
return stages
def ops():
if time.time() - CACHE["ts"] < 300:
return CACHE["data"]
runs = {u: _runs(u) for u in UNITS}
data = {
"runs": runs,
"schedules": _schedules(),
"proxy": _proxy(),
"pipeline": _pipeline(),
"alerts": [],
"disk_free_gb": 0,
}
try:
data["disk_free_gb"] = round(shutil.disk_usage("/").free / 1e9, 1)
except Exception:
pass
A = data["alerts"]
if data["disk_free_gb"] and data["disk_free_gb"] < 6:
A.append(
{
"level": "crit",
"text": f"Disken nästan full: {data['disk_free_gb']} GB fritt",
}
)
try:
clog = (
open(os.path.join(CI, "crawl.log"), errors="replace")
.read()
.splitlines()[-3:]
)
if clog and all("cooldown" in l for l in clog):
A.append(
{
"level": "warn",
"text": "car.info stampar i cooldown-loop (tunna modeller)",
}
)
except Exception:
pass
try:
if "not found" in open("/tmp/p0lytics-health.log", errors="replace").read():
A.append(
{
"level": "err",
"text": "p0lytics-cron trasig (.venv saknas) — ingen health-data",
}
)
except Exception:
pass
CACHE.update(ts=time.time(), data=data)
return data
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Scraper-detaljsidor: /scraper/<id> - spec + live-data per scraper."""
SCRAPERS = {
"sf": {
"label": "SF multi-scrape", "unit": "sf-multi-scrape.service",
"desc": "Multi-source vehicle scraper: biluppgifter/fordonskollen via pool, fordonssok/car.info direkt",
"cmd": "Documents/SF_Extractor/.venv/bin/python scrape_sources.py",
"sources": ["biluppgifter", "fordonskollen", "fordonssok", "carinfo"],
"spec": [
("Källor", "biluppgifter.se, fordonskollen.se, fordonssok.se, car.info (sv-se)"),
("Indata", "scrape_urls/*.txt (14,5M regnr, sitemap-enumererade)"),
("Utdata", "spool/*.jsonl.gz (rå HTML) + full_data/*/records-*.jsonl.gz (extraherat)"),
("Takt", "~4 parallella per domän; biluppgifter via pool (CF IP-block)"),
("Drift", "systemd always-restart (startar om efter stop-timeout, återupptar)"),
("Logg", "Documents/SF_Extractor/logs/multi_scrape.log — [källa] n= ok= fail= @pos/total"),
],
},
"carinfo": {
"label": "car.info specs", "unit": "carinfo-crawl.service",
"desc": "car.info spec crawler, resumable (vehiq VEH-HIST-030)",
"cmd": "/usr/bin/python3 ~/.local/share/carinfo/crawl.py",
"sources": ["car.info"],
"spec": [
("Källa", "car.info modell/generation/motorvarianter (en-se)"),
("Utdata", "~/.local/share/carinfo/trims.jsonl {url, make, model, specs}"),
("Checkpoint", "ckpt.json (makes_done, models_done, gens_done) — återupptar"),
("Drift", "systemd; cooldown-loop vid tunna modeller"),
("Logg", "~/.local/share/carinfo/crawl.log"),
],
},
"enrich": {
"label": "SIAX enrich", "unit": "siax-enrich.service",
"desc": "Job Description Enricher (LinkedIn jobbsidor)",
"cmd": "job-automation/deploy/scanner/enrich-descriptions-laptop.py",
"sources": ["linkedin"],
"spec": [
("Källa", "LinkedIn jobbsidor"),
("Utdata", "ingen lokal resultatfil (fjeder/nätverk) — endast status synlig"),
("Drift", "systemd"),
],
},
"scanner": {
"label": "SIAX laptop-scanner", "unit": "siax-laptop-scanner.service",
"desc": "Laptop Job Scanner (residential IP, pushar till server6)",
"cmd": "job-automation/deploy/scanner/laptop-scanner.py",
"sources": ["jobbsajter"],
"spec": [
("Källa", "jobbsajter via residential IP"),
("Utdata", "pushas till server6 — ingen lokal resultatfil"),
("Drift", "systemd"),
],
},
}
DETAIL = """<!DOCTYPE html><html lang="sv"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Scrape Console · __LABEL__</title>
<style>body{background:#0d1117;color:#e6edf3;font-family:system-ui,sans-serif;margin:0;padding:16px}
a{color:#58a6ff}h1{font-size:20px;margin:0 0 4px}h2{font-size:15px;margin:16px 0 6px}
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:12px;margin-bottom:12px}
table{border-collapse:collapse;width:100%;font-size:12px}th,td{border:1px solid #30363d;padding:5px 8px;text-align:left;vertical-align:top}
th{background:#0d1117;width:140px;color:#8b949e}
.badge{display:inline-block;font-size:11px;padding:2px 8px;border-radius:10px}
.on{background:#1a7f37}.off{background:#8c1515}
.kv{font-size:12px;color:#8b949e;display:flex;gap:12px;flex-wrap:wrap}.kv b{color:#e6edf3}
.bar{background:#30363d;border-radius:4px;height:8px;margin:4px 0 8px}.bar>i{display:block;height:8px;border-radius:4px;background:#1f6feb}
.src{font-size:12px;margin:2px 0}.src b{color:#e6edf3}
pre{background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:8px;font-size:11px;overflow:auto;max-height:200px;white-space:pre-wrap}
button{background:#21262d;color:#e6edf3;border:1px solid #30363d;border-radius:6px;padding:4px 10px;margin-right:4px;cursor:pointer;font-size:12px}</style></head>
<body><h1>🕷️ <a href="/">Scrape Console</a> · <span id="t"></span></h1>
<div class="card"><span id="badge"></span> <span id="ctl"></span><div class="kv" id="kv"></div><div id="prog"></div></div>
<div class="card"><h2>Specifikation</h2><table id="spec"></table><div class="kv" id="cmd" style="margin-top:8px"></div></div>
<div class="card"><h2>Dataset</h2><div id="ds"></div></div>
<div class="card"><h2>Logg</h2><pre id="log"></pre></div>
<div class="card"><h2>Körningar (runs-historik, 3 dygn)</h2><div id="runs"></div></div>
<div class="card" id="pipeCard" style="display:none"><h2>Pipeline-steg</h2><div id="pipe"></div></div>
<div class="card" id="proxyCard" style="display:none"><h2>Proxy-pool (IP-rotation)</h2><div id="proxy"></div></div>
<div class="card"><h2>Schema</h2><div id="sched"></div></div>
<script>
const ID = "__ID__", LOCAL = ['localhost','127.0.0.1'].includes(location.hostname);
async function tick(){
const s = await (await fetch('/state')).json();
const c = s.scrapers.find(x=>x.unit=== "__UNIT__");
const meta = await (await fetch('/scraper-meta?id='+ID)).json();
document.title = 'Scrape Console · ' + meta.label;
document.getElementById('t').textContent = meta.label;
const on = c && c.active==='active';
document.getElementById('badge').innerHTML = `<span class="badge ${on?'on':'off'}">${c?c.active+' · pid '+c.pid:'okänd'}</span>`;
document.getElementById('ctl').innerHTML = LOCAL
? `<button onclick="ctl('restart')">↻ Omstart</button><button onclick="ctl('stop')">⏹ Stopp</button><button onclick="ctl('start')">▶ Start</button><a href="/data-page?scraper=${meta.key}"><button>Alla data →</button></a>`
: `<a href="/data-page?scraper=${meta.key}"><button>Alla data →</button></a>`;
document.getElementById('kv').innerHTML = c ? `<span>minne <b>${c.mem_mb} MB</b></span><span>cpu <b>${c.cpu_s}s</b></span><span>start <b>${c.started||'-'}</b></span>` : '';
let ph = '';
if (c && c.progress) for (const [k,v] of Object.entries(c.progress)) {
if (k.startsWith('_')) continue;
const pct = 100*v.pos/v.total, okr = (100*v.ok/Math.max(1,v.ok+v.fail)).toFixed(1);
ph += `<div class="src"><b>${k}</b> ${v.ok} ok / ${v.fail} fail (ok-rate ${okr}%) · ${v.pos}/${v.total} (${pct.toFixed(2)}%)</div><div class="bar"><i style="width:${Math.min(100,pct)}%"></i></div>`;
}
if (c && c.ckpt) ph += `<div class="src">checkpoint: <b>${Object.entries(c.ckpt).map(([k,v])=>k+'='+v).join(' · ')}</b></div>`;
document.getElementById('prog').innerHTML = ph;
document.getElementById('spec').innerHTML = meta.spec.map(([k,v])=>`<tr><th>${k}</th><td>${v}</td></tr>`).join('');
document.getElementById('cmd').innerHTML = `<span>kommando: <b>${meta.cmd}</b></span><span>${meta.desc}</span>`;
const ds = await (await fetch('/datasets')).json();
document.getElementById('ds').innerHTML = ds.filter(d=>d.scraper===meta.key).map(d=>`<div class="src"><b>${d.label}</b> — ${d.rows} rader · <a href="/data-page?ds=${d.id}">öppna tabell →</a></div>`).join('') || '<div class="kv">inga lokala dataset (statusläge)</div>';
document.getElementById('log').textContent = (c && c.log) || '(ingen logg)';
const ops = await (await fetch('/ops')).json();
const runs = (ops.runs && ops.runs["__UNIT__"]) || [];
document.getElementById('runs').innerHTML = runs.length ? runs.slice().reverse().map(r =>
`<div class="src"><b>${r.day||''} ${r.start} → ${r.end}</b> · ${r.result}</div>`).join('')
: '<div class="kv">inga journalförda starter senaste 3 dygnen</div>';
document.getElementById('sched').innerHTML = (ops.schedules||[]).map(s =>
`<div class="src">nästa <b>${s.next}</b> · ${String(s.unit).slice(0,80)}</div>`).join('') || '<div class="kv">inga scheman</div>';
if (ID === 'sf') {
document.getElementById('pipeCard').style.display = 'block';
document.getElementById('proxyCard').style.display = 'block';
document.getElementById('pipe').innerHTML = Object.entries(ops.pipeline||{}).map(([k,v]) =>
`<div class="src">${k}: <b>${v}</b></div>`).join('');
const px = ops.proxy||{};
document.getElementById('proxy').innerHTML = px.error ? px.error :
`<div class="kv"><span>proxies <b>${px.total}</b></span><span>fräscha &lt;1h <b>${px.fresh_1h}</b></span><span>friska <b>${px.healthy}</b></span><span>latens p50 <b>${px.lat_p50} ms</b></span></div>
<div class="src">topp-länder: <b>${(px.top_countries||[]).map(([c,n])=>c+' '+n).join(' · ')}</b></div>`;
}
}
async function ctl(action){
if(!confirm(action+'?')) return;
const r = await fetch('/control',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({unit:"__UNIT__",action})});
alert(await r.text()); tick();
}
tick(); setInterval(tick, 10000);
</script></body></html>"""
+406
View File
@@ -0,0 +1,406 @@
#!/usr/bin/env python3
"""Scrape Console v2: live-översikt + historik + kontroller (localhost-only).
GET / /state /history /log?src=multi&n=50 · POST /control (127.0.0.1 only)."""
import json, os, re, shutil, subprocess, time
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
import data_views
import dash_views
import scraper_views
import ops_views
HOME = os.path.expanduser("~")
SF = os.path.join(HOME, "Documents/SF_Extractor")
CI = os.path.join(HOME, ".local/share/carinfo")
HIST = os.path.join(HOME, ".local/share/scrape-console/history.jsonl")
CACHE = {"ts": 0, "data": None}
def sys_show(unit, *props):
try:
out = subprocess.run(
["systemctl", "--user", "show", unit, "-p", ",".join(props)],
capture_output=True,
text=True,
timeout=10,
).stdout
d = {}
for line in out.splitlines():
if "=" in line:
k, v = line.split("=", 1)
d[k] = v
return d
except Exception:
return {}
def svc(unit, label, desc):
p = sys_show(
unit,
"ActiveState",
"MainPID",
"MemoryCurrent",
"CPUUsageNSec",
"ExecMainStartTimestamp",
)
mem = int(p.get("MemoryCurrent") or 0)
return {
"unit": unit,
"label": label,
"desc": desc,
"active": p.get("ActiveState", "?"),
"pid": p.get("MainPID", "?"),
"mem_mb": round(mem / 1024 / 1024, 1),
"cpu_s": round(int(p.get("CPUUsageNSec") or 0) / 1e9, 1),
"started": (p.get("ExecMainStartTimestamp") or "")[4:16],
}
def tail(path, n=8):
try:
with open(path, errors="replace") as f:
return "".join(f.readlines()[-n:])
except Exception as e:
return f"(kan inte läsa {path}: {e})"
def sf_progress():
out = {}
try:
with open(os.path.join(SF, "logs/multi_scrape.log"), errors="replace") as f:
for line in f:
m = re.match(
r"\[(.+?)\]\s+n=(\d+)\s+ok=(\d+)\s+fail=(\d+)\s+@(\d+)/(\d+)",
line.strip(),
)
if m:
src, n, ok, fail, pos, total = m.groups()
out[src] = {
"n": int(n),
"ok": int(ok),
"fail": int(fail),
"pos": int(pos),
"total": int(total),
}
except Exception as e:
out["_error"] = str(e)
return out
def du(path):
try:
return round(
sum(
os.path.getsize(os.path.join(dp, f))
for dp, _, fns in os.walk(path)
for f in fns
)
/ 1024
/ 1024,
1,
)
except Exception:
return -1
def collect():
try:
d = json.load(open(os.path.join(CI, "ckpt.json")))
ci_ckpt = {k: (len(v) if isinstance(v, list) else v) for k, v in d.items()}
except Exception as e:
ci_ckpt = {"_error": str(e)[:100]}
try:
spool = len(os.listdir(os.path.join(SF, "spool")))
except Exception:
spool = -1
prog = sf_progress()
mem = {
s["unit"]: s["mem_mb"]
for s in [
svc("sf-multi-scrape.service", "", ""),
svc("carinfo-crawl.service", "", ""),
]
}
data = {
"ts": time.strftime("%H:%M:%S"),
"scrapers": [
dict(
svc(
"sf-multi-scrape.service",
"SF multi-scrape",
"fordon: biluppgifter/fordonskollen/fordonssok/car.info",
),
progress=prog,
spool_files=spool,
data_mb={"full_data": du(os.path.join(SF, "full_data"))},
log=tail(os.path.join(SF, "logs/multi_scrape.log"), 6),
),
dict(
svc(
"carinfo-crawl.service", "car.info specs", "VEH-HIST-030, resumable"
),
ckpt=ci_ckpt,
log=tail(os.path.join(CI, "crawl.log"), 6),
),
dict(svc("siax-enrich.service", "SIAX enrich", "LinkedIn jobbsidor")),
dict(
svc(
"siax-laptop-scanner.service",
"SIAX laptop-scanner",
"jobb, residential IP",
)
),
],
"cron": {
"p0lytics_health": tail("/tmp/p0lytics-health.log", 2).strip(),
"p0lytics_rate": tail("/tmp/p0lytics-rate.log", 2).strip(),
},
}
# historik (1 prov/min)
try:
last = 0
if os.path.exists(HIST):
with open(HIST, errors="replace") as f:
for line in f:
pass
try:
last = json.loads(line).get("t", 0)
except Exception:
pass
now = int(time.time())
if now - last >= 55:
snap = {
"t": now,
"ok": {k: v["ok"] for k, v in prog.items() if not k.startswith("_")},
"mem": mem,
}
with open(HIST, "a") as f:
f.write(json.dumps(snap) + "\n")
# kapa till ~1500 rader
with open(HIST, errors="replace") as f:
lines = f.readlines()
if len(lines) > 1500:
open(HIST, "w").writelines(lines[-1500:])
except Exception:
pass
return data
HTML = """<!DOCTYPE html><html lang="sv"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Scrape Console live</title>
<style>body{background:#0d1117;color:#e6edf3;font-family:system-ui,sans-serif;margin:0;padding:16px}
h1{font-size:20px;margin:0 0 4px}#ts{color:#8b949e;font-size:12px;margin-bottom:12px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:12px}
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:12px}
.card h2{font-size:15px;margin:0 0 2px}.desc{color:#8b949e;font-size:12px;margin-bottom:8px}
.badge{display:inline-block;font-size:11px;padding:2px 8px;border-radius:10px;margin:0 4px 8px 0}
.on{background:#1a7f37}.off{background:#8c1515}.qq{background:#9e6a03}
.kv{font-size:12px;color:#8b949e;display:flex;gap:12px;flex-wrap:wrap;margin-bottom:6px}
.kv b{color:#e6edf3}.bar{background:#30363d;border-radius:4px;height:8px;margin:4px 0 8px}.bar>i{display:block;height:8px;border-radius:4px;background:#1f6feb}
.src{font-size:12px;margin:2px 0}.src b{color:#e6edf3}
pre{background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:8px;font-size:11px;overflow:auto;max-height:150px;white-space:pre-wrap}
button{background:#21262d;color:#e6edf3;border:1px solid #30363d;border-radius:6px;padding:4px 10px;margin:0 4px 4px 0;cursor:pointer;font-size:12px}
button:hover{background:#30363d}svg{width:100%;height:44px;margin:2px 0 6px}</style></head>
<body><h1>🕷️ Scrape Console <span style="font-size:12px;color:#8b949e">live</span> <a href="/data-page" style="font-size:13px">Alla data →</a> <a href="/dash" style="font-size:13px">Min dashboard →</a></h1>
<div id="ts"></div><div id="alerts" style="margin-bottom:12px"></div><div class="grid" id="g"></div>
<script>
const LOCAL = ['localhost','127.0.0.1'].includes(location.hostname);
let hist = [];
let HISTG = [];
async function tick(){
const s = await (await fetch('/state')).json();
try { HISTG = await (await fetch('/history')).json(); } catch(e) {}
try {
const ops = await (await fetch('/ops')).json();
const col = {crit:'#8c1515', err:'#8c1515', warn:'#9e6a03'};
document.getElementById('alerts').innerHTML = (ops.alerts||[]).map(x =>
`<div class="card" style="border-left:4px solid ${col[x.level]||'#666'};margin-bottom:8px"><b>${x.level.toUpperCase()}</b> · ${x.text}</div>`).join('');
} catch(e) {}
document.getElementById('ts').textContent = 'Uppdaterad ' + s.ts + ' (auto 10 s)' + (LOCAL ? '' : ' · skrivskyddad vy');
hist = await (await fetch('/history')).json();
const g = document.getElementById('g'); g.innerHTML = '';
for (const c of s.scrapers) {
const on = c.active === 'active';
let h = `<div class="card"><span class="badge ${on?'on':'off'}">${c.active} · pid ${c.pid}</span>`;
if (LOCAL) h += `<button onclick="ctl('${c.unit}','restart')">↻</button><button onclick="ctl('${c.unit}','stop')">⏹</button><button onclick="ctl('${c.unit}','start')">▶</button>`;
const SCR0 = {'SF multi-scrape':'sf','car.info specs':'carinfo','SIAX enrich':'enrich','SIAX laptop-scanner':'scanner'};
const sid0 = SCR0[c.label] || '';
h += `<h2><a href="/scraper/${sid0}" style="color:#e6edf3">${c.label}</a></h2><div class="desc">${c.desc}<br>${c.unit}</div><div class="kv"><span>minne <b>${c.mem_mb} MB</b></span><span>cpu <b>${c.cpu_s}s</b></span><span>start <b>${c.started||'-'}</b></span></div>`;
if (sid0) h += `<div class="src"><a href="/data-page?scraper=${sid0}">Visa all data →</a></div>`;
if (c.progress) for (const [k,v] of Object.entries(c.progress)) {
if (k.startsWith('_')) continue;
const pct = (100*v.pos/v.total);
const okr = (100*v.ok/Math.max(1,v.ok+v.fail)).toFixed(1);
const eta = etaStr(k, v);
h += `<div class="src"><b>${k}</b> ${v.ok} ok / ${v.fail} fail (ok-rate ${okr}%) · ${v.pos}/${v.total} (${pct.toFixed(2)}%)${eta}</div><div class="bar"><i style="width:${Math.min(100,pct)}%"></i></div>`;
h += spark(k);
}
if (c.spool_files !== undefined) h += `<div class="src">spool-filer: <b>${c.spool_files}</b> · full_data: <b>${c.data_mb.full_data} MB</b></div>`;
if (c.ckpt) h += `<div class="src">checkpoint: <b>${Object.entries(c.ckpt).map(([k,v])=>k+'='+v).join(' · ')}</b></div>`;
if (c.log) h += `<pre>${c.log.replace(/</g,'&lt;')}</pre>`;
g.innerHTML += h + '</div>';
}
g.innerHTML += `<div class="card"><span class="badge qq">cron</span><h2>p0lytics monitors</h2><div class="desc">health var 15:e min · rate var 30:e</div><pre>health: ${s.cron.p0lytics_health.replace(/</g,'&lt;')}\nrate: ${s.cron.p0lytics_rate.replace(/</g,'&lt;')}</pre></div>`;
}
function etaStr(src, v){
const pts = HISTG.filter(h=>h.ok && h.ok[src]!==undefined).slice(-12);
if(pts.length < 2) return '';
const dv = pts[pts.length-1].ok[src]-pts[0].ok[src], dt = (pts[pts.length-1].t-pts[0].t)/60;
if(dv <= 0 || dt <= 0) return '';
const left = (v.total-v.pos)/(dv/dt), h = Math.floor(left/60), m = Math.round(left%60);
return ` · ETA ${h>0?h+'h ':''}${m}m`;
}
function spark(src){
const pts = hist.filter(h=>h.ok && h.ok[src]!==undefined).slice(-60);
if (pts.length < 2) return '';
const vals = pts.map(h=>h.ok[src]); const max = Math.max(...vals), min = Math.min(...vals);
const W=300,H=40, path = vals.map((v,i)=>`${(i/(vals.length-1)*W).toFixed(1)},${(H-2-((v-min)/Math.max(1,max-min))*(H-4)).toFixed(1)}`).join(' ');
const rate = ((vals[vals.length-1]-vals[0])/Math.max(1,(pts[pts.length-1].t-pts[0].t)/60)).toFixed(0);
return `<svg viewBox="0 0 ${W} ${H}"><polyline points="${path}" fill="none" stroke="#1f6feb" stroke-width="1.5"/></svg><div class="src">takt: <b>${rate}/min</b> senaste timmen</div>`;
}
async function ctl(unit,action){
if(!confirm(action+' '+unit+'?')) return;
const r = await fetch('/control',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({unit,action})});
alert(await r.text()); tick();
}
tick(); setInterval(tick, 10000);
</script></body></html>"""
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _send(self, body, ctype):
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
u = urlparse(self.path)
if u.path == "/state":
if time.time() - CACHE["ts"] > 10:
CACHE["data"] = collect()
CACHE["ts"] = time.time()
self._send(json.dumps(CACHE["data"]).encode(), "application/json")
elif u.path == "/history":
try:
with open(HIST, errors="replace") as f:
lines = f.readlines()
data = [json.loads(l) for l in lines[-90:] if l.strip()]
except Exception:
data = []
self._send(json.dumps(data).encode(), "application/json")
elif u.path == "/log":
q = parse_qs(u.query)
src = q.get("src", ["multi"])[0]
n = min(int(q.get("n", ["50"])[0]), 200)
path = {
"multi": os.path.join(SF, "logs/multi_scrape.log"),
"carinfo": os.path.join(CI, "crawl.log"),
}.get(src, "")
self._send(tail(path, n).encode(), "text/plain; charset=utf-8")
elif u.path == "/data-page":
self._send(data_views.PAGE.encode(), "text/html; charset=utf-8")
elif u.path == "/dash":
self._send(dash_views.DASH_PAGE.encode(), "text/html; charset=utf-8")
elif u.path == "/datasets":
counts = data_views.counts()
out = [
{
"id": d["id"],
"label": d["label"],
"scraper": data_views.SCRAPER_OF.get(d["id"], ""),
"rows": counts.get(d["id"], {}).get("rows", -1),
}
for d in data_views.DATASETS
]
self._send(json.dumps(out).encode(), "application/json")
elif u.path == "/data":
q = parse_qs(u.query)
res = data_views.query(
q.get("ds", [""])[0],
q.get("q", [""])[0],
int(q.get("page", ["1"])[0]),
sort=q.get("sort", [None])[0],
sdir=q.get("dir", ["asc"])[0],
)
self._send(json.dumps(res, ensure_ascii=False).encode(), "application/json")
elif u.path == "/record":
q = parse_qs(u.query)
res = data_views.record_at(
q.get("ds", [""])[0], int(q.get("n", ["0"])[0]), q.get("q", [""])[0]
)
self._send(json.dumps(res, ensure_ascii=False).encode(), "application/json")
elif u.path == "/export":
q = parse_qs(u.query)
body, ctype, fname = data_views.export(
q.get("ds", [""])[0], q.get("q", [""])[0], q.get("fmt", ["csv"])[0]
)
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Disposition", "attachment; filename=" + fname)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
elif u.path.startswith("/scraper/"):
sid = u.path.split("/scraper/")[-1].split("?")[0]
sc = scraper_views.SCRAPERS.get(sid)
if not sc:
self.send_response(404); self.end_headers(); return
html = scraper_views.DETAIL.replace("__ID__", sid).replace(
"__UNIT__", sc["unit"]).replace("__LABEL__", sc["label"])
self._send(html.encode(), "text/html; charset=utf-8")
elif u.path == "/scraper-meta":
q = parse_qs(u.query)
sid = q.get("id", [""])[0]
sc = scraper_views.SCRAPERS.get(sid, {})
self._send(json.dumps({"key": sid, "label": sc.get("label", sid),
"desc": sc.get("desc", ""), "cmd": sc.get("cmd", ""),
"spec": sc.get("spec", [])}).encode(),
"application/json")
elif u.path == "/ops":
self._send(json.dumps(ops_views.ops()).encode(), "application/json")
else:
self._send(HTML.encode(), "text/html; charset=utf-8")
def do_POST(self):
if self.path != "/control":
self.send_response(404)
self.end_headers()
return
if self.client_address[0] not in ("127.0.0.1", "::1"):
self.send_response(403)
self.end_headers()
self.wfile.write(b"localhost only")
return
try:
d = json.loads(self.rfile.read(int(self.headers["Content-Length"] or 0)))
unit, action = d["unit"], d["action"]
assert re.match(r"^[a-z0-9@._-]+\.service$", unit) and action in (
"start",
"stop",
"restart",
)
r = subprocess.run(
["systemctl", "--user", action, unit],
capture_output=True,
text=True,
timeout=60,
)
msg = f"{action} {unit}: {'OK' if r.returncode == 0 else r.stderr[:200]}"
except Exception as e:
msg = f"fel: {e}"
body = msg.encode()
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
if __name__ == "__main__":
HTTPServer(("127.0.0.1", 8899), H).serve_forever()