vikingsintel,
a Vikings beat, automated and accountable.
vikingsintel.com --- Minnesota Vikings news, analysis, and intel. PHP 8.3 frontend, Python 3.10 automation, MySQL utf8mb4 on LiteSpeed. No editorial committee.
A working Vikings beat that ships every day. The frontend is hand-written PHP with mobile-first CSS Grid; the backend is four cron-driven Python agents that scout RSS, draft articles through Claude with a pre-publish lint gate, refresh cap-state context, and ingest social embeds for review. The site is live, indexed in Google News, and accountable: drift in personnel facts is surfaced in an admin dashboard so unpublished articles can be requalified before they ever face a reader.
ITech scope
- Hand-written PHP per page.
index.phpis the front page;article.php,roster.php,scores.php,draft.php,cap.php,schedule.php, and a dozen others handle the editorial surfaces; 19 admin views live behind a singleadmin/directory with$_SESSION-based auth. No framework, no Composer dependencies — templates are plain PHP includes, which is what makes a 20K-LOC frontend tractable for one operator. - SEO surfaces are first-class. Every article emits per-page JSON-LD with
NewsArticle+Person+SportsTeamentities; the dynamic XML sitemap carriesnews:newstags scoped to the last 48 hours;inject_internal_links()resolves canonical-fact references at render time so a player rename does not strand stale anchor text. RSS is per-section and per-author, not just site-wide. A corrections page and per-author byline pages live as first-class URLs because Google News scoring rewards them. - Custom search overlay bound to ⌘K / Ctrl+K / / with a debounced live-results modal. The backing index is a MySQL fulltext + LIKE hybrid (utf8mb4,
ngramparser disabled because team and player names mostly tokenize fine on whitespace), and the modal returns within 80 ms on shared hosting because the query plan is pinned byFORCE INDEX. - Schema discipline lives in
.claude/rules/schema.md. Column-name gotchas (excerptnotsummary,body_htmlnotbody,published_atnotposted) are documented and consulted before any SQL change. There are no foreign-key constraints — shared hosting MySQL doesn’t reliably enforce them under InnoDB barrier configs — so referential integrity is enforced at the application layer with explicit checks, and the rule file is the contract that keeps those checks honest. - The PDO connection is a singleton (
db()), prepared statements are used for every parameterized query, and thearticlestable sits behind a small set of repository functions (article_load_by_slug,article_list_recent,article_publish) so the SQL surface is finite and auditable.
IIAutomation
Four agents run on a 30-minute cron driven by automation/automation_engine.py. Each is idempotent — running the same tick twice yields the same database state — and each fails loudly: a missing field, a 5xx, or a parse error increments a per-agent failure counter and surfaces in the admin dashboard before it can become a silent gap.
- Scout (Agent A) — 7 RSS feeds total: 4 Vikings-dedicated, 3 NFL feeds keyword-filtered for Vikings + Minnesota + Skol. Items land in a
scraped_sourcesqueue with the source feed, fetch timestamp, and a SHA-256 of the entry id so a republish from the upstream is deduped on insert. - Writer (Agent B) — selects one unprocessed source per 48 hours and drafts an article through the Anthropic Claude API. The draft lands as
is_published = 0and must clear the pre-publish lint gate before any human ever sees a "ready to review" notification. The Claude prompt is a fixed editorial brief plus the source excerpt, capped at 6 000 input tokens, with prompt caching enabled so the brief itself is paid for once per refresh. - CapScraper (Agent C) — refreshes salary-cap state from OverTheCap and Spotrac into
site_settings['cap_intel']as a single JSON blob. The blob is consulted bycap.phpat render time, which means a cap-state change propagates the moment Agent C commits, without a deploy. - SocialScout (Agent D) — ingests Reddit JSON, YouTube channel RSS, and a small set of RSS bridges (Threads, Bluesky) into a
social_embedsreview queue. Embeds are not auto-published; the queue is the operator’s morning surface.
An accuracy dashboard piggy-backs on the same 30-minute tick. A self-hosted RSS hub on the MasterAgent box runs every 15 minutes, FTPs RSS XML and a manifest.json to the site, and SocialScout reads from there — which means the social ingest pipeline is independent of any third-party rate limit on the publishing host.
IIIEditorial gates
One lint function (article_lint()) enforces canonical-fact validation across every surface that publishes — the publish gate, the drift dashboard, and the truth engine all call the same code path. The lint reads the canonical-fact table (current roster, current contract status, current depth-chart) and refuses to publish an article that contradicts it without a hedging qualifier. When a player’s status changes — released, traded, retired, suspended — previously-published articles that reference the now-stale fact are unpublished automatically and held in a "requires editor" bucket until a human inserts the right qualifier ("former", "ex", "then-", "now-Bears").
The structural choice that makes this work is "0 editorial committee." The publish decision belongs to one person, which is what makes a 30-minute automation cadence sustainable; a committee would not approve every drafted article fast enough for the news cycle, and a multi-author shop would need to trade auto-unpublish for a different reconciliation policy. The cap of one human owner is the load-bearing constraint behind the speed.
IVNumbers
The numbers reflect a one-operator beat. Two languages (PHP for the frontend, Python for the automation), one cadence tied to the NFL calendar (free agency, draft, training camp, regular season, playoffs — each with its own publishing rhythm), and a 30-minute automation tick that does not assume game-week traffic patterns. The automation modules are scheduled, idempotent, and fail loudly: a missing field, a 5xx upstream, or a parse error increments a per-agent failure counter and surfaces in the admin dashboard before it can become a silent gap. Missing a week is preferable to publishing a hallucinated stat. The PHP-heavy line count above is the public-site rendering surface, not application logic; the actual editorial decisions live in the smaller Python automation tree.
VConstraints
No third-party comment system, no analytics beyond server logs, no signed-in surface for readers. Automation is allowed to summarize box scores and roster moves verbatim from official sources but is never permitted to invent quotes, infer intent, or attribute thoughts to a player without a citation. When LLM rewrites are used (sparingly, and only on copy-edit passes), they are constrained to text the operator has already approved as factually correct — never generation from prompts alone, and never restructuring of the source quotes. The ‘0 editorial committee’ plate is structural: the entire publishing decision belongs to one person, which is what makes a 30-minute automation cadence sustainable in the first place. A multi-author shop would need to trade auto-unpublish-on-fact-drift for a different reconciliation policy, and the cadence would slip with it.