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:
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__':
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 {
max-width: clamp(1200px, 90vw, 2400px);
@ -6,6 +6,61 @@
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 ─────────────────────────────── */
.radar-controls {
display: flex;
@ -22,7 +77,8 @@
flex-wrap: wrap;
}
.radar-filter {
.radar-filter,
.gov-range {
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
letter-spacing: 1.5px;
@ -34,13 +90,15 @@
transition: all 0.2s ease;
}
.radar-filter:hover {
.radar-filter:hover,
.gov-range:hover {
background: rgba(255, 170, 0, 0.06);
border-color: rgba(255, 170, 0, 0.2);
color: rgba(255, 170, 0, 0.7);
}
.radar-filter.active {
.radar-filter.active,
.gov-range.active {
background: rgba(255, 170, 0, 0.08);
border-color: rgba(255, 170, 0, 0.4);
color: rgba(255, 170, 0, 0.9);
@ -100,6 +158,36 @@
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 ────────────────────────────────── */
.radar-stats {
display: flex;
@ -126,6 +214,11 @@
color: rgba(0, 204, 68, 0.7);
}
.stat-new {
color: rgba(0, 204, 68, 0.9);
font-weight: 700;
}
.radar-live {
display: flex;
align-items: center;
@ -142,12 +235,27 @@
animation: pulse-dot 1.5s ease infinite;
}
.gov-dot {
background: #00cc44;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; box-shadow: 0 0 4px #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 {
display: flex;
flex-direction: column;
@ -276,7 +384,162 @@
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 ───────────────────────────────── */
@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) {
.radar-controls {
flex-direction: column;
@ -300,17 +563,46 @@
.radar-stats {
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) {
.radar-container {
padding: 0 1rem 2rem;
}
.radar-filters {
.radar-filters,
.gov-range-filters {
gap: 0.3rem;
}
.radar-filter {
.radar-filter,
.gov-range {
font-size: 0.65rem;
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() {
'use strict';
const API = '/api/radar';
const REFRESH_INTERVAL = 15 * 60 * 1000; // 15 minutes
let currentSource = 'all';
let allItems = [];
let refreshTimer = null;
// ═══════════════════════════════════════════════════
// SHARED UTILITIES
// ═══════════════════════════════════════════════════
function esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
// ─── Time Ago ──────────────────────────────────
function timeAgo(dateStr) {
if (!dateStr) return '';
const now = new Date();
@ -21,146 +24,368 @@
return Math.floor(diff / 604800) + 'w ago';
}
// ─── Extract Domain ────────────────────────────
function extractDomain(url) {
try {
const u = new URL(url);
return u.hostname.replace('www.', '');
} catch(e) {
return '';
}
try { return new URL(url).hostname.replace('www.', ''); }
catch(e) { return ''; }
}
// ─── Escape HTML ───────────────────────────────
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ═══════════════════════════════════════════════════
// NEWS FEED MODULE
// ═══════════════════════════════════════════════════
// ─── Render Feed ───────────────────────────────
function renderFeed(items) {
const feed = document.getElementById('radarFeed');
if (!items || items.length === 0) {
feed.innerHTML = '<div class="radar-empty">NO SIGNALS DETECTED ON CURRENT FREQUENCY</div>';
return;
}
const NewsFeed = {
API: '/api/radar',
REFRESH_INTERVAL: 15 * 60 * 1000,
currentSource: 'all',
allItems: [],
refreshTimer: null,
let html = '';
items.forEach(item => {
const domain = extractDomain(item.url);
const ago = timeAgo(item.published);
const sourceClass = 'source-' + item.source_id;
const sourceLabel = item.source || 'UNKNOWN';
html += '<div class="radar-item">';
html += ' <div class="radar-item-time">' + esc(ago) + '</div>';
html += ' <div class="radar-item-source ' + sourceClass + '">' + esc(sourceLabel) + '</div>';
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 += ' <div class="radar-item-meta">';
if (domain) {
html += ' <span class="radar-item-domain">' + esc(domain) + '</span>';
renderFeed: function(items) {
const feed = document.getElementById('radarFeed');
if (!items || items.length === 0) {
feed.innerHTML = '<div class="radar-empty">NO SIGNALS DETECTED ON CURRENT FREQUENCY</div>';
return;
}
if (item.comments_url) {
html += ' <a href="' + esc(item.comments_url) + '" class="radar-item-comments" target="_blank" rel="noopener">COMMENTS</a>';
let html = '';
items.forEach(function(item) {
const domain = extractDomain(item.url);
const ago = timeAgo(item.published);
const sourceClass = 'source-' + item.source_id;
const sourceLabel = item.source || 'UNKNOWN';
html += '<div class="radar-item">';
html += ' <div class="radar-item-time">' + esc(ago) + '</div>';
html += ' <div class="radar-item-source ' + sourceClass + '">' + esc(sourceLabel) + '</div>';
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 += ' <div class="radar-item-meta">';
if (domain) 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>';
});
feed.innerHTML = html;
},
applyFilters: function() {
var q = document.getElementById('radarSearch').value.trim().toLowerCase();
var filtered = this.allItems;
if (this.currentSource !== 'all') {
filtered = filtered.filter(function(i) { return i.source_id === NewsFeed.currentSource; });
}
html += ' </div>';
html += ' </div>';
html += '</div>';
});
if (q) {
filtered = filtered.filter(function(i) {
return (i.title || '').toLowerCase().includes(q) ||
(i.summary || '').toLowerCase().includes(q);
});
}
document.getElementById('statTotal').textContent = filtered.length;
this.renderFeed(filtered);
},
feed.innerHTML = html;
}
fetch: function(forceRefresh) {
var self = this;
var feed = document.getElementById('radarFeed');
feed.innerHTML = '<div class="radar-loading">SCANNING FREQUENCIES...</div>';
var chain = forceRefresh
? fetch(this.API + '/refresh', { method: 'POST' }).then(function() { return fetch(self.API); })
: fetch(this.API);
chain.then(function(res) { return res.json(); })
.then(function(data) {
self.allItems = data.items || [];
document.getElementById('statTotal').textContent = self.allItems.length;
if (data.last_updated) {
var d = new Date(data.last_updated);
var pad = function(n) { return String(n).padStart(2, '0'); };
document.getElementById('statUpdated').textContent =
pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + ' UTC';
}
self.applyFilters();
})
.catch(function(err) {
console.error('RADAR fetch error:', err);
feed.innerHTML = '<div class="radar-empty">⚠ SIGNAL LOST — UNABLE TO REACH FEED API</div>';
});
},
// ─── Filter & Search ───────────────────────────
function applyFilters() {
const q = document.getElementById('radarSearch').value.trim().toLowerCase();
let filtered = allItems;
if (currentSource !== 'all') {
filtered = filtered.filter(i => i.source_id === currentSource);
}
if (q) {
filtered = filtered.filter(i =>
(i.title || '').toLowerCase().includes(q) ||
(i.summary || '').toLowerCase().includes(q)
);
init: function() {
var self = this;
// Source filters
document.querySelectorAll('#viewNewsfeed .radar-filter').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
document.querySelectorAll('#viewNewsfeed .radar-filter').forEach(function(b) { b.classList.remove('active'); });
this.classList.add('active');
self.currentSource = this.dataset.source;
self.applyFilters();
});
});
// Search
var searchTimeout;
document.getElementById('radarSearch').addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(function() { self.applyFilters(); }, 200);
});
// Refresh
document.getElementById('radarRefresh').addEventListener('click', function(e) {
e.preventDefault();
this.classList.add('spinning');
var btn = this;
self.fetch(true).then(function() {
setTimeout(function() { btn.classList.remove('spinning'); }, 500);
});
});
// Initial fetch
this.fetch(false);
// Auto-refresh
this.refreshTimer = setInterval(function() { self.fetch(false); }, this.REFRESH_INTERVAL);
}
};
document.getElementById('statTotal').textContent = filtered.length;
renderFeed(filtered);
}
// ═══════════════════════════════════════════════════
// .GOV TRACKER MODULE
// ═══════════════════════════════════════════════════
// ─── Fetch Data ────────────────────────────────
async function fetchRadar(forceRefresh) {
const feed = document.getElementById('radarFeed');
feed.innerHTML = '<div class="radar-loading">SCANNING FREQUENCIES...</div>';
const GovTracker = {
API: '/api/govdomains',
STATS_API: '/api/govdomains/stats',
currentRange: 'all',
currentType: '',
currentSearch: '',
allDomains: [],
statsData: null,
loaded: false,
try {
if (forceRefresh) {
await fetch(API + '/refresh', { method: 'POST' });
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;
}
const res = await fetch(API);
const data = await res.json();
// Show count
document.getElementById('govResultCount').textContent =
domains.length.toLocaleString() + ' DOMAIN' + (domains.length !== 1 ? 'S' : '') + ' FOUND';
allItems = data.items || [];
document.getElementById('statTotal').textContent = allItems.length;
// Only render first 200 for performance
var visible = domains.slice(0, 200);
var html = '';
// Format last updated
if (data.last_updated) {
const d = new Date(data.last_updated);
const pad = n => String(n).padStart(2, '0');
document.getElementById('statUpdated').textContent =
pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + ' UTC';
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>';
}
applyFilters();
feed.innerHTML = html;
},
} catch(err) {
console.error('RADAR fetch error:', err);
feed.innerHTML = '<div class="radar-empty">⚠ SIGNAL LOST — UNABLE TO REACH FEED API</div>';
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);
});
});
}
}
};
// ─── Event Listeners ───────────────────────────
function init() {
// Source filters
document.querySelectorAll('.radar-filter').forEach(btn => {
// ═══════════════════════════════════════════════════
// 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();
document.querySelectorAll('.radar-filter').forEach(b => b.classList.remove('active'));
var view = this.dataset.view;
// Toggle button states
document.querySelectorAll('.radar-view-btn').forEach(function(b) { b.classList.remove('active'); });
this.classList.add('active');
currentSource = this.dataset.source;
applyFilters();
// 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();
}
});
});
// Search
let searchTimeout;
document.getElementById('radarSearch').addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(applyFilters, 200);
});
// Refresh button
document.getElementById('radarRefresh').addEventListener('click', function(e) {
e.preventDefault();
this.classList.add('spinning');
fetchRadar(true).then(() => {
setTimeout(() => this.classList.remove('spinning'), 500);
});
});
// Initial fetch
fetchRadar(false);
// Auto-refresh every 15 min
refreshTimer = setInterval(() => fetchRadar(false), REFRESH_INTERVAL);
}
// ─── Boot ──────────────────────────────────────
// ═══════════════════════════════════════════════════
// BOOT
// ═══════════════════════════════════════════════════
function init() {
initViewToggle();
NewsFeed.init();
GovTracker.init();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} 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 rel="stylesheet" href="/css/style.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>
</head>
<body>
@ -41,34 +41,85 @@
<section class="section-header" style="padding-top: calc(var(--nav-height) + 1.5rem);">
<div class="section-header-label">TRANSMISSIONS // INCOMING SIGNALS</div>
<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>
<!-- ─── Top-Level View Toggle ─────────────────── -->
<div class="radar-container">
<div class="radar-controls">
<div class="radar-filters">
<button class="radar-filter active" data-source="all">ALL FEEDS</button>
<button class="radar-filter" data-source="hackernews">HACKER NEWS</button>
<button class="radar-filter" data-source="reddit_technology">R/TECHNOLOGY</button>
<button class="radar-filter" data-source="reddit_programming">R/PROGRAMMING</button>
<button class="radar-filter" data-source="reddit_netsec">R/NETSEC</button>
<button class="radar-filter" data-source="lobsters">LOBSTERS</button>
<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-filters">
<button class="radar-filter active" data-source="all">ALL FEEDS</button>
<button class="radar-filter" data-source="hackernews">HACKER NEWS</button>
<button class="radar-filter" data-source="reddit_technology">R/TECHNOLOGY</button>
<button class="radar-filter" data-source="reddit_programming">R/PROGRAMMING</button>
<button class="radar-filter" data-source="reddit_netsec">R/NETSEC</button>
<button class="radar-filter" data-source="lobsters">LOBSTERS</button>
</div>
<div class="radar-actions">
<input type="text" class="radar-search" placeholder="SEARCH FEED..." id="radarSearch">
<button class="radar-refresh" id="radarRefresh" title="Force refresh">↻ REFRESH</button>
</div>
</div>
<div class="radar-actions">
<input type="text" class="radar-search" placeholder="SEARCH FEED..." id="radarSearch">
<button class="radar-refresh" id="radarRefresh" title="Force refresh">↻ REFRESH</button>
<div class="radar-stats">
<span class="radar-stat"><span class="stat-label">INTERCEPTS:</span> <span id="statTotal"></span></span>
<span class="radar-stat"><span class="stat-label">LAST SYNC:</span> <span id="statUpdated"></span></span>
<span class="radar-stat"><span class="stat-label">AUTO-REFRESH:</span> <span class="stat-auto">15 MIN</span></span>
<span class="radar-stat radar-live"><span class="live-dot"></span> LIVE</span>
</div>
<div class="radar-feed" id="radarFeed">
<div class="radar-loading">SCANNING FREQUENCIES...</div>
</div>
</div>
<div class="radar-stats">
<span class="radar-stat"><span class="stat-label">INTERCEPTS:</span> <span id="statTotal"></span></span>
<span class="radar-stat"><span class="stat-label">LAST SYNC:</span> <span id="statUpdated"></span></span>
<span class="radar-stat"><span class="stat-label">AUTO-REFRESH:</span> <span class="stat-auto">15 MIN</span></span>
<span class="radar-stat radar-live"><span class="live-dot"></span> LIVE</span>
</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-feed" id="radarFeed">
<div class="radar-loading">SCANNING FREQUENCIES...</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>
@ -87,6 +138,6 @@
<script src="/js/wallet-connect.js"></script>
<script src="/js/nav.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>
</html>