feat: .GOV Domain Tracker tab on RADAR page

- Add govdomains_sync.py: clones CISA dotgov-data, parses CSV, tracks first_seen dates
- Add /api/govdomains and /api/govdomains/stats Flask endpoints with range/type/search filters
- Add NEWS FEED | .GOV TRACKER toggle to RADAR page
- Domain type badges (Federal=red, State=blue, City=green, County=amber)
- New domain detection with pulsing green highlight and NEW badge
- Responsive grid layout with stats bar and result count
This commit is contained in:
jae 2026-04-15 15:52:32 +00:00
parent acb52eb06a
commit 2bc40ac285
5 changed files with 1058 additions and 147 deletions

View file

@ -1410,5 +1410,120 @@ def api_sitrep_generate():
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
# ─── .GOV Domain Tracker ──────────────────────────────
def _load_govdomains():
p = DATA_DIR / 'govdomains.json'
if p.exists():
try:
with open(p) as f:
return json.load(f)
except Exception:
pass
return {'last_sync': None, 'total': 0, 'domains': []}
@app.route('/api/govdomains')
def api_govdomains():
"""Return .gov domains with optional filters: range, search, type, new_only."""
data = _load_govdomains()
domains = data.get('domains', [])
# Range filter (by first_seen)
range_param = request.args.get('range', 'all').lower()
if range_param != 'all':
range_map = {
'24h': 1, '3d': 3, '7d': 7, '14d': 14, '30d': 30
}
days = range_map.get(range_param, None)
if days:
from datetime import datetime as dt, timedelta
cutoff = (dt.utcnow() - timedelta(days=days)).strftime('%Y-%m-%d')
domains = [d for d in domains if d.get('first_seen', '') >= cutoff]
# Type filter
type_param = request.args.get('type', '').strip()
if type_param:
type_lower = type_param.lower()
domains = [d for d in domains if d.get('type', '').lower() == type_lower]
# New only filter
if request.args.get('new_only', '').lower() == 'true':
domains = [d for d in domains if d.get('is_new', False)]
# Search filter
search = request.args.get('search', '').strip().lower()
if search:
domains = [d for d in domains if (
search in d.get('domain', '').lower() or
search in d.get('agency', '').lower() or
search in d.get('organization', '').lower() or
search in d.get('city', '').lower() or
search in d.get('state', '').lower()
)]
# Pagination
limit = min(int(request.args.get('limit', 500)), 2000)
offset = int(request.args.get('offset', 0))
return jsonify({
'last_sync': data.get('last_sync'),
'total_all': data.get('total', 0),
'total_filtered': len(domains),
'offset': offset,
'limit': limit,
'domains': domains[offset:offset + limit]
})
@app.route('/api/govdomains/stats')
def api_govdomains_stats():
"""Return summary stats for .gov domains."""
data = _load_govdomains()
domains = data.get('domains', [])
# Count by type
type_counts = {}
for d in domains:
t = d.get('type', 'Unknown') or 'Unknown'
type_counts[t] = type_counts.get(t, 0) + 1
# Count new (24h)
from datetime import datetime as dt, timedelta
cutoff_24h = (dt.utcnow() - timedelta(days=1)).strftime('%Y-%m-%d')
cutoff_7d = (dt.utcnow() - timedelta(days=7)).strftime('%Y-%m-%d')
cutoff_30d = (dt.utcnow() - timedelta(days=30)).strftime('%Y-%m-%d')
new_24h = sum(1 for d in domains if d.get('first_seen', '') >= cutoff_24h)
new_7d = sum(1 for d in domains if d.get('first_seen', '') >= cutoff_7d)
new_30d = sum(1 for d in domains if d.get('first_seen', '') >= cutoff_30d)
# Load history for recent additions timeline
hist_path = DATA_DIR / 'govdomains_history.json'
history = {}
if hist_path.exists():
try:
with open(hist_path) as f:
history = json.load(f)
except Exception:
pass
# Recent history (last 30 days)
recent_history = {}
for date_key, entries in history.items():
if date_key >= cutoff_30d:
count = len([e for e in entries if not e.startswith('__baseline__')])
if count > 0:
recent_history[date_key] = count
return jsonify({
'total': data.get('total', 0),
'last_sync': data.get('last_sync'),
'new_24h': new_24h,
'new_7d': new_7d,
'new_30d': new_30d,
'by_type': type_counts,
'recent_additions': recent_history,
'types_list': sorted(type_counts.keys())
})
if __name__ == '__main__': if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=False) app.run(host='0.0.0.0', port=5000, debug=False)

228
api/govdomains_sync.py Normal file
View file

@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""JAESWIFT .GOV Domain Tracker — Sync Script
Clones/pulls cisagov/dotgov-data, parses current-full.csv,
tracks first_seen dates and new domain additions.
Designed to run via cron every 12 hours.
"""
import csv
import json
import os
import subprocess
import sys
from datetime import datetime, date
from pathlib import Path
# ─── Configuration ─────────────────────────────────────
BASE_DIR = Path(__file__).parent
SOURCE_DIR = BASE_DIR / 'govdomains-source'
DATA_DIR = BASE_DIR / 'data'
DOMAINS_FILE = DATA_DIR / 'govdomains.json'
HISTORY_FILE = DATA_DIR / 'govdomains_history.json'
REPO_URL = 'https://github.com/cisagov/dotgov-data.git'
CSV_FILE = SOURCE_DIR / 'current-full.csv'
def log(msg):
ts = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')
print(f'[{ts}] {msg}', flush=True)
def clone_or_pull():
"""Clone the CISA dotgov-data repo or pull latest changes."""
if (SOURCE_DIR / '.git').exists():
log('Pulling latest dotgov-data...')
result = subprocess.run(
['git', '-C', str(SOURCE_DIR), 'pull', '--ff-only'],
capture_output=True, text=True, timeout=120
)
if result.returncode != 0:
log(f'Git pull failed: {result.stderr.strip()}')
# Force reset
subprocess.run(
['git', '-C', str(SOURCE_DIR), 'fetch', 'origin'],
capture_output=True, text=True, timeout=120
)
subprocess.run(
['git', '-C', str(SOURCE_DIR), 'reset', '--hard', 'origin/main'],
capture_output=True, text=True, timeout=60
)
log('Force-reset to origin/main')
else:
log(f'Pull OK: {result.stdout.strip()}')
else:
log('Cloning dotgov-data repo...')
SOURCE_DIR.mkdir(parents=True, exist_ok=True)
result = subprocess.run(
['git', 'clone', '--depth', '1', REPO_URL, str(SOURCE_DIR)],
capture_output=True, text=True, timeout=300
)
if result.returncode != 0:
log(f'Clone failed: {result.stderr.strip()}')
sys.exit(1)
log('Clone complete')
def parse_csv():
"""Parse current-full.csv and return list of domain dicts."""
if not CSV_FILE.exists():
log(f'CSV not found: {CSV_FILE}')
sys.exit(1)
domains = []
with open(CSV_FILE, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
domain = (row.get('Domain Name') or row.get('Domain name') or '').strip().lower()
if not domain:
continue
# Ensure .gov suffix
if not domain.endswith('.gov'):
domain += '.gov'
domains.append({
'domain': domain,
'type': (row.get('Domain Type') or row.get('Domain type') or '').strip(),
'agency': (row.get('Agency') or '').strip(),
'organization': (row.get('Organization') or row.get('Organization name') or '').strip(),
'city': (row.get('City') or '').strip(),
'state': (row.get('State') or '').strip(),
'security_contact': (row.get('Security Contact Email') or row.get('Security contact email') or '').strip(),
})
log(f'Parsed {len(domains)} domains from CSV')
return domains
def load_existing():
"""Load existing govdomains.json if it exists."""
if DOMAINS_FILE.exists():
try:
with open(DOMAINS_FILE, 'r') as f:
return json.load(f)
except (json.JSONDecodeError, IOError) as e:
log(f'Error loading existing data: {e}')
return None
def load_history():
"""Load domain addition history."""
if HISTORY_FILE.exists():
try:
with open(HISTORY_FILE, 'r') as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
pass
return {}
def sync():
"""Main sync logic."""
today = date.today().isoformat()
now_iso = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
# Ensure data directory exists
DATA_DIR.mkdir(parents=True, exist_ok=True)
# Clone or pull the repo
clone_or_pull()
# Parse fresh CSV data
csv_domains = parse_csv()
if not csv_domains:
log('No domains parsed — aborting')
sys.exit(1)
# Build lookup from CSV: domain -> record
csv_lookup = {}
for d in csv_domains:
csv_lookup[d['domain']] = d
# Load existing data
existing = load_existing()
history = load_history()
# Build lookup of existing domains: domain -> record (with first_seen)
existing_lookup = {}
if existing and 'domains' in existing:
for d in existing['domains']:
existing_lookup[d['domain']] = d
# Merge: preserve first_seen for known domains, mark new ones
new_domains_today = []
merged = []
for domain_name, csv_record in csv_lookup.items():
if domain_name in existing_lookup:
# Existing domain — preserve first_seen, update other fields
entry = {
**csv_record,
'first_seen': existing_lookup[domain_name].get('first_seen', today),
'is_new': False,
}
else:
# New domain
entry = {
**csv_record,
'first_seen': today,
'is_new': True,
}
new_domains_today.append(domain_name)
merged.append(entry)
# Sort by first_seen descending then domain name
merged.sort(key=lambda x: (x['first_seen'], x['domain']), reverse=True)
# On first run, don't mark everything as "new" in history
# (all domains get first_seen=today but that's expected)
is_first_run = existing is None
# Build output
output = {
'last_sync': now_iso,
'total': len(merged),
'new_today': len(new_domains_today),
'is_first_run': is_first_run,
'domains': merged,
}
# Save domains file
with open(DOMAINS_FILE, 'w') as f:
json.dump(output, f, indent=2)
log(f'Saved {len(merged)} domains to {DOMAINS_FILE}')
# Update history
if new_domains_today and not is_first_run:
if today not in history:
history[today] = []
# Append without duplicating
existing_in_day = set(history[today])
for d in new_domains_today:
if d not in existing_in_day:
history[today].append(d)
log(f'Recorded {len(new_domains_today)} new domains for {today}')
elif is_first_run:
# First run — record total as baseline, not individual domains
history[today] = [f'__baseline__:{len(merged)}_domains']
log(f'First run — baseline of {len(merged)} domains established')
with open(HISTORY_FILE, 'w') as f:
json.dump(history, f, indent=2)
log(f'History updated: {HISTORY_FILE}')
# Summary
log(f'Sync complete: {len(merged)} total domains, {len(new_domains_today)} new today')
if new_domains_today and len(new_domains_today) <= 20:
for d in new_domains_today:
log(f' NEW: {d}')
elif new_domains_today:
log(f' (too many to list individually)')
if __name__ == '__main__':
try:
sync()
except Exception as e:
log(f'FATAL ERROR: {e}')
import traceback
traceback.print_exc()
sys.exit(1)

View file

@ -1,4 +1,4 @@
/* ─── RADAR: Live Intelligence Feed ─────────────── */ /* ─── RADAR: Live Intelligence Feed + .GOV Domain Tracker ─── */
.radar-container { .radar-container {
max-width: clamp(1200px, 90vw, 2400px); max-width: clamp(1200px, 90vw, 2400px);
@ -6,6 +6,61 @@
padding: 0 2rem 3rem; padding: 0 2rem 3rem;
} }
/* ─── View Toggle ──────────────────────────────── */
.radar-view-toggle {
display: flex;
gap: 0;
margin-bottom: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.08);
overflow: hidden;
width: fit-content;
}
.radar-view-btn {
font-family: 'Orbitron', 'JetBrains Mono', monospace;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 2px;
padding: 0.7rem 1.5rem;
background: rgba(255, 255, 255, 0.02);
border: none;
border-right: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.25);
cursor: pointer;
transition: all 0.25s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.radar-view-btn:last-child {
border-right: none;
}
.radar-view-btn:hover {
background: rgba(255, 170, 0, 0.04);
color: rgba(255, 170, 0, 0.5);
}
.radar-view-btn.active {
background: rgba(255, 170, 0, 0.08);
color: rgba(255, 170, 0, 0.95);
box-shadow: inset 0 -2px 0 rgba(255, 170, 0, 0.6);
}
.view-icon {
font-size: 0.9rem;
}
/* ─── View Containers ──────────────────────────── */
.radar-view {
transition: opacity 0.2s ease;
}
.radar-view.hidden {
display: none;
}
/* ─── Controls Bar ─────────────────────────────── */ /* ─── Controls Bar ─────────────────────────────── */
.radar-controls { .radar-controls {
display: flex; display: flex;
@ -22,7 +77,8 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.radar-filter { .radar-filter,
.gov-range {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem; font-size: 0.75rem;
letter-spacing: 1.5px; letter-spacing: 1.5px;
@ -34,13 +90,15 @@
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.radar-filter:hover { .radar-filter:hover,
.gov-range:hover {
background: rgba(255, 170, 0, 0.06); background: rgba(255, 170, 0, 0.06);
border-color: rgba(255, 170, 0, 0.2); border-color: rgba(255, 170, 0, 0.2);
color: rgba(255, 170, 0, 0.7); color: rgba(255, 170, 0, 0.7);
} }
.radar-filter.active { .radar-filter.active,
.gov-range.active {
background: rgba(255, 170, 0, 0.08); background: rgba(255, 170, 0, 0.08);
border-color: rgba(255, 170, 0, 0.4); border-color: rgba(255, 170, 0, 0.4);
color: rgba(255, 170, 0, 0.9); color: rgba(255, 170, 0, 0.9);
@ -100,6 +158,36 @@
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* ─── Type Filter Dropdown ─────────────────────── */
.gov-type-filter {
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
letter-spacing: 1px;
padding: 0.4rem 0.8rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.6);
outline: none;
cursor: pointer;
min-width: 160px;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='rgba(255,170,0,0.5)'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.6rem center;
padding-right: 1.8rem;
}
.gov-type-filter:focus {
border-color: rgba(255, 170, 0, 0.4);
}
.gov-type-filter option {
background: #0a0a0a;
color: rgba(255, 255, 255, 0.7);
}
/* ─── Stats Bar ────────────────────────────────── */ /* ─── Stats Bar ────────────────────────────────── */
.radar-stats { .radar-stats {
display: flex; display: flex;
@ -126,6 +214,11 @@
color: rgba(0, 204, 68, 0.7); color: rgba(0, 204, 68, 0.7);
} }
.stat-new {
color: rgba(0, 204, 68, 0.9);
font-weight: 700;
}
.radar-live { .radar-live {
display: flex; display: flex;
align-items: center; align-items: center;
@ -142,12 +235,27 @@
animation: pulse-dot 1.5s ease infinite; animation: pulse-dot 1.5s ease infinite;
} }
.gov-dot {
background: #00cc44;
}
@keyframes pulse-dot { @keyframes pulse-dot {
0%, 100% { opacity: 1; box-shadow: 0 0 4px #00cc44; } 0%, 100% { opacity: 1; box-shadow: 0 0 4px #00cc44; }
50% { opacity: 0.4; box-shadow: 0 0 1px #00cc44; } 50% { opacity: 0.4; box-shadow: 0 0 1px #00cc44; }
} }
/* ─── Feed Items ───────────────────────────────── */ /* ─── Gov Results Bar ──────────────────────────── */
.gov-results-bar {
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
letter-spacing: 1.5px;
color: rgba(255, 255, 255, 0.2);
padding: 0.4rem 1rem;
margin-bottom: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
/* ─── Feed Items (News) ────────────────────────── */
.radar-feed { .radar-feed {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -276,7 +384,162 @@
letter-spacing: 2px; letter-spacing: 2px;
} }
/* ═══════════════════════════════════════════════════ */
/* .GOV DOMAIN TRACKER STYLES */
/* ═══════════════════════════════════════════════════ */
/* ─── Domain Item ──────────────────────────────── */
.gov-domain-item {
display: grid;
grid-template-columns: 1fr auto auto auto;
gap: 1rem;
align-items: center;
padding: 0.7rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
transition: background 0.15s, border-color 0.3s;
border-left: 2px solid transparent;
}
.gov-domain-item:hover {
background: rgba(255, 255, 255, 0.02);
}
/* ─── New Domain Highlight ─────────────────────── */
.gov-domain-item.gov-new {
border-left: 2px solid rgba(0, 204, 68, 0.6);
background: rgba(0, 204, 68, 0.02);
animation: gov-new-pulse 3s ease infinite;
}
@keyframes gov-new-pulse {
0%, 100% { background: rgba(0, 204, 68, 0.02); }
50% { background: rgba(0, 204, 68, 0.05); }
}
.gov-domain-item.gov-new:hover {
background: rgba(0, 204, 68, 0.06);
}
/* ─── Domain Name ──────────────────────────────── */
.gov-domain-main {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.gov-domain-name {
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
text-decoration: none;
letter-spacing: 0.5px;
transition: color 0.15s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gov-domain-name:hover {
color: rgba(255, 170, 0, 0.95);
}
/* ─── NEW Badge ────────────────────────────────── */
.gov-new-badge {
font-family: 'Orbitron', monospace;
font-size: 0.55rem;
font-weight: 900;
letter-spacing: 2px;
padding: 0.1rem 0.4rem;
background: rgba(0, 204, 68, 0.15);
border: 1px solid rgba(0, 204, 68, 0.4);
color: #00cc44;
animation: badge-glow 2s ease infinite;
flex-shrink: 0;
}
@keyframes badge-glow {
0%, 100% { box-shadow: 0 0 4px rgba(0, 204, 68, 0.2); }
50% { box-shadow: 0 0 10px rgba(0, 204, 68, 0.4); }
}
/* ─── Type Badge ───────────────────────────────── */
.gov-domain-type {
font-family: 'JetBrains Mono', monospace;
font-size: 0.6rem;
letter-spacing: 1.5px;
padding: 0.15rem 0.6rem;
border: 1px solid;
text-align: center;
white-space: nowrap;
flex-shrink: 0;
min-width: 80px;
text-transform: uppercase;
}
/* ─── Domain Details ───────────────────────────── */
.gov-domain-details {
display: flex;
gap: 1.2rem;
flex-wrap: wrap;
min-width: 0;
}
.gov-detail {
font-family: 'JetBrains Mono', monospace;
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.35);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 250px;
}
.gov-detail-label {
color: rgba(255, 255, 255, 0.15);
font-size: 0.6rem;
letter-spacing: 1px;
}
/* ─── First Seen Date ──────────────────────────── */
.gov-domain-date {
font-family: 'JetBrains Mono', monospace;
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.2);
letter-spacing: 0.5px;
white-space: nowrap;
flex-shrink: 0;
min-width: 80px;
text-align: right;
}
/* ─── Truncation Notice ────────────────────────── */
.gov-truncated {
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
color: rgba(255, 170, 0, 0.4);
text-align: center;
padding: 1.5rem;
letter-spacing: 1.5px;
border-top: 1px solid rgba(255, 170, 0, 0.1);
}
/* ─── Responsive ───────────────────────────────── */ /* ─── Responsive ───────────────────────────────── */
@media (max-width: 1024px) {
.gov-domain-item {
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
gap: 0.4rem 1rem;
}
.gov-domain-details {
grid-column: 1 / -1;
}
.gov-domain-date {
text-align: left;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.radar-controls { .radar-controls {
flex-direction: column; flex-direction: column;
@ -300,17 +563,46 @@
.radar-stats { .radar-stats {
gap: 0.8rem; gap: 0.8rem;
} }
.radar-view-toggle {
width: 100%;
}
.radar-view-btn {
flex: 1;
justify-content: center;
padding: 0.6rem 1rem;
font-size: 0.65rem;
}
.gov-domain-item {
grid-template-columns: 1fr;
gap: 0.3rem;
padding: 0.6rem 0.8rem;
}
.gov-domain-type {
justify-self: start;
}
.gov-domain-date {
text-align: left;
}
.gov-type-filter {
min-width: 0;
flex: 1;
}
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.radar-container { .radar-container {
padding: 0 1rem 2rem; padding: 0 1rem 2rem;
} }
.radar-filters { .radar-filters,
.gov-range-filters {
gap: 0.3rem; gap: 0.3rem;
} }
.radar-filter { .radar-filter,
.gov-range {
font-size: 0.65rem; font-size: 0.65rem;
padding: 0.3rem 0.5rem; padding: 0.3rem 0.5rem;
} }
.gov-detail {
max-width: 180px;
}
} }

View file

@ -1,14 +1,17 @@
/* ─── RADAR: Live Intelligence Feed ─────────────── */ /* ─── RADAR: Live Intelligence Feed + .GOV Domain Tracker ─── */
(function() { (function() {
'use strict'; 'use strict';
const API = '/api/radar'; // ═══════════════════════════════════════════════════
const REFRESH_INTERVAL = 15 * 60 * 1000; // 15 minutes // SHARED UTILITIES
let currentSource = 'all'; // ═══════════════════════════════════════════════════
let allItems = [];
let refreshTimer = null; function esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
// ─── Time Ago ──────────────────────────────────
function timeAgo(dateStr) { function timeAgo(dateStr) {
if (!dateStr) return ''; if (!dateStr) return '';
const now = new Date(); const now = new Date();
@ -21,146 +24,368 @@
return Math.floor(diff / 604800) + 'w ago'; return Math.floor(diff / 604800) + 'w ago';
} }
// ─── Extract Domain ────────────────────────────
function extractDomain(url) { function extractDomain(url) {
try { try { return new URL(url).hostname.replace('www.', ''); }
const u = new URL(url); catch(e) { return ''; }
return u.hostname.replace('www.', '');
} catch(e) {
return '';
}
} }
// ─── Escape HTML ─────────────────────────────── // ═══════════════════════════════════════════════════
function esc(s) { // NEWS FEED MODULE
const d = document.createElement('div'); // ═══════════════════════════════════════════════════
d.textContent = s;
return d.innerHTML;
}
// ─── Render Feed ─────────────────────────────── const NewsFeed = {
function renderFeed(items) { API: '/api/radar',
REFRESH_INTERVAL: 15 * 60 * 1000,
currentSource: 'all',
allItems: [],
refreshTimer: null,
renderFeed: function(items) {
const feed = document.getElementById('radarFeed'); const feed = document.getElementById('radarFeed');
if (!items || items.length === 0) { if (!items || items.length === 0) {
feed.innerHTML = '<div class="radar-empty">NO SIGNALS DETECTED ON CURRENT FREQUENCY</div>'; feed.innerHTML = '<div class="radar-empty">NO SIGNALS DETECTED ON CURRENT FREQUENCY</div>';
return; return;
} }
let html = ''; let html = '';
items.forEach(item => { items.forEach(function(item) {
const domain = extractDomain(item.url); const domain = extractDomain(item.url);
const ago = timeAgo(item.published); const ago = timeAgo(item.published);
const sourceClass = 'source-' + item.source_id; const sourceClass = 'source-' + item.source_id;
const sourceLabel = item.source || 'UNKNOWN'; const sourceLabel = item.source || 'UNKNOWN';
html += '<div class="radar-item">'; html += '<div class="radar-item">';
html += ' <div class="radar-item-time">' + esc(ago) + '</div>'; html += ' <div class="radar-item-time">' + esc(ago) + '</div>';
html += ' <div class="radar-item-source ' + sourceClass + '">' + esc(sourceLabel) + '</div>'; html += ' <div class="radar-item-source ' + sourceClass + '">' + esc(sourceLabel) + '</div>';
html += ' <div class="radar-item-content">'; html += ' <div class="radar-item-content">';
html += ' <a href="' + esc(item.url) + '" class="radar-item-title" target="_blank" rel="noopener">' + esc(item.title) + '</a>'; html += ' <a href="' + esc(item.url) + '" class="radar-item-title" target="_blank" rel="noopener">' + esc(item.title) + '</a>';
html += ' <div class="radar-item-meta">'; html += ' <div class="radar-item-meta">';
if (domain) { if (domain) html += '<span class="radar-item-domain">' + esc(domain) + '</span>';
html += ' <span class="radar-item-domain">' + esc(domain) + '</span>'; if (item.comments_url) html += '<a href="' + esc(item.comments_url) + '" class="radar-item-comments" target="_blank" rel="noopener">COMMENTS</a>';
} html += ' </div></div></div>';
if (item.comments_url) {
html += ' <a href="' + esc(item.comments_url) + '" class="radar-item-comments" target="_blank" rel="noopener">COMMENTS</a>';
}
html += ' </div>';
html += ' </div>';
html += '</div>';
}); });
feed.innerHTML = html; feed.innerHTML = html;
} },
// ─── Filter & Search ─────────────────────────── applyFilters: function() {
function applyFilters() { var q = document.getElementById('radarSearch').value.trim().toLowerCase();
const q = document.getElementById('radarSearch').value.trim().toLowerCase(); var filtered = this.allItems;
let filtered = allItems; if (this.currentSource !== 'all') {
filtered = filtered.filter(function(i) { return i.source_id === NewsFeed.currentSource; });
if (currentSource !== 'all') {
filtered = filtered.filter(i => i.source_id === currentSource);
} }
if (q) { if (q) {
filtered = filtered.filter(i => filtered = filtered.filter(function(i) {
(i.title || '').toLowerCase().includes(q) || return (i.title || '').toLowerCase().includes(q) ||
(i.summary || '').toLowerCase().includes(q) (i.summary || '').toLowerCase().includes(q);
); });
} }
document.getElementById('statTotal').textContent = filtered.length; document.getElementById('statTotal').textContent = filtered.length;
renderFeed(filtered); this.renderFeed(filtered);
} },
// ─── Fetch Data ──────────────────────────────── fetch: function(forceRefresh) {
async function fetchRadar(forceRefresh) { var self = this;
const feed = document.getElementById('radarFeed'); var feed = document.getElementById('radarFeed');
feed.innerHTML = '<div class="radar-loading">SCANNING FREQUENCIES...</div>'; feed.innerHTML = '<div class="radar-loading">SCANNING FREQUENCIES...</div>';
var chain = forceRefresh
try { ? fetch(this.API + '/refresh', { method: 'POST' }).then(function() { return fetch(self.API); })
if (forceRefresh) { : fetch(this.API);
await fetch(API + '/refresh', { method: 'POST' }); chain.then(function(res) { return res.json(); })
} .then(function(data) {
self.allItems = data.items || [];
const res = await fetch(API); document.getElementById('statTotal').textContent = self.allItems.length;
const data = await res.json();
allItems = data.items || [];
document.getElementById('statTotal').textContent = allItems.length;
// Format last updated
if (data.last_updated) { if (data.last_updated) {
const d = new Date(data.last_updated); var d = new Date(data.last_updated);
const pad = n => String(n).padStart(2, '0'); var pad = function(n) { return String(n).padStart(2, '0'); };
document.getElementById('statUpdated').textContent = document.getElementById('statUpdated').textContent =
pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + ' UTC'; pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + ' UTC';
} }
self.applyFilters();
applyFilters(); })
.catch(function(err) {
} catch(err) {
console.error('RADAR fetch error:', err); console.error('RADAR fetch error:', err);
feed.innerHTML = '<div class="radar-empty">⚠ SIGNAL LOST — UNABLE TO REACH FEED API</div>'; feed.innerHTML = '<div class="radar-empty">⚠ SIGNAL LOST — UNABLE TO REACH FEED API</div>';
} });
} },
// ─── Event Listeners ─────────────────────────── init: function() {
function init() { var self = this;
// Source filters // Source filters
document.querySelectorAll('.radar-filter').forEach(btn => { document.querySelectorAll('#viewNewsfeed .radar-filter').forEach(function(btn) {
btn.addEventListener('click', function(e) { btn.addEventListener('click', function(e) {
e.preventDefault(); e.preventDefault();
document.querySelectorAll('.radar-filter').forEach(b => b.classList.remove('active')); document.querySelectorAll('#viewNewsfeed .radar-filter').forEach(function(b) { b.classList.remove('active'); });
this.classList.add('active'); this.classList.add('active');
currentSource = this.dataset.source; self.currentSource = this.dataset.source;
applyFilters(); self.applyFilters();
}); });
}); });
// Search // Search
let searchTimeout; var searchTimeout;
document.getElementById('radarSearch').addEventListener('input', function() { document.getElementById('radarSearch').addEventListener('input', function() {
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
searchTimeout = setTimeout(applyFilters, 200); searchTimeout = setTimeout(function() { self.applyFilters(); }, 200);
}); });
// Refresh
// Refresh button
document.getElementById('radarRefresh').addEventListener('click', function(e) { document.getElementById('radarRefresh').addEventListener('click', function(e) {
e.preventDefault(); e.preventDefault();
this.classList.add('spinning'); this.classList.add('spinning');
fetchRadar(true).then(() => { var btn = this;
setTimeout(() => this.classList.remove('spinning'), 500); self.fetch(true).then(function() {
setTimeout(function() { btn.classList.remove('spinning'); }, 500);
}); });
}); });
// Initial fetch // Initial fetch
fetchRadar(false); this.fetch(false);
// Auto-refresh
this.refreshTimer = setInterval(function() { self.fetch(false); }, this.REFRESH_INTERVAL);
}
};
// Auto-refresh every 15 min // ═══════════════════════════════════════════════════
refreshTimer = setInterval(() => fetchRadar(false), REFRESH_INTERVAL); // .GOV TRACKER MODULE
// ═══════════════════════════════════════════════════
const GovTracker = {
API: '/api/govdomains',
STATS_API: '/api/govdomains/stats',
currentRange: 'all',
currentType: '',
currentSearch: '',
allDomains: [],
statsData: null,
loaded: false,
TYPE_COLORS: {
'Federal': { color: '#ff4444', bg: 'rgba(255,68,68,0.08)', border: 'rgba(255,68,68,0.35)' },
'Executive': { color: '#ff4444', bg: 'rgba(255,68,68,0.08)', border: 'rgba(255,68,68,0.35)' },
'Judicial': { color: '#ff6b6b', bg: 'rgba(255,107,107,0.08)', border: 'rgba(255,107,107,0.3)' },
'Legislative': { color: '#ff6b6b', bg: 'rgba(255,107,107,0.08)', border: 'rgba(255,107,107,0.3)' },
'State': { color: '#4488ff', bg: 'rgba(68,136,255,0.08)', border: 'rgba(68,136,255,0.35)' },
'Interstate': { color: '#44aaff', bg: 'rgba(68,170,255,0.08)', border: 'rgba(68,170,255,0.3)' },
'City': { color: '#00cc44', bg: 'rgba(0,204,68,0.08)', border: 'rgba(0,204,68,0.35)' },
'County': { color: '#ffaa00', bg: 'rgba(255,170,0,0.08)', border: 'rgba(255,170,0,0.35)' },
'Independent Intrastate': { color: '#bb88ff', bg: 'rgba(187,136,255,0.08)', border: 'rgba(187,136,255,0.3)' },
'Native Sovereign Nation': { color: '#ff8844', bg: 'rgba(255,136,68,0.08)', border: 'rgba(255,136,68,0.3)' }
},
getTypeStyle: function(type) {
return this.TYPE_COLORS[type] || { color: '#888', bg: 'rgba(136,136,136,0.08)', border: 'rgba(136,136,136,0.3)' };
},
renderDomains: function(domains) {
var feed = document.getElementById('govFeed');
if (!domains || domains.length === 0) {
feed.innerHTML = '<div class="radar-empty">NO .GOV DOMAINS MATCH CURRENT FILTERS</div>';
document.getElementById('govResultCount').textContent = '0 RESULTS';
return;
}
// Show count
document.getElementById('govResultCount').textContent =
domains.length.toLocaleString() + ' DOMAIN' + (domains.length !== 1 ? 'S' : '') + ' FOUND';
// Only render first 200 for performance
var visible = domains.slice(0, 200);
var html = '';
visible.forEach(function(d) {
var style = GovTracker.getTypeStyle(d.type);
var isNew = d.is_new;
var itemClass = 'gov-domain-item' + (isNew ? ' gov-new' : '');
html += '<div class="' + itemClass + '">';
// Domain name + link
html += '<div class="gov-domain-main">';
html += ' <a href="https://' + esc(d.domain) + '" class="gov-domain-name" target="_blank" rel="noopener">';
html += esc(d.domain);
html += ' </a>';
if (isNew) html += ' <span class="gov-new-badge">NEW</span>';
html += '</div>';
// Type badge
html += '<div class="gov-domain-type" style="color:' + style.color + ';background:' + style.bg + ';border-color:' + style.border + '">';
html += esc(d.type || 'UNKNOWN');
html += '</div>';
// Details
html += '<div class="gov-domain-details">';
if (d.agency) html += '<span class="gov-detail"><span class="gov-detail-label">AGENCY:</span> ' + esc(d.agency) + '</span>';
if (d.organization) html += '<span class="gov-detail"><span class="gov-detail-label">ORG:</span> ' + esc(d.organization) + '</span>';
if (d.city || d.state) {
var loc = [d.city, d.state].filter(Boolean).join(', ');
html += '<span class="gov-detail"><span class="gov-detail-label">LOC:</span> ' + esc(loc) + '</span>';
}
html += '</div>';
// First seen
html += '<div class="gov-domain-date">' + esc(d.first_seen || '—') + '</div>';
html += '</div>';
});
if (domains.length > 200) {
html += '<div class="gov-truncated">SHOWING 200 OF ' + domains.length.toLocaleString() + ' — REFINE SEARCH TO SEE MORE</div>';
}
feed.innerHTML = html;
},
fetchStats: function() {
var self = this;
return fetch(this.STATS_API)
.then(function(res) { return res.json(); })
.then(function(stats) {
self.statsData = stats;
document.getElementById('govStatTotal').textContent = (stats.total || 0).toLocaleString();
document.getElementById('govStatNew24').textContent = (stats.new_24h || 0).toLocaleString();
document.getElementById('govStatFederal').textContent = ((stats.by_type || {})['Federal'] || 0).toLocaleString();
document.getElementById('govStatState').textContent = ((stats.by_type || {})['State'] || 0).toLocaleString();
if (stats.last_sync) {
var d = new Date(stats.last_sync);
var pad = function(n) { return String(n).padStart(2, '0'); };
document.getElementById('govStatSync').textContent =
pad(d.getHours()) + ':' + pad(d.getMinutes()) + ' UTC';
}
// Populate type filter dropdown
var select = document.getElementById('govTypeFilter');
var currentVal = select.value;
// Clear existing options except first
while (select.options.length > 1) select.remove(1);
(stats.types_list || []).forEach(function(t) {
var opt = document.createElement('option');
opt.value = t;
opt.textContent = t.toUpperCase() + ' (' + ((stats.by_type || {})[t] || 0) + ')';
select.appendChild(opt);
});
if (currentVal) select.value = currentVal;
})
.catch(function(err) {
console.error('Gov stats error:', err);
});
},
fetchDomains: function() {
var self = this;
var feed = document.getElementById('govFeed');
feed.innerHTML = '<div class="radar-loading">QUERYING .GOV REGISTRY...</div>';
// Build query params
var params = new URLSearchParams();
if (this.currentRange !== 'all') params.set('range', this.currentRange);
if (this.currentType) params.set('type', this.currentType);
if (this.currentSearch) params.set('search', this.currentSearch);
params.set('limit', '2000');
var url = this.API + '?' + params.toString();
return fetch(url)
.then(function(res) { return res.json(); })
.then(function(data) {
self.allDomains = data.domains || [];
self.renderDomains(self.allDomains);
})
.catch(function(err) {
console.error('Gov domains error:', err);
feed.innerHTML = '<div class="radar-empty">⚠ UNABLE TO QUERY .GOV REGISTRY</div>';
});
},
loadAll: function() {
var self = this;
return Promise.all([this.fetchStats(), this.fetchDomains()]).then(function() {
self.loaded = true;
});
},
init: function() {
var self = this;
// Range filters
document.querySelectorAll('.gov-range').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
document.querySelectorAll('.gov-range').forEach(function(b) { b.classList.remove('active'); });
this.classList.add('active');
self.currentRange = this.dataset.range;
self.fetchDomains();
});
});
// Type filter
document.getElementById('govTypeFilter').addEventListener('change', function() {
self.currentType = this.value;
self.fetchDomains();
});
// Search
var searchTimeout;
document.getElementById('govSearch').addEventListener('input', function() {
clearTimeout(searchTimeout);
var val = this.value.trim();
searchTimeout = setTimeout(function() {
self.currentSearch = val;
self.fetchDomains();
}, 300);
});
// Refresh
document.getElementById('govRefresh').addEventListener('click', function(e) {
e.preventDefault();
this.classList.add('spinning');
var btn = this;
self.loadAll().then(function() {
setTimeout(function() { btn.classList.remove('spinning'); }, 500);
});
});
}
};
// ═══════════════════════════════════════════════════
// VIEW TOGGLE CONTROLLER
// ═══════════════════════════════════════════════════
function initViewToggle() {
var subtitles = {
newsfeed: '> Live intelligence feed — intercepted from Hacker News, Reddit, and Lobste.rs',
govtracker: '> .GOV domain intelligence — tracking U.S. government infrastructure registry'
};
document.querySelectorAll('.radar-view-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
var view = this.dataset.view;
// Toggle button states
document.querySelectorAll('.radar-view-btn').forEach(function(b) { b.classList.remove('active'); });
this.classList.add('active');
// Toggle views
document.getElementById('viewNewsfeed').classList.toggle('hidden', view !== 'newsfeed');
document.getElementById('viewGovtracker').classList.toggle('hidden', view !== 'govtracker');
// Update subtitle
document.getElementById('radarSubtitle').innerHTML = subtitles[view] || '';
// Lazy-load gov tracker data on first switch
if (view === 'govtracker' && !GovTracker.loaded) {
GovTracker.loadAll();
}
});
});
}
// ═══════════════════════════════════════════════════
// BOOT
// ═══════════════════════════════════════════════════
function init() {
initViewToggle();
NewsFeed.init();
GovTracker.init();
} }
// ─── Boot ──────────────────────────────────────
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);
} else { } else {

View file

@ -8,7 +8,7 @@
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/section.css"> <link rel="stylesheet" href="/css/section.css">
<link rel="stylesheet" href="/css/radar.css?v=20260404"> <link rel="stylesheet" href="/css/radar.css?v=20260415">
<style>body{background:#0a0a0a;}</style> <style>body{background:#0a0a0a;}</style>
</head> </head>
<body> <body>
@ -41,10 +41,22 @@
<section class="section-header" style="padding-top: calc(var(--nav-height) + 1.5rem);"> <section class="section-header" style="padding-top: calc(var(--nav-height) + 1.5rem);">
<div class="section-header-label">TRANSMISSIONS // INCOMING SIGNALS</div> <div class="section-header-label">TRANSMISSIONS // INCOMING SIGNALS</div>
<h1 class="section-header-title">RADAR</h1> <h1 class="section-header-title">RADAR</h1>
<p class="section-header-sub">&gt; Live intelligence feed — intercepted from Hacker News, Reddit, and Lobste.rs</p> <p class="section-header-sub" id="radarSubtitle">&gt; Live intelligence feed — intercepted from Hacker News, Reddit, and Lobste.rs</p>
</section> </section>
<!-- ─── Top-Level View Toggle ─────────────────── -->
<div class="radar-container"> <div class="radar-container">
<div class="radar-view-toggle">
<button class="radar-view-btn active" data-view="newsfeed">
<span class="view-icon"></span> NEWS FEED
</button>
<button class="radar-view-btn" data-view="govtracker">
<span class="view-icon"></span> .GOV TRACKER
</button>
</div>
<!-- ═══ NEWS FEED VIEW ═══════════════════════ -->
<div class="radar-view" id="viewNewsfeed">
<div class="radar-controls"> <div class="radar-controls">
<div class="radar-filters"> <div class="radar-filters">
<button class="radar-filter active" data-source="all">ALL FEEDS</button> <button class="radar-filter active" data-source="all">ALL FEEDS</button>
@ -72,6 +84,45 @@
</div> </div>
</div> </div>
<!-- ═══ .GOV TRACKER VIEW ════════════════════ -->
<div class="radar-view hidden" id="viewGovtracker">
<div class="radar-controls">
<div class="radar-filters gov-range-filters">
<button class="gov-range active" data-range="all">ALL</button>
<button class="gov-range" data-range="24h">LAST 24H</button>
<button class="gov-range" data-range="3d">3 DAYS</button>
<button class="gov-range" data-range="7d">7 DAYS</button>
<button class="gov-range" data-range="14d">14 DAYS</button>
<button class="gov-range" data-range="30d">30 DAYS</button>
</div>
<div class="radar-actions">
<select class="gov-type-filter" id="govTypeFilter">
<option value="">ALL TYPES</option>
</select>
<input type="text" class="radar-search" placeholder="SEARCH DOMAINS..." id="govSearch">
<button class="radar-refresh" id="govRefresh" title="Refresh data">↻ SYNC</button>
</div>
</div>
<div class="radar-stats gov-stats">
<span class="radar-stat"><span class="stat-label">TOTAL DOMAINS:</span> <span id="govStatTotal"></span></span>
<span class="radar-stat"><span class="stat-label">NEW (24H):</span> <span id="govStatNew24" class="stat-new"></span></span>
<span class="radar-stat"><span class="stat-label">FEDERAL:</span> <span id="govStatFederal"></span></span>
<span class="radar-stat"><span class="stat-label">STATE:</span> <span id="govStatState"></span></span>
<span class="radar-stat"><span class="stat-label">LAST SYNC:</span> <span id="govStatSync"></span></span>
<span class="radar-stat radar-live"><span class="live-dot gov-dot"></span> TRACKING</span>
</div>
<div class="gov-results-bar">
<span id="govResultCount"></span>
</div>
<div class="radar-feed" id="govFeed">
<div class="radar-loading">QUERYING .GOV REGISTRY...</div>
</div>
</div>
</div>
<footer class="footer"> <footer class="footer">
<div class="footer-container"> <div class="footer-container">
<div class="footer-left"> <div class="footer-left">
@ -87,6 +138,6 @@
<script src="/js/wallet-connect.js"></script> <script src="/js/wallet-connect.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script src="/js/clock.js"></script> <script src="/js/clock.js"></script>
<script src="/js/radar.js?v=20260404"></script> <script src="/js/radar.js?v=20260415"></script>
</body> </body>
</html> </html>