For AI coding agents (ZCode, Hermes-Agent, Pi, OpenCode, KiloCode, Claude Code, Cursor, etc.)
Visual-HN — HN w/ pics. FastAPI app that proxies hcker.news, adds preview images/Open Graph metadata, tracks position trends, and serves data for the hcker.news browser extension. The old frontend is being retired; the extension will consume the Visual-HN API for screen-capture assets, scores, and related story data.
This project runs across two machines. Code runs in both places; commands are not portable.
| VPS (proxy + scraper) | Residential node (Cloudflare bypass) | |
|---|---|---|
| OS | Ubuntu 24.04 (Hetzner CX32) | Windows 11 (residential laptop) |
| Hostname | (see internal docs) | (see internal docs) |
| Shell | bash | PowerShell 7 |
| Network | DC IP + Tailscale (internal) | Residential IP + Tailscale (internal) |
| Runs | main.py (FastAPI proxy + scraper) as systemd service |
residential_fetcher.py (headless Chrome via Playwright) via Task Scheduler |
| Service | visual-hn.service (systemctl start/stop/restart) |
VHN-ResidentialFetcher scheduled task |
| Venv | .venv (Python 3.10+) |
.node-venv (Python 3.11+) |
| Role | Owns the DB, serves the public site, owns the scrape loop | Called by VPS only when curl_cffi gets 403/429/503 — solves CF JS challenges via real Chrome (Playwright, headless) |
Commands are not interchangeable. A systemctl restart does nothing on Windows; Start-ScheduledTask does nothing on the VPS. When a command in this file looks wrong for the machine you're on, check which environment you're in before assuming the doc is stale.
Full deployment instructions for both environments: docs/DEPLOYMENT.md. The residential node is intermittent by design — it's a laptop under daily use. When it's off, the VPS falls through to Wayback Machine → screenshot → favicon composite. No blocking, no alerting. See docs_internal/anti-scraping.md for the full fallback chain.
No assume. No hide confusion. Surface tradeoffs.
- State assumptions. Uncertain → ask.
- Multiple interpretations → present, no silent pick.
- Simpler path exist → say so. Push back when warranted.
- Unclear → stop. Name confusion. Ask.
Min code that solve problem. Nothing speculative.
- No features beyond ask.
- No abstractions for single-use code.
- No "flexibility"/"configurability" not requested.
- No error handling for impossible cases.
- 200 lines could be 50 → rewrite.
Test: senior eng call this overcomplicated? Yes → simplify.
Define success. Loop until verified.
- "Add validation" → write failing tests, make pass.
- "Fix bug" → write reproducing test, make pass.
- "Refactor X" → tests pass before and after.
Multi-step → state plan: [step] → verify: [check].
Before making any changes, create a branch from main:
git checkout main && git pull && git checkout -b <descriptive-name>Never commit directly to main. Every task gets its own branch.
Run checks (black ., pytest) and commit as you go. Use Conventional Commits messages.
black .— Python formatting.pytest— run all tests.
Always get explicit user confirmation before:
git push(any remote)- Opening a PR (
gh pr create) - Publishing or deploying anything
Commit locally all you want. Ask before it leaves the machine.
- Read before write. Each file once.
- Edit over rewrite. No write-delete-rewrite cycles.
- Test once, fix, verify once.
- Budget: 50 tool calls.
- Stuck → ask. No dead ends.
- No sycophantic openers/fluff.
- Never guess paths.
Environment matters. Commands below are tagged [VPS] (Ubuntu/bash, the production proxy) or [NODE] (Windows 11/PowerShell 7, the residential fetcher). Same repo, different machines. See
docs/DEPLOYMENT.mdfor the full topology.
cd /srv/apps/visual-hn
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python -m playwright install chromium # for screenshot fallbackFollow docs/NODE_SETUP.md. Uses Playwright with bundled Chromium (no system Chrome required). Summary:
cd D:\GitHub\visual-hn
python -m venv .node-venv
.\.node-venv\Scripts\Activate.ps1
pip install fastapi uvicorn playwright
python -m playwright install chromiumsource .venv/bin/activate
uvicorn main:app --reloadThe systemd service owns this. Do not run uvicorn manually while the service is active.
sudo systemctl restart visual-hn # after code changes
sudo systemctl status visual-hn
sudo journalctl -u visual-hn -f # live logs.\scripts\start-fetcher.ps1 # manual, foreground
# Or via Task Scheduler (auto-start on login):
.\scripts
egister-task.ps1npx @tailwindcss/cli -i ./static/css/input.css -o ./static/css/output.css
# Watch mode:
npx @tailwindcss/cli -i ./static/css/input.css -o ./static/css/output.css --watchsource .venv/bin/activate
pytest # all tests
pytest test_database.py -v # single fileTests use pytest-asyncio with in-memory SQLite. Async test functions need @pytest.mark.asyncio and the test_db fixture for database access.
source .venv/bin/activate
black .
blackis not installed on the VPS — runpip install blackin the venv before relying on it.
The scrape pipeline (hn_scraper.py → metadata.py → database.py) runs every 15 minutes. Gotchas the code won't tell you:
database.pyrenames HN API fields on the way in:by→poster,descendants→comments_count,time→time_posted.- Position trends are inverted: a lower position number is a higher rank, so
last_position > current_positionmeans"up". - No migrations —
create_allruns on startup, so schema changes tomodels.pyneed the DB recreated by hand.
Web serving / extension API: The main consumer is the visual-hn-previews/ project, which calls the Visual-HN API for HN w/ pics. The old web frontend is being retired. The home route should stay minimal, while the legacy frontend lives behind a two-word hidden route. Scores still need to be exposed through the Visual-HN API for the extension.
- Python 3.10+, async throughout, type hints on function signatures
- Functional style preferred over classes (except ORM models)
- Use
async deffor I/O operations,deffor pure functions - Early returns for error handling, guard clauses over nested conditionals
- Use Python
loggingmodule, not print statements - Pydantic for validation, SQLAlchemy ORM for persistence
Always prefix commands with rtk. If RTK has dedicated filter, it uses it. Else passthrough unchanged. RTK always safe. No rtk bun; see commands.
Important: Even in command chains with &&, use rtk:
# ❌ Wrong
git add . && git commit -m "msg" && git push
# ✅ Correct
rtk git add . && rtk git commit -m "msg" && rtk git pushFull command reference (which tools have dedicated filters, and their savings): the rtk-commands skill in .claude/skills/rtk-commands/.