Initial commit: SIAX Scrape Console (migrated from laptop)
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.bak
|
||||
*.bak-*
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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,'<')}</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,'<')}</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,'<')}</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
@@ -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,'<')}</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,'<').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
File diff suppressed because it is too large
Load Diff
+215
@@ -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
|
||||
@@ -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 <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>"""
|
||||
@@ -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,'<')}</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,'<')}\nrate: ${s.cron.p0lytics_rate.replace(/</g,'<')}</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()
|
||||
Reference in New Issue
Block a user