feat: initial domain hunter panel (dynamic DB-backed)
This commit is contained in:
commit
71d7bb8bf2
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
data/domainhunter.db
|
||||||
|
data/latest-domains.json
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
25
README.md
Normal file
25
README.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Domain Hunter Panel
|
||||||
|
|
||||||
|
Dynamiczny panel do monitoringu wolnych domen i porównania kosztów rejestracji/odnowień.
|
||||||
|
|
||||||
|
## Co robi
|
||||||
|
- Pobiera najnowsze wyniki skanu domen z Mongo (`aiagent.domain_scans`)
|
||||||
|
- Trzyma dane panelu w SQLite (`data/domainhunter.db`)
|
||||||
|
- Udostępnia API (`api.php`) dla frontendu
|
||||||
|
- Pozwala edytować ceny rejestratorów i zapisuje je po stronie serwera
|
||||||
|
- Wspiera autopobieranie cen (heurystyki)
|
||||||
|
|
||||||
|
## Pliki
|
||||||
|
- `index.html` — frontend panelu
|
||||||
|
- `api.php` — backend API
|
||||||
|
- `init_db.py` — inicjalizacja SQLite
|
||||||
|
- `refresh_domain_data.py` — sync Mongo -> SQLite
|
||||||
|
- `update_registrar_prices.py` — auto-update cen
|
||||||
|
- `refresh_and_publish.sh` — pełny refresh + publikacja
|
||||||
|
|
||||||
|
## Szybki start
|
||||||
|
```bash
|
||||||
|
python3 init_db.py
|
||||||
|
python3 update_registrar_prices.py || true
|
||||||
|
python3 refresh_domain_data.py
|
||||||
|
```
|
||||||
93
api.php
Normal file
93
api.php
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$dbPath = __DIR__ . '/data/domainhunter.db';
|
||||||
|
if (!file_exists($dbPath)) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'DB not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = new SQLite3($dbPath, SQLITE3_OPEN_READWRITE);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'DB open failed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = $_GET['action'] ?? 'domains';
|
||||||
|
|
||||||
|
if ($action === 'domains') {
|
||||||
|
$q = trim($_GET['q'] ?? '');
|
||||||
|
$tld = trim($_GET['tld'] ?? '');
|
||||||
|
|
||||||
|
$runId = $db->querySingle("SELECT value FROM metadata WHERE key='latest_run_id'");
|
||||||
|
$scannedAt = $db->querySingle("SELECT value FROM metadata WHERE key='latest_scanned_at'");
|
||||||
|
|
||||||
|
$sql = "SELECT domain, tld, score, status, keywords_json FROM domains WHERE run_id = :run";
|
||||||
|
if ($q !== '') $sql .= " AND domain LIKE :q";
|
||||||
|
if ($tld !== '') $sql .= " AND tld = :tld";
|
||||||
|
$sql .= " ORDER BY score DESC, domain ASC LIMIT 500";
|
||||||
|
|
||||||
|
$st = $db->prepare($sql);
|
||||||
|
$st->bindValue(':run', $runId, SQLITE3_TEXT);
|
||||||
|
if ($q !== '') $st->bindValue(':q', '%' . $q . '%', SQLITE3_TEXT);
|
||||||
|
if ($tld !== '') $st->bindValue(':tld', $tld, SQLITE3_TEXT);
|
||||||
|
|
||||||
|
$res = $st->execute();
|
||||||
|
$rows = [];
|
||||||
|
while ($r = $res->fetchArray(SQLITE3_ASSOC)) {
|
||||||
|
$r['keywords'] = json_decode($r['keywords_json'] ?: '[]', true) ?: [];
|
||||||
|
unset($r['keywords_json']);
|
||||||
|
$rows[] = $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => true,
|
||||||
|
'meta' => [
|
||||||
|
'runId' => $runId,
|
||||||
|
'scannedAt' => $scannedAt,
|
||||||
|
'count' => count($rows)
|
||||||
|
],
|
||||||
|
'domains' => $rows,
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'prices') {
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$raw = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$registrar = trim($raw['registrar'] ?? '');
|
||||||
|
$tld = trim($raw['tld'] ?? '');
|
||||||
|
$registerPrice = $raw['register_price'] ?? null;
|
||||||
|
$renewPrice = $raw['renew_price'] ?? null;
|
||||||
|
|
||||||
|
if ($registrar === '' || $tld === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'registrar/tld required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$st = $db->prepare("UPDATE registrar_prices SET register_price=:rp, renew_price=:rr, updated_at=datetime('now') WHERE registrar=:r AND tld=:t");
|
||||||
|
if ($registerPrice === null || $registerPrice === '') $st->bindValue(':rp', null, SQLITE3_NULL);
|
||||||
|
else $st->bindValue(':rp', floatval($registerPrice), SQLITE3_FLOAT);
|
||||||
|
if ($renewPrice === null || $renewPrice === '') $st->bindValue(':rr', null, SQLITE3_NULL);
|
||||||
|
else $st->bindValue(':rr', floatval($renewPrice), SQLITE3_FLOAT);
|
||||||
|
$st->bindValue(':r', $registrar, SQLITE3_TEXT);
|
||||||
|
$st->bindValue(':t', $tld, SQLITE3_TEXT);
|
||||||
|
$st->execute();
|
||||||
|
|
||||||
|
echo json_encode(['ok' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$res = $db->query("SELECT registrar, url, tld, register_price, renew_price, updated_at FROM registrar_prices ORDER BY registrar, tld");
|
||||||
|
$items = [];
|
||||||
|
while ($r = $res->fetchArray(SQLITE3_ASSOC)) $items[] = $r;
|
||||||
|
echo json_encode(['ok' => true, 'items' => $items], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'unknown action']);
|
||||||
338
data/registrars.json
Normal file
338
data/registrars.json
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
{
|
||||||
|
"note": "Ceny orientacyjne + auto-pobieranie (heurystyka z publicznych cenników). Zawsze weryfikuj przed zakupem.",
|
||||||
|
"updatedAt": "2026-02-17",
|
||||||
|
"registrars": [
|
||||||
|
{
|
||||||
|
"name": "OVH",
|
||||||
|
"url": "https://www.ovhcloud.com/pl/domains/",
|
||||||
|
"pricing": {
|
||||||
|
"pl": {
|
||||||
|
"register": 20.69,
|
||||||
|
"renew": 20.69
|
||||||
|
},
|
||||||
|
"com": {
|
||||||
|
"register": 34.08,
|
||||||
|
"renew": 18.27
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"register": 306.69,
|
||||||
|
"renew": 306.69
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoPricing": {
|
||||||
|
"pl": {
|
||||||
|
"url": "https://www.ovhcloud.com/pl/domains/tld/pl/"
|
||||||
|
},
|
||||||
|
"com": {
|
||||||
|
"url": "https://www.ovhcloud.com/pl/domains/tld/com/"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"url": "https://www.ovhcloud.com/pl/domains/tld/ai/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoMeta": {
|
||||||
|
"pl": {
|
||||||
|
"status": "ok",
|
||||||
|
"checkedAt": "2026-02-17T21:40:45.836487Z",
|
||||||
|
"url": "https://www.ovhcloud.com/pl/domains/tld/pl/",
|
||||||
|
"registerFound": 20.69,
|
||||||
|
"renewFound": 20.69,
|
||||||
|
"registerContext": "okumentacja roadmap & changelog sprawdź, czy usługa jest dostępna w twojej lokalizacji pełna gama usług fibre połącz kilka ścieżek dostępu z otb dokumentacja roadmap & changelog domena .pl domena wszystkie rozsze",
|
||||||
|
"renewContext": "y usługa jest dostępna w twojej lokalizacji pełna gama usług fibre połącz kilka ścieżek dostępu z otb dokumentacja roadmap & changelog domena .pl domena wszystkie rozszerzenia domen domena .pl zarezerwuj twoją domenę"
|
||||||
|
},
|
||||||
|
"com": {
|
||||||
|
"status": "ok",
|
||||||
|
"checkedAt": "2026-02-17T21:40:45.836487Z",
|
||||||
|
"url": "https://www.ovhcloud.com/pl/domains/tld/com/",
|
||||||
|
"registerFound": 34.08,
|
||||||
|
"renewFound": 18.27,
|
||||||
|
"registerContext": "ata odnowienie na 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 lata czas rejestracji natychmiastowy liczba znaków 2 do 63 znaki obsługa idn (znaki międzynarodowe) tak dnssec obsługiwane tak cena (1. rok) 34,08 pln netto 41,92 pln brutt",
|
||||||
|
"renewContext": "gelog sprawdź, czy usługa jest dostępna w twojej lokalizacji pełna gama usług fibre połącz kilka ścieżek dostępu z otb dokumentacja roadmap & changelog domena .com domena wszystkie rozszerzenia domen domena .com zare"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"status": "ok",
|
||||||
|
"checkedAt": "2026-02-17T21:40:45.836487Z",
|
||||||
|
"url": "https://www.ovhcloud.com/pl/domains/tld/ai/",
|
||||||
|
"registerFound": 306.69,
|
||||||
|
"renewFound": 306.69,
|
||||||
|
"registerContext": "p; changelog sprawdź, czy usługa jest dostępna w twojej lokalizacji pełna gama usług fibre połącz kilka ścieżek dostępu z otb dokumentacja roadmap & changelog domena .ai domena wszystkie rozszerzenia domen domena .ai",
|
||||||
|
"renewContext": "i zarejestruj domenę .ai domena .ai jest powiązana z tematyką sztucznej inteligencji. jest to również oficjalne rozszerzenie wyspy anguilla. może zostać zarejestrowane przez dowolnego użytkownika na minimalny okres wynos"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nazwa.pl",
|
||||||
|
"url": "https://www.nazwa.pl/domeny/",
|
||||||
|
"pricing": {
|
||||||
|
"pl": {
|
||||||
|
"register": null,
|
||||||
|
"renew": null
|
||||||
|
},
|
||||||
|
"com": {
|
||||||
|
"register": null,
|
||||||
|
"renew": null
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"register": null,
|
||||||
|
"renew": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoPricing": {
|
||||||
|
"pl": {
|
||||||
|
"url": "https://www.nazwa.pl/domeny/rejestracja-domeny-pl/"
|
||||||
|
},
|
||||||
|
"com": {
|
||||||
|
"url": "https://www.nazwa.pl/domeny/rejestracja-domeny-com/"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"url": "https://www.nazwa.pl/domeny/rejestracja-domeny-ai/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoMeta": {
|
||||||
|
"pl": {
|
||||||
|
"status": "error",
|
||||||
|
"error": "HTTP Error 404: Not Found",
|
||||||
|
"checkedAt": "2026-02-17T21:40:45.836487Z",
|
||||||
|
"url": "https://www.nazwa.pl/domeny/rejestracja-domeny-pl/"
|
||||||
|
},
|
||||||
|
"com": {
|
||||||
|
"status": "error",
|
||||||
|
"error": "HTTP Error 404: Not Found",
|
||||||
|
"checkedAt": "2026-02-17T21:40:45.836487Z",
|
||||||
|
"url": "https://www.nazwa.pl/domeny/rejestracja-domeny-com/"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"status": "error",
|
||||||
|
"error": "HTTP Error 404: Not Found",
|
||||||
|
"checkedAt": "2026-02-17T21:40:45.836487Z",
|
||||||
|
"url": "https://www.nazwa.pl/domeny/rejestracja-domeny-ai/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "home.pl",
|
||||||
|
"url": "https://home.pl/domeny/",
|
||||||
|
"pricing": {
|
||||||
|
"pl": {
|
||||||
|
"register": null,
|
||||||
|
"renew": null
|
||||||
|
},
|
||||||
|
"com": {
|
||||||
|
"register": null,
|
||||||
|
"renew": null
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"register": null,
|
||||||
|
"renew": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoPricing": {
|
||||||
|
"pl": {
|
||||||
|
"url": "https://home.pl/domeny/.pl/"
|
||||||
|
},
|
||||||
|
"com": {
|
||||||
|
"url": "https://home.pl/domeny/.com/"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"url": "https://home.pl/domeny/.ai/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoMeta": {
|
||||||
|
"pl": {
|
||||||
|
"status": "error",
|
||||||
|
"error": "The read operation timed out",
|
||||||
|
"checkedAt": "2026-02-17T21:40:45.836487Z",
|
||||||
|
"url": "https://home.pl/domeny/.pl/"
|
||||||
|
},
|
||||||
|
"com": {
|
||||||
|
"status": "error",
|
||||||
|
"error": "HTTP Error 404: Not Found",
|
||||||
|
"checkedAt": "2026-02-17T21:40:45.836487Z",
|
||||||
|
"url": "https://home.pl/domeny/.com/"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"status": "error",
|
||||||
|
"error": "HTTP Error 404: Not Found",
|
||||||
|
"checkedAt": "2026-02-17T21:40:45.836487Z",
|
||||||
|
"url": "https://home.pl/domeny/.ai/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cyber_Folks",
|
||||||
|
"url": "https://cyberfolks.pl/domeny/",
|
||||||
|
"pricing": {
|
||||||
|
"pl": {
|
||||||
|
"register": 0.0,
|
||||||
|
"renew": 9.9
|
||||||
|
},
|
||||||
|
"com": {
|
||||||
|
"register": 9.9,
|
||||||
|
"renew": 9.9
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"register": 9.9,
|
||||||
|
"renew": 9.9
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoPricing": {
|
||||||
|
"pl": {
|
||||||
|
"url": "https://cyberfolks.pl/domeny/"
|
||||||
|
},
|
||||||
|
"com": {
|
||||||
|
"url": "https://cyberfolks.pl/domeny/"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"url": "https://cyberfolks.pl/domeny/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoMeta": {
|
||||||
|
"pl": {
|
||||||
|
"status": "ok",
|
||||||
|
"checkedAt": "2026-02-17T21:40:45.836487Z",
|
||||||
|
"url": "https://cyberfolks.pl/domeny/",
|
||||||
|
"registerFound": 0.0,
|
||||||
|
"renewFound": 9.9,
|
||||||
|
"registerContext": "ocztę i pomoże w seo! audyt wcag pomoc pomoc pomoc cyber_folks pomoc _stores pomoc _now zaloguj się pomoc pomoc cyber_folks pomoc _stores pomoc _now uruchom chat wróć zaloguj się panel klienta panel admin poczta panel _s",
|
||||||
|
"renewContext": "owadzić do niezamierzonego kierowania ruchu do konkurencji. 03 tanie domeny czy drogie domeny? mówiąc o koszcie utrzymania domen trzeba brać pod uwagę cenę rejestracji oraz cenę odnowienia domeny w kolejnych latach. w na"
|
||||||
|
},
|
||||||
|
"com": {
|
||||||
|
"status": "ok",
|
||||||
|
"checkedAt": "2026-02-17T21:40:45.836487Z",
|
||||||
|
"url": "https://cyberfolks.pl/domeny/",
|
||||||
|
"registerFound": 9.9,
|
||||||
|
"renewFound": 9.9,
|
||||||
|
"registerContext": "ji. 03 tanie domeny czy drogie domeny? mówiąc o koszcie utrzymania domen trzeba brać pod uwagę cenę rejestracji oraz cenę odnowienia domeny w kolejnych latach. w naszej ofercie domenę .pl i inne domeny nask możesz zareje",
|
||||||
|
"renewContext": "y drogie domeny? mówiąc o koszcie utrzymania domen trzeba brać pod uwagę cenę rejestracji oraz cenę odnowienia domeny w kolejnych latach. w naszej ofercie domenę .pl i inne domeny nask możesz zarejestrować za 9.90 zł net"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"status": "ok",
|
||||||
|
"checkedAt": "2026-02-17T21:40:45.836487Z",
|
||||||
|
"url": "https://cyberfolks.pl/domeny/",
|
||||||
|
"registerFound": 9.9,
|
||||||
|
"renewFound": 9.9,
|
||||||
|
"registerContext": "ji. 03 tanie domeny czy drogie domeny? mówiąc o koszcie utrzymania domen trzeba brać pod uwagę cenę rejestracji oraz cenę odnowienia domeny w kolejnych latach. w naszej ofercie domenę .pl i inne domeny nask możesz zareje",
|
||||||
|
"renewContext": "y drogie domeny? mówiąc o koszcie utrzymania domen trzeba brać pod uwagę cenę rejestracji oraz cenę odnowienia domeny w kolejnych latach. w naszej ofercie domenę .pl i inne domeny nask możesz zarejestrować za 9.90 zł net"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Aftermarket",
|
||||||
|
"url": "https://aftermarket.pl/",
|
||||||
|
"pricing": {
|
||||||
|
"pl": {
|
||||||
|
"register": null,
|
||||||
|
"renew": null
|
||||||
|
},
|
||||||
|
"com": {
|
||||||
|
"register": null,
|
||||||
|
"renew": null
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"register": null,
|
||||||
|
"renew": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoPricing": {
|
||||||
|
"pl": {
|
||||||
|
"url": "https://aftermarket.pl/domeny/"
|
||||||
|
},
|
||||||
|
"com": {
|
||||||
|
"url": "https://aftermarket.pl/domeny/"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"url": "https://aftermarket.pl/domeny/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoMeta": {
|
||||||
|
"pl": {
|
||||||
|
"status": "ok",
|
||||||
|
"checkedAt": "2026-02-17T21:40:45.836487Z",
|
||||||
|
"url": "https://aftermarket.pl/domeny/",
|
||||||
|
"registerFound": null,
|
||||||
|
"renewFound": null,
|
||||||
|
"registerContext": null,
|
||||||
|
"renewContext": null
|
||||||
|
},
|
||||||
|
"com": {
|
||||||
|
"status": "ok",
|
||||||
|
"checkedAt": "2026-02-17T21:40:45.836487Z",
|
||||||
|
"url": "https://aftermarket.pl/domeny/",
|
||||||
|
"registerFound": null,
|
||||||
|
"renewFound": null,
|
||||||
|
"registerContext": null,
|
||||||
|
"renewContext": null
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"status": "ok",
|
||||||
|
"checkedAt": "2026-02-17T21:40:45.836487Z",
|
||||||
|
"url": "https://aftermarket.pl/domeny/",
|
||||||
|
"registerFound": null,
|
||||||
|
"renewFound": null,
|
||||||
|
"registerContext": null,
|
||||||
|
"renewContext": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Porkbun",
|
||||||
|
"url": "https://porkbun.com/",
|
||||||
|
"pricing": {
|
||||||
|
"pl": {
|
||||||
|
"register": 26.26,
|
||||||
|
"renew": 26.26
|
||||||
|
},
|
||||||
|
"com": {
|
||||||
|
"register": null,
|
||||||
|
"renew": null
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"register": null,
|
||||||
|
"renew": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoPricing": {
|
||||||
|
"pl": {
|
||||||
|
"url": "https://porkbun.com/tld/pl"
|
||||||
|
},
|
||||||
|
"com": {
|
||||||
|
"url": "https://porkbun.com/tld/com"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"url": "https://porkbun.com/tld/ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoMeta": {
|
||||||
|
"pl": {
|
||||||
|
"status": "ok",
|
||||||
|
"checkedAt": "2026-02-17T21:40:45.836487Z",
|
||||||
|
"url": "https://porkbun.com/tld/pl",
|
||||||
|
"registerFound": 26.26,
|
||||||
|
"renewFound": 26.26,
|
||||||
|
"registerContext": "st deals page. a b c d e f g h i j k l m n o p q r s t u v w x x y z - idn extension registration renewal transfer a back to top .abogado $ 26.26 $ 26.26 $ 26.26 .ac $46.65 1st yr sale! $ 26.06 $ 46.65 $ 46.65 .ac.nz $ 1",
|
||||||
|
"renewContext": "test deals page. a b c d e f g h i j k l m n o p q r s t u v w x x y z - idn extension registration renewal transfer a back to top .abogado $ 26.26 $ 26.26 $ 26.26 .ac $46.65 1st yr sale! $ 26.06 $ 46.65 $ 46.65 .ac.nz $"
|
||||||
|
},
|
||||||
|
"com": {
|
||||||
|
"status": "ok",
|
||||||
|
"checkedAt": "2026-02-17T21:40:45.836487Z",
|
||||||
|
"url": "https://porkbun.com/tld/com",
|
||||||
|
"registerFound": null,
|
||||||
|
"renewFound": null,
|
||||||
|
"registerContext": null,
|
||||||
|
"renewContext": null
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"status": "ok",
|
||||||
|
"checkedAt": "2026-02-17T21:40:45.836487Z",
|
||||||
|
"url": "https://porkbun.com/tld/ai",
|
||||||
|
"registerFound": null,
|
||||||
|
"renewFound": null,
|
||||||
|
"registerContext": null,
|
||||||
|
"renewContext": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"autoLastRunAt": "2026-02-17T21:40:45.836487Z",
|
||||||
|
"autoUpdatedFields": 14
|
||||||
|
}
|
||||||
204
index.html
Normal file
204
index.html
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="pl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Domain Hunter Panel</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:Inter,Arial,sans-serif;background:#0b1020;color:#e8ecff;margin:0;padding:12px}
|
||||||
|
.wrap{max-width:1200px;margin:0 auto}
|
||||||
|
.card{background:#121933;border:1px solid #2a376b;border-radius:12px;padding:12px;margin-bottom:12px}
|
||||||
|
h1{margin:0 0 8px;font-size:24px} h2{margin:0 0 10px;font-size:17px}
|
||||||
|
.muted{color:#9aa6d1}
|
||||||
|
input,button,select{background:#0f1530;color:#e8ecff;border:1px solid #3a4a87;border-radius:8px;padding:8px}
|
||||||
|
.controls{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end}
|
||||||
|
.controls label{display:flex;flex-direction:column;gap:6px;min-width:150px}
|
||||||
|
.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||||
|
.table-wrap{overflow:auto;-webkit-overflow-scrolling:touch}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:13px;min-width:760px}
|
||||||
|
th,td{border-bottom:1px solid #24315f;padding:8px;text-align:left;vertical-align:top}
|
||||||
|
.pill{display:inline-flex;align-items:center;gap:6px;background:#1f2b56;border:1px solid #3a4a87;border-radius:999px;padding:4px 8px;margin:4px 6px 4px 0;font-size:12px}
|
||||||
|
.pill input{width:78px;padding:4px 6px}
|
||||||
|
.ok{color:#63db8f}.bad{color:#ff8b8b}
|
||||||
|
@media (max-width: 980px){.grid{grid-template-columns:1fr}}
|
||||||
|
@media (max-width: 700px){
|
||||||
|
body{padding:10px}
|
||||||
|
.controls{flex-direction:column;align-items:stretch}
|
||||||
|
.controls label{width:100%;min-width:0}
|
||||||
|
button{width:100%}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>Domain Hunter Panel</h1>
|
||||||
|
<p class="muted">Dynamicznie z DB: domeny z Mongo + ceny rejestratorów (zapisywane na serwerze).</p>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Filtr domen</h2>
|
||||||
|
<div class="controls">
|
||||||
|
<label>Szukaj <input id="q" placeholder="np. lead, ai, crm" /></label>
|
||||||
|
<label>TLD
|
||||||
|
<select id="tld"><option value="">Wszystkie</option><option>pl</option><option>com</option><option>ai</option></select>
|
||||||
|
</label>
|
||||||
|
<button id="reload">Odśwież</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted" id="meta"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Cennik rejestratorów</h2>
|
||||||
|
<p class="muted">Edycja zapisuje się bezpośrednio na serwerze (SQLite), bez localStorage.</p>
|
||||||
|
<div id="pricing"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>TOP domen + najlepszy koszt</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Domena</th><th>Score</th><th>Najtaniej kupisz</th><th>Najtaniej odnowisz</th><th>2 lata</th></tr></thead>
|
||||||
|
<tbody id="rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let domains = [];
|
||||||
|
let registrarRows = [];
|
||||||
|
let registrars = [];
|
||||||
|
|
||||||
|
function money(v){ return (v===null || v===undefined || Number.isNaN(Number(v))) ? '—' : `${Number(v).toFixed(2)} zł`; }
|
||||||
|
|
||||||
|
function groupPrices(items){
|
||||||
|
const map = new Map();
|
||||||
|
for(const x of items){
|
||||||
|
if(!map.has(x.registrar)) map.set(x.registrar, {name:x.registrar, url:x.url, pricing:{}});
|
||||||
|
const r = map.get(x.registrar);
|
||||||
|
r.pricing[x.tld] = {register:x.register_price, renew:x.renew_price};
|
||||||
|
}
|
||||||
|
return [...map.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function priceFor(reg, tld, k){
|
||||||
|
const p = reg.pricing?.[tld]?.[k];
|
||||||
|
return (p===null || p===undefined || p==='') ? null : Number(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function best(regs, tld, k){
|
||||||
|
let b = null;
|
||||||
|
for(const r of regs){
|
||||||
|
const v = priceFor(r,tld,k);
|
||||||
|
if(v === null || Number.isNaN(v)) continue;
|
||||||
|
if(!b || v < b.value) b = {name:r.name,url:r.url,value:v};
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function best2y(regs, tld){
|
||||||
|
let b = null;
|
||||||
|
for(const r of regs){
|
||||||
|
const a = priceFor(r,tld,'register');
|
||||||
|
const c = priceFor(r,tld,'renew');
|
||||||
|
if(a===null || c===null || Number.isNaN(a) || Number.isNaN(c)) continue;
|
||||||
|
const v = a+c;
|
||||||
|
if(!b || v < b.value) b = {name:r.name,url:r.url,value:v};
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDomains(){
|
||||||
|
const q = document.getElementById('q').value.trim();
|
||||||
|
const tld = document.getElementById('tld').value;
|
||||||
|
const u = new URL('./api.php', location.href);
|
||||||
|
u.searchParams.set('action','domains');
|
||||||
|
if(q) u.searchParams.set('q', q);
|
||||||
|
if(tld) u.searchParams.set('tld', tld);
|
||||||
|
const d = await (await fetch(u)).json();
|
||||||
|
domains = d.domains || [];
|
||||||
|
document.getElementById('meta').textContent = `runId: ${d.meta?.runId||'—'} | scannedAt: ${d.meta?.scannedAt||'—'} | widoczne: ${domains.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPrices(){
|
||||||
|
const u = new URL('./api.php', location.href); u.searchParams.set('action','prices');
|
||||||
|
const d = await (await fetch(u)).json();
|
||||||
|
registrarRows = d.items || [];
|
||||||
|
registrars = groupPrices(registrarRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePrice(registrar,tld,registerPrice,renewPrice){
|
||||||
|
const u = new URL('./api.php', location.href); u.searchParams.set('action','prices');
|
||||||
|
const r = await fetch(u, {
|
||||||
|
method:'POST',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({registrar,tld,register_price:registerPrice,renew_price:renewPrice})
|
||||||
|
});
|
||||||
|
return r.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPricing(){
|
||||||
|
const wrap = document.getElementById('pricing');
|
||||||
|
wrap.innerHTML = '';
|
||||||
|
for(const r of registrars){
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.style.marginBottom='8px';
|
||||||
|
row.innerHTML = `<b>${r.name}</b> <a href="${r.url}" target="_blank">link</a><br>` + ['pl','com','ai'].map(tld=>{
|
||||||
|
const a = priceFor(r,tld,'register');
|
||||||
|
const b = priceFor(r,tld,'renew');
|
||||||
|
return `<span class='pill'>.${tld} reg <input data-r='${r.name}' data-t='${tld}' data-k='register' value='${a??''}' /></span>
|
||||||
|
<span class='pill'>odn <input data-r='${r.name}' data-t='${tld}' data-k='renew' value='${b??''}' /></span>`;
|
||||||
|
}).join('');
|
||||||
|
wrap.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
wrap.querySelectorAll('input').forEach(inp=>{
|
||||||
|
inp.addEventListener('change', async (e)=>{
|
||||||
|
const {r,t,k} = e.target.dataset;
|
||||||
|
const reg = registrars.find(x=>x.name===r);
|
||||||
|
const value = e.target.value.trim()==='' ? null : Number(e.target.value.replace(',','.'));
|
||||||
|
if(!reg.pricing[t]) reg.pricing[t] = {};
|
||||||
|
reg.pricing[t][k] = Number.isFinite(value) ? value : null;
|
||||||
|
const ok = await savePrice(r, t, reg.pricing[t].register ?? null, reg.pricing[t].renew ?? null);
|
||||||
|
e.target.classList.toggle('ok', ok);
|
||||||
|
e.target.classList.toggle('bad', !ok);
|
||||||
|
renderRows();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRows(){
|
||||||
|
const rows = document.getElementById('rows');
|
||||||
|
rows.innerHTML='';
|
||||||
|
for(const d of domains.slice(0,300)){
|
||||||
|
const buy = best(registrars,d.tld,'register');
|
||||||
|
const renew = best(registrars,d.tld,'renew');
|
||||||
|
const two = best2y(registrars,d.tld);
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `<td><b>${d.domain}</b></td><td>${d.score}</td>
|
||||||
|
<td>${buy?`<a href='${buy.url}' target='_blank'>${buy.name}</a> (${money(buy.value)})`:'—'}</td>
|
||||||
|
<td>${renew?`<a href='${renew.url}' target='_blank'>${renew.name}</a> (${money(renew.value)})`:'—'}</td>
|
||||||
|
<td>${two?`<a href='${two.url}' target='_blank'>${two.name}</a> (${money(two.value)})`:'—'}</td>`;
|
||||||
|
rows.appendChild(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll(){
|
||||||
|
await loadDomains();
|
||||||
|
await loadPrices();
|
||||||
|
renderPricing();
|
||||||
|
renderRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
let qTimer;
|
||||||
|
document.getElementById('reload').addEventListener('click', refreshAll);
|
||||||
|
document.getElementById('q').addEventListener('input', async()=>{
|
||||||
|
clearTimeout(qTimer);
|
||||||
|
qTimer = setTimeout(async()=>{ await loadDomains(); renderRows(); }, 250);
|
||||||
|
});
|
||||||
|
document.getElementById('tld').addEventListener('change', async()=>{await loadDomains(); renderRows();});
|
||||||
|
refreshAll();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
42
init_db.py
Normal file
42
init_db.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB = Path('/home/szmyt/.openclaw/workspace/domain-panel/data/domainhunter.db')
|
||||||
|
DB.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
con = sqlite3.connect(DB)
|
||||||
|
cur = con.cursor()
|
||||||
|
cur.executescript('''
|
||||||
|
CREATE TABLE IF NOT EXISTS metadata (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS domains (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
run_id TEXT,
|
||||||
|
scanned_at TEXT,
|
||||||
|
domain TEXT,
|
||||||
|
tld TEXT,
|
||||||
|
score INTEGER,
|
||||||
|
status TEXT,
|
||||||
|
keywords_json TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domains_run ON domains(run_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domains_domain ON domains(domain);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS registrar_prices (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
registrar TEXT,
|
||||||
|
url TEXT,
|
||||||
|
tld TEXT,
|
||||||
|
register_price REAL,
|
||||||
|
renew_price REAL,
|
||||||
|
updated_at TEXT,
|
||||||
|
UNIQUE(registrar, tld)
|
||||||
|
);
|
||||||
|
''')
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
print(f'OK init {DB}')
|
||||||
10
refresh_and_publish.sh
Executable file
10
refresh_and_publish.sh
Executable file
@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
python3 /home/szmyt/.openclaw/workspace/domain-panel/init_db.py
|
||||||
|
python3 /home/szmyt/.openclaw/workspace/domain-panel/update_registrar_prices.py || true
|
||||||
|
python3 /home/szmyt/.openclaw/workspace/domain-panel/refresh_domain_data.py
|
||||||
|
sudo rsync -a --delete /home/szmyt/.openclaw/workspace/domain-panel/ /var/www/domain-panel/
|
||||||
|
sudo chown -R www-data:www-data /var/www/domain-panel
|
||||||
|
sudo find /var/www/domain-panel -type d -exec chmod 755 {} +
|
||||||
|
sudo find /var/www/domain-panel -type f -exec chmod 644 {} +
|
||||||
|
echo "Published domain panel to /var/www/domain-panel"
|
||||||
145
refresh_domain_data.py
Executable file
145
refresh_domain_data.py
Executable file
@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ENV_PATH = Path('/home/szmyt/docker/databases/.env')
|
||||||
|
REG_PATH = Path('/home/szmyt/.openclaw/workspace/domain-panel/data/registrars.json')
|
||||||
|
DB_PATH = Path('/home/szmyt/.openclaw/workspace/domain-panel/data/domainhunter.db')
|
||||||
|
|
||||||
|
|
||||||
|
def read_env(path: Path):
|
||||||
|
data = {}
|
||||||
|
if not path.exists():
|
||||||
|
return data
|
||||||
|
for line in path.read_text(encoding='utf-8').splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith('#') or '=' not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split('=', 1)
|
||||||
|
data[k.strip()] = v.strip()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_latest_from_mongo(user, pwd):
|
||||||
|
js = r'''
|
||||||
|
const dbx = db.getSiblingDB("aiagent");
|
||||||
|
const first = dbx.domain_scans.find().sort({scannedAt:-1}).limit(1).toArray();
|
||||||
|
if (!first.length) {
|
||||||
|
print(JSON.stringify({runId:null,scannedAt:null,totalAvailable:0,domains:[]}));
|
||||||
|
} else {
|
||||||
|
const runId = first[0].runId;
|
||||||
|
const scannedAt = first[0].scannedAt;
|
||||||
|
const docs = dbx.domain_scans.find({runId: runId, available: true}).sort({score:-1}).limit(500).toArray().map(d => ({
|
||||||
|
domain: d.domain,
|
||||||
|
tld: d.tld,
|
||||||
|
score: d.score,
|
||||||
|
status: d.status,
|
||||||
|
keywords: d.keywords || []
|
||||||
|
}));
|
||||||
|
print(JSON.stringify({runId, scannedAt, totalAvailable: docs.length, domains: docs}));
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
cmd = [
|
||||||
|
'docker', 'exec', 'mongo', 'mongosh', '--quiet',
|
||||||
|
'--username', user,
|
||||||
|
'--password', pwd,
|
||||||
|
'--authenticationDatabase', 'admin',
|
||||||
|
'--eval', js,
|
||||||
|
]
|
||||||
|
p = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||||
|
if p.returncode != 0:
|
||||||
|
raise RuntimeError((p.stderr or p.stdout or 'mongosh failed').strip())
|
||||||
|
out = p.stdout.strip().splitlines()[-1].strip()
|
||||||
|
return json.loads(out)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_metadata(cur, key, value):
|
||||||
|
cur.execute('INSERT INTO metadata(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value', (key, value))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
env = read_env(ENV_PATH)
|
||||||
|
user = env.get('MONGO_INITDB_ROOT_USERNAME', 'root')
|
||||||
|
pwd = env.get('MONGO_INITDB_ROOT_PASSWORD', '')
|
||||||
|
|
||||||
|
latest = fetch_latest_from_mongo(user, pwd)
|
||||||
|
|
||||||
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
con = sqlite3.connect(DB_PATH)
|
||||||
|
cur = con.cursor()
|
||||||
|
|
||||||
|
# ensure schema exists
|
||||||
|
cur.executescript('''
|
||||||
|
CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT);
|
||||||
|
CREATE TABLE IF NOT EXISTS domains (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
run_id TEXT,
|
||||||
|
scanned_at TEXT,
|
||||||
|
domain TEXT,
|
||||||
|
tld TEXT,
|
||||||
|
score INTEGER,
|
||||||
|
status TEXT,
|
||||||
|
keywords_json TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domains_run ON domains(run_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domains_domain ON domains(domain);
|
||||||
|
CREATE TABLE IF NOT EXISTS registrar_prices (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
registrar TEXT,
|
||||||
|
url TEXT,
|
||||||
|
tld TEXT,
|
||||||
|
register_price REAL,
|
||||||
|
renew_price REAL,
|
||||||
|
updated_at TEXT,
|
||||||
|
UNIQUE(registrar, tld)
|
||||||
|
);
|
||||||
|
''')
|
||||||
|
|
||||||
|
run_id = latest.get('runId')
|
||||||
|
scanned_at = latest.get('scannedAt')
|
||||||
|
|
||||||
|
if run_id:
|
||||||
|
cur.execute('DELETE FROM domains WHERE run_id = ?', (run_id,))
|
||||||
|
for d in latest.get('domains', []):
|
||||||
|
cur.execute('''INSERT INTO domains(run_id, scanned_at, domain, tld, score, status, keywords_json)
|
||||||
|
VALUES(?,?,?,?,?,?,?)''', (
|
||||||
|
run_id,
|
||||||
|
scanned_at,
|
||||||
|
d.get('domain'),
|
||||||
|
d.get('tld'),
|
||||||
|
int(d.get('score') or 0),
|
||||||
|
d.get('status'),
|
||||||
|
json.dumps(d.get('keywords') or [], ensure_ascii=False),
|
||||||
|
))
|
||||||
|
|
||||||
|
upsert_metadata(cur, 'latest_run_id', run_id or '')
|
||||||
|
upsert_metadata(cur, 'latest_scanned_at', scanned_at or '')
|
||||||
|
upsert_metadata(cur, 'latest_total_available', str(latest.get('totalAvailable', 0)))
|
||||||
|
|
||||||
|
if REG_PATH.exists():
|
||||||
|
reg = json.loads(REG_PATH.read_text(encoding='utf-8'))
|
||||||
|
for r in reg.get('registrars', []):
|
||||||
|
name = r.get('name')
|
||||||
|
url = r.get('url')
|
||||||
|
pricing = r.get('pricing', {})
|
||||||
|
for tld in ['pl', 'com', 'ai']:
|
||||||
|
p = pricing.get(tld, {})
|
||||||
|
cur.execute('''INSERT INTO registrar_prices(registrar,url,tld,register_price,renew_price,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(registrar,tld) DO UPDATE SET
|
||||||
|
url=excluded.url,
|
||||||
|
register_price=excluded.register_price,
|
||||||
|
renew_price=excluded.renew_price,
|
||||||
|
updated_at=excluded.updated_at''', (
|
||||||
|
name, url, tld, p.get('register'), p.get('renew'), reg.get('autoLastRunAt') or reg.get('updatedAt') or ''
|
||||||
|
))
|
||||||
|
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
print(f"OK: db refreshed {DB_PATH} | available={latest.get('totalAvailable',0)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
120
update_registrar_prices.py
Executable file
120
update_registrar_prices.py
Executable file
@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REG_PATH = Path('/home/szmyt/.openclaw/workspace/domain-panel/data/registrars.json')
|
||||||
|
TIMEOUT = 8
|
||||||
|
|
||||||
|
MONEY_RE = re.compile(r'(\d{1,4}(?:[\.,]\d{1,2})?)\s*(zł|pln|eur|€|usd|\$)', re.IGNORECASE)
|
||||||
|
|
||||||
|
REGISTER_HINTS = ["rejestr", "rejestracja", "new", "first year", "1 rok", "1 rok"]
|
||||||
|
RENEW_HINTS = ["odnow", "renew", "renewal", "kolejny", "next year", "2 rok"]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_text(url: str) -> str:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 DomainHunterBot/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
text = raw.decode('utf-8', errors='ignore')
|
||||||
|
text = re.sub(r'<script[\s\S]*?</script>', ' ', text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r'<style[\s\S]*?</style>', ' ', text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r'<[^>]+>', ' ', text)
|
||||||
|
text = re.sub(r'\s+', ' ', text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def to_float(num: str):
|
||||||
|
try:
|
||||||
|
return float(num.replace(',', '.'))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_price(text: str, tld: str, kind: str):
|
||||||
|
hints = REGISTER_HINTS if kind == 'register' else RENEW_HINTS
|
||||||
|
tld_token = f'.{tld.lower()}'
|
||||||
|
|
||||||
|
# 1) szukaj okna z tld + hint
|
||||||
|
for m in re.finditer(re.escape(tld_token), text, flags=re.IGNORECASE):
|
||||||
|
start = max(0, m.start() - 240)
|
||||||
|
end = min(len(text), m.end() + 240)
|
||||||
|
chunk = text[start:end].lower()
|
||||||
|
if any(h in chunk for h in hints):
|
||||||
|
m2 = MONEY_RE.search(chunk)
|
||||||
|
if m2:
|
||||||
|
val = to_float(m2.group(1))
|
||||||
|
if val is not None:
|
||||||
|
return val, chunk[:220]
|
||||||
|
|
||||||
|
# 2) fallback: hint + cena
|
||||||
|
for h in hints:
|
||||||
|
for mh in re.finditer(re.escape(h), text, flags=re.IGNORECASE):
|
||||||
|
start = max(0, mh.start() - 100)
|
||||||
|
end = min(len(text), mh.end() + 200)
|
||||||
|
chunk = text[start:end].lower()
|
||||||
|
m2 = MONEY_RE.search(chunk)
|
||||||
|
if m2:
|
||||||
|
val = to_float(m2.group(1))
|
||||||
|
if val is not None:
|
||||||
|
return val, chunk[:220]
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
data = json.loads(REG_PATH.read_text(encoding='utf-8'))
|
||||||
|
now = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
|
||||||
|
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
for reg in data.get('registrars', []):
|
||||||
|
auto = reg.get('autoPricing', {})
|
||||||
|
for tld in ['pl', 'com', 'ai']:
|
||||||
|
rule = auto.get(tld)
|
||||||
|
if not rule or not rule.get('url'):
|
||||||
|
continue
|
||||||
|
url = rule['url']
|
||||||
|
try:
|
||||||
|
text = fetch_text(url)
|
||||||
|
except Exception as e:
|
||||||
|
reg.setdefault('autoMeta', {})[tld] = {
|
||||||
|
'status': 'error',
|
||||||
|
'error': str(e),
|
||||||
|
'checkedAt': now,
|
||||||
|
'url': url,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
pricing = reg.setdefault('pricing', {}).setdefault(tld, {'register': None, 'renew': None})
|
||||||
|
reg_val, reg_ctx = find_price(text, tld, 'register')
|
||||||
|
ren_val, ren_ctx = find_price(text, tld, 'renew')
|
||||||
|
|
||||||
|
if reg_val is not None:
|
||||||
|
pricing['register'] = reg_val
|
||||||
|
updated_count += 1
|
||||||
|
if ren_val is not None:
|
||||||
|
pricing['renew'] = ren_val
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
reg.setdefault('autoMeta', {})[tld] = {
|
||||||
|
'status': 'ok',
|
||||||
|
'checkedAt': now,
|
||||||
|
'url': url,
|
||||||
|
'registerFound': reg_val,
|
||||||
|
'renewFound': ren_val,
|
||||||
|
'registerContext': reg_ctx,
|
||||||
|
'renewContext': ren_ctx,
|
||||||
|
}
|
||||||
|
|
||||||
|
data['updatedAt'] = now[:10]
|
||||||
|
data['autoLastRunAt'] = now
|
||||||
|
data['autoUpdatedFields'] = updated_count
|
||||||
|
REG_PATH.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||||
|
print(f'OK: updated fields={updated_count}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue
Block a user