| Frontend | React 19 + Vite + Tailwind CSS v4 + Framer Motion + react-leaflet |
| Backend | FastAPI + SQLAlchemy + SQLite |
| AI Enrichment | Claude Haiku 4.5 via Anthropic SDK — auto-summarizes articles |
| Scraper | BeautifulSoup + requests, 30min cron, SSRF-hardened |
| Map | Leaflet + CartoDB dark_matter tiles, coordinate-grouped cluster markers |
| Infra | HestiaCP + nginx + systemd + Let's Encrypt SSL |
| Design system | ux-ui-pro (25 packages: marquee-content, cursor-blob, text-slicer, GSAP…) |
WebFetch Scraped both sources in parallel:
cornucopia.se/tag/ukraina/ — 5 articles extracted (structured HTML, article tags)blogg.svenskkrigare.com/ — 623 articles extracted. Site is JS-rendered; no <article> tags. Had to reverse-engineer: find all <a href> matching /YYYY/MM/DD/slug/, collect longest text per URL, split concatenated title+description at case boundaries.Three agents launched simultaneously:
Svensk Krigare scraper returned 0 articles. Root cause: empty-text <a> tags were added to seen_urls first, blocking the later text-bearing duplicates. Fix: two-pass approach — first collect longest text per URL, then process.
v-add-web-domain robert warblog.svensk.ai — HestiaCP domain creationv-add-dns-record robert svensk.ai warblog A 38.19.200.32 — DNS in parent zonev-add-letsencrypt-domain — SSL cert (valid through 2026-06-29)/home/robert/conf/web/warblog.svensk.ai/nginx.ssl.conf_warblog for API proxysecurity-auditor Background agent reviewed all code. Applied:
/api/refresh (5min cooldown)/docs, /redoc, /openapi.jsonMultiple design passes driven by user feedback:
Events grouped by coordinate. Clicking a cluster shows scrollable timeline popup (desktop) or bottom sheet (mobile). Map touch events blocked while sheet is open via stopPropagation + pointer-events:none.
python-pro Built enrichment pipeline:
enricher.py follows each source_url, extracts article body via BeautifulSoupfull_text + ai_summary in DB (auto-migrated columns via ALTER TABLE)Standalone admin.html at /admin.html. Source management CRUD via /api/admin/sources. Manual scrape trigger.
Explore agent researched all 43 repos by ux-ui-pro on GitHub. Found 24 published to npm:
marquee-content text-slicer cursor-blob liqbg wave-path bubbles-rising cascading-reel coverflow-carousel clicktone easy-confetti btn-kit dialog-lite gridline iconly masonry-simple media-trigger persist-flag typographics snowfall-canvas scratch-reveal wheel-fortune wheel-duo reel-deal zero-hour
All installed to /opt/warblog/node_modules/. UMD builds copied to vendor dir. Integrated: marquee-content (GSAP ticker), cursor-blob (desktop custom cursor), text-slicer (text animation).
general-purpose Full rewrite to React 19 + Vite + Tailwind:
| Component | Purpose |
|---|---|
| App.jsx | State management, data fetching, view routing |
| Header.jsx | Brand + search + source filter |
| StatsBar.jsx | Live stats dashboard |
| TabNav.jsx | Map / Split / Timeline tabs |
| MapView.jsx | react-leaflet with coordinate-grouped cluster markers |
| Timeline.jsx | Western Front-style centered spine with year/month markers |
| TimelineCard.jsx | Expandable card with framer-motion, AI summary display |
| EventModal.jsx | Detail modal with mini-map |
| BottomSheet.jsx | Mobile cluster event list |
| Loading.jsx | Spinner overlay |
Two general-purpose agents launched in parallel:
Issues found and fixed:
| # | Severity | Issue | Fix |
|---|---|---|---|
| 1 | Medium | Both uvicorn workers ran scrape+enrich → duplicate Claude API calls, double cost | Added fcntl.LOCK_EX file lock — only PRIMARY worker runs scrape/enrich |
| 2 | Low | /api/refresh didn't trigger enrichment after scraping | Changed to call _initial_scrape_and_enrich |
| 3 | Low | datetime.utcnow() deprecated in Python 3.12 | Replaced with datetime.now(timezone.utc) |
| 4 | Low | CORS missing Vite dev server port | Added http://localhost:5173 |
| 5 | Low | Build/deploy mismatch (stale JS/CSS hashes) | Rebuilt and redeployed, verified MD5 match |
User added Kyiv Independent via admin page but it didn't appear — the admin sources were saved to DB but the scraper was hardcoded and never queried the sources table.
Fix: rewired run_scrape() to query get_active_sources() from DB after running hardcoded scrapers. Added scrape_generic() — a universal scraper that works with any news site:
add_allowed_domain() dynamically adds new domains to the SSRF allowlistResult: kyivindependent.com → 22 new articles scraped + AI-summarized. Total: 650 events, 3 sources.
fastapi-pro Added production-grade API features:
| Feature | Endpoint | Detail |
|---|---|---|
| Pagination | GET /api/events?page=1&per_page=50 | Returns {count, page, per_page, total_pages, events}. Backward compatible — omit page param for full list. |
| Health check | GET /api/health | Uptime, event/source counts, last scrape time, enrichment progress, version |
| RSS feed | GET /api/rss | RSS 2.0 XML, latest 50 events with AI summaries |
| Analytics | GET /api/analytics/daily | Event counts grouped by date for charts |
| Categories | ?category=military | Auto-categorized by Claude: military, diplomatic, humanitarian, economic, infrastructure, political, nuclear, weapons |
Also expanded location dictionary from 18 to 49 locations (added Kursk, Belgorod, Melitopol, Vovchansk, Robotyne, Lviv, Mykolaiv, Sevastopol, etc.) and added 2 default sources (Ukrinform, Liveuamap).
general-purpose Added React components:
| Feature | Component |
|---|---|
| Event frequency chart | Analytics.jsx — SVG bar chart, last 60 days, hover tooltips |
| Category filter pills | CategoryFilter.jsx — color-coded pill buttons, multi-select |
| Footer with links | Footer.jsx — RSS, Admin, Brain links + keyboard hints |
| Deep links | #event-{id} in URL → shareable event links |
| Keyboard shortcuts | 1/2/3 views, / search, r refresh, Esc close |
| Infinite scroll | Timeline loads 50 at a time via IntersectionObserver |
| Source badge colors | Consistent color per source (Cornucopia=blue, Svensk Krigare=green, Kyiv=yellow) |
| PWA manifest | manifest.json — installable, standalone, dark theme |
/opt/warblog/ ├── backend/ │ ├── main.py FastAPI app, endpoints, background scrape │ ├── database.py SQLAlchemy models (Event, Source), migrations │ ├── scraper.py BeautifulSoup scraper, SSRF protection │ ├── enricher.py Deep scrape + Claude AI summaries │ ├── requirements.txt Pinned dependencies │ └── .env ANTHROPIC_API_KEY ├── frontend-react/ │ ├── src/ │ │ ├── App.jsx Main layout + state │ │ ├── index.css Tailwind + custom styles │ │ └── components/ 14 React components │ ├── dist/ Production build → deployed to webroot │ └── package.json React, Tailwind, Leaflet, Framer Motion ├── node_modules/ 25 ux-ui-pro packages + GSAP ├── data/events.db SQLite (863 events, 49 locations, 5 sources) ├── warblog.service systemd unit ├── deploy.sh Deployment script └── nginx/warblog.conf Nginx config template
v-add-web-domain + v-add-dns-record CLI instead of manual nginx config.fcntl.LOCK_EX | LOCK_NB makes the first worker PRIMARY, second SECONDARY. No external coordination needed.<article> tags, Svensk Krigare uses URL pattern matching). Admin-added sources use a generic approach: find all <a> with date-patterned URLs on the same domain, extract text, filter by war keywords. Works for most news sites without configuration.add_allowed_domain() expands the allowlist at runtime when admin adds a source. Still blocks internal IPs and non-HTTP schemes.Built a standalone broken link crawler for klm.com at klm.svensk.ai.
crawler.py: BFS crawler, robots.txt compliant, 2 req/s rate limit, checks internal + external links, categorizes 404s/5xx/timeouts/SSL/redirect chainsv-add-web-domain + v-add-dns-record + Let's Encrypt SSLBuilt by Claude Opus 4.6 (1M context) via Claude Code CLI — single conversation session