The monolithic Peptide_News_LLM class mixed HTTP transport, prompt
construction, batch orchestration, and AJAX handling. Split into:
- class-peptide-news-llm-client.php (199 lines) — OpenRouter HTTP
transport with retry logic and response parsing
- class-peptide-news-llm-prompt-builder.php (142 lines) — keyword
and summary prompt construction, article text fetching
- class-peptide-news-llm-ajax.php (87 lines) — bulk-generate AJAX
handler
- class-peptide-news-llm.php (300 lines) — orchestrator with
backward-compatible proxies for is_valid_model(), call_openrouter(),
and call_openrouter_with_usage()
Extracted run_llm_task() helper to deduplicate the keyword/summary
processing blocks. All public API preserved — no test changes needed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The add_action(), add_filter(), and add() methods used `object` type
hints for the $component parameter, but several hooks pass string class
names (for static method callbacks). PHP 8.3 strict typing rejects
strings where `object` is declared, causing a fatal TypeError that
crashes the entire site.
Removed the `object` type hint to accept both object instances and
string class names, which is required for WordPress static callbacks.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace 4 short ternary operators (?:) with explicit conditionals in Cost_Tracker
- Move declare(strict_types=1) after file-level docblocks in 3 template files
(PHPCS requires docblock immediately after opening PHP tag)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- New Cost_Tracker class: logs every API call (model, tokens, cost), enforces
monthly budgets with hard-stop mode, provides aggregated reporting
- New llm_costs database table with indexes on created_at, model, operation
- Modified LLM class: call_openrouter_with_usage() extracts token usage from
OpenRouter responses; budget gate checks before each API call
- Admin settings: monthly budget (USD), enforcement mode (disabled/warn/hard-stop),
cost log retention period
- Admin cost dashboard: budget progress bar, daily cost chart (Chart.js),
per-model and per-operation breakdowns, recent API call log
- PHPUnit tests: 16 tests covering cost calculation, pricing lookups,
budget enforcement logic, and model validation
- Updated ARCHITECTURE.md with new table schema, data flow, and key decisions
- Updated CONVENTIONS.md with "how to add cost tracking for a new operation"
- Version bump to 2.4.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Downgrade Generic.Files.OneObjectStructurePerFile to warning
(widget class shares file with public class — split in future cleanup)
- Add translators comment for __() with %s placeholder in REST API
- Remove blank line after control structure in REST API
- Fix return-newline in content filter (extract to $prompt variable)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
WordPress coding standards require tab indentation. All 12 PHP files
in includes/, admin/, and public/ were using 4-space indentation.
Converted to tabs to pass the Generic.WhiteSpace.DisallowSpaceIndent
PHPCS rule. Whitespace-only change — no logic modifications.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add bulk and per-row article deletion with AJAX handler, confirmation
dialogs, and cascading cleanup of click analytics via foreign keys.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Google News RSS items often have no description or content — just a
title. This left the LLM with insufficient context, resulting in empty
ai_summary fields.
When the stored content is sparse (<100 chars or matches the title),
build_summary_prompt() now fetches the actual article page via
wp_remote_get(), extracts text from <article>/<main> tags, and feeds
it to the LLM. Gracefully falls back to title-only if the fetch fails.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The security audit added wp_kses_post()/esc_html() to article fields
before returning them via the REST API. This caused double-encoding
(e.g., & → &) because REST responses are JSON, not HTML —
WordPress's rest_ensure_response() already handles JSON encoding.
The frontend is responsible for output escaping at render time.
Removes the 4 escaping lines from get_articles() to fix broken
summaries and truncated content on the live site.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Security (7 fixes):
- SEC-001: Use wpdb->prepare() for COUNT query in REST API
- SEC-002: Set httponly=true on session cookie to prevent XSS
- SEC-003: Mask API keys in admin settings (show last 4 chars only)
- SEC-004: Check capability before nonce in manual fetch handler
- SEC-005: Validate redirect domains to prevent open redirects
- SEC-006: Escape all article fields before REST API response
- SEC-007: Add capability check to AJAX summary handler
Infrastructure (7 fixes):
- WPS-002: Clear cron schedule on plugin deactivation
- WPS-003: Bundle Chart.js locally instead of CDN without SRI
- WPS-004: Add uninstall.php for clean plugin removal
- DB-001: Add foreign key constraints with CASCADE deletes
- DB-002: Clamp pagination to valid page range
- CI-001: Use StrictHostKeyChecking=accept-new in deploy
- CI-002: Add security scanning step to CI pipeline
- CI-003: Add rollback workflow via workflow_dispatch
Performance & Quality (8 fixes):
- PHP-001: Validate widget $instance as array before access
- PHP-002: Return null from get_client_ip() for invalid IPs
- PHP-003: Process RSS feeds in chunked batches of 10
- PHP-004: Wire up clear_article_cache() in fetch cycle
- PERF-001: Cache source names in transient for strip_source_suffix
- PERF-002: Add server-side transient caching for REST API
- ARCH-002: Make LLM API URL configurable via WordPress options
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The strip_source_suffix() fallbacks aren't catching all nbsp-separated
publisher names. Adding a direct strrpos-based cleanup pass in the
get_articles() method itself as a safety net. This operates on the
already-decoded text returned by strip_source_suffix().
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Previous strrpos-based approach wasn't catching all cases. Switch to a
preg_match with /u flag and capture groups to reliably split text at
nbsp separator boundaries.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The previous regex approach with \x{00A0} wasn't reliably catching all
nbsp-separated publisher names (e.g., "UC Santa Cruz" on article 19).
Switched to strrpos-based string search which is simpler and avoids
potential PCRE/UTF-8 edge cases with multibyte characters.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Google News RSS excerpts use double non-breaking spaces followed by the
publisher name (e.g. "...habitat News-Medical"). When the source field
is "news.google.com" rather than the actual publisher, the exact-match
logic can't strip it. This adds a regex fallback that catches trailing
nbsp-separated capitalized names.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Google News RSS excerpts use (double non-breaking spaces)
to separate the excerpt text from the source name. Added html_entity_decode
and nbsp-based separators to handle this pattern.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The deploy workflow reads from the repo root (admin/, includes/, public/),
not the peptide-news-plugin/ subdirectory. Applied the same changes:
- Removed Tag component and tag rendering from ArticleCard
- Removed all tag-related CSS (variables, styles, skeleton, responsive)
- Added strip_source_suffix() to REST API for cleaning source names
from titles/summaries with case-insensitive and domain-based matching
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Introduces Peptide_News_Logger class with leveled logging (info, warning,
error, debug) to a custom DB table, auto-pruning at 2000 rows. Adds a
filterable, paginated log viewer to the admin settings page. Instruments
the LLM and Fetcher classes with structured log calls for fetch cycles,
AI processing, and error tracking. Version bumped to 2.1.0.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The qwen/qwen3.6-plus-preview:free model returns "No endpoint"
errors. Switch to google/gemma-3-27b-it:free which is confirmed
working on OpenRouter's free tier.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Surface actual API error messages in the admin UI instead of just
showing a generic failure count. This helps diagnose issues with
the OpenRouter API configuration.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The configured model qwen/qwen3.6-plus:free doesn't exist on
OpenRouter — the correct ID is qwen/qwen3.6-plus-preview:free.
Add auto-correction in the bulk generation handler.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Free OpenRouter models are too slow for batch processing on shared
hosting. Reduce batch size from 5 to 1, API timeout from 30s to 15s,
and max retries from 3 to 1 to stay within PHP max_execution_time.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The previous batch size of 50 exceeded PHP max_execution_time on
Hostinger shared hosting (30s). Each article needs ~3s for the
OpenRouter API call, so 5 articles per AJAX request keeps each
batch under 20s. The JS loop still processes all articles
automatically — just in more, smaller batches.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a "Generate AI Summaries" button to the Peptide News settings page
that processes all articles missing summaries in batches of 50. The UI
loops automatically until every article has been summarized, showing
real-time progress. The admin batch overrides the per-cycle limit so it
isn't capped at 10 — the existing 120-second timeout guard still applies.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
LiteSpeed Cache was serving stale REST API responses with 0 articles, causing the React frontend to show "No articles" despite 50+ articles in the DB.
- Add Cache-Control and X-LiteSpeed-Cache-Control: no-cache headers to /articles endpoint
- Include ai_summary and thumbnail_local columns in the REST API query (needed by React frontend)
Remove all AI thumbnail generation infrastructure (too expensive,
poor results) and rewrite the public-facing article feed as a React
SPA mounted via WordPress shortcode.
Changes:
- Remove thumbnail generation, OG scraping, and image download code
from fetcher, LLM, and admin classes
- Replace server-rendered PHP templates with React component
(PeptideNewsFeed.jsx) using WordPress-bundled React/ReactDOM
- Add Babel build pipeline for JSX transpilation
- Update REST API to serve ai_summary instead of thumbnail fields
- Redesign public CSS: clean card layout, loading skeletons,
dark mode, responsive, no thumbnail imagery
- Remove dead admin settings (llm_image_model, thumbnail_fallback)
- Fix call_openrouter() visibility for Content Filter cross-class use
- Bump version to 1.3.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
DuckDuckGo's HTML search endpoint now requires vqd session tokens,
making server-side search-based URL resolution unreliable. Simplified
the thumbnail pipeline:
- OG scrape only for non-aggregator URLs (direct publisher pages)
- AI image generation via OpenRouter for aggregator articles (Google News)
- Added Image Model settings field in admin UI
- Added thumbnail_local column for AI-generated images
- Updated public/admin display to check thumbnail_local as fallback
- Removed all DuckDuckGo search code (find_article_url_via_search,
extract_ddg_url, resolve_article_for_scraping)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Google News RSS stores encrypted article URLs that cannot be resolved
server-side (JS SPA, no HTTP redirects, encrypted base64 IDs). Replace
the failed decode/parse strategies with DuckDuckGo search-based lookup
using article title + publisher name to find actual article pages, then
scrape OG images from those pages.
- Add find_article_url_via_search() using DuckDuckGo HTML endpoint
- Add resolve_article_for_scraping() combining redirect + search fallback
- Mark failed lookups with '_no_image' sentinel to avoid repeated retries
- Backfill button resets sentinels to allow manual re-scraping
- Clean up verbose debug instrumentation from prior attempts
- Update frontend to handle '_no_image' sentinel gracefully
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace batchexecute API with direct fetch + HTML parsing strategies:
data-n-au attributes, anchor href extraction, meta refresh, JS redirect.
Add verbose debug output to diagnose server-side response from Google News.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace broken base64 decoding with Google's internal batchexecute API
endpoint to resolve encrypted article IDs to actual publisher URLs.
This is the approach used by known Google News decoder libraries.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Temporary debug version that returns query counts, URL resolution results,
and OG scraping outcomes to identify why thumbnail scraping returns 0.
Also adds Google News URL decoder (base64/protobuf approach).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The OG image scraper was using Google News redirect URLs (source_url) directly,
which don't serve og:image tags. Added resolve_scrape_url() to follow redirects
(including meta-refresh and JS redirects) to reach the actual publisher page
before scraping. Also added a backfill AJAX endpoint and admin button to
re-scrape thumbnails for existing articles.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Google News RSS feeds report every article as coming from
"news.google.com" because the source was extracted from the feed URL
rather than the individual article. This adds a multi-strategy
resolver that extracts the real publisher name via:
1. SimplePie <source> element (Google News provides this)
2. Title suffix parsing ("Headline - Source" format)
3. HTTP HEAD redirect resolution as a fallback
Also adds a one-click "Fix Article Sources" backfill button on the
settings page to correct existing articles in the database.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The Peptide_News_Loader class was referenced by class-peptide-news.php
but never committed, causing a fatal error on plugin activation.
Also adds public/css/public-style.css which is enqueued by the public
class.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- analytics.php line 38: mismatched quotes on session_id array key
- analytics.php line 246: invalid escape before concatenation operator
- analytics.php line 250: COuNT typo → COUNT
- analytics.php line 252: NULIF typo → NULLIF
- rest-api.php line 54: stray # commenting out foreach variable
These bugs predate the content filter feature but were caught by the
new CI pipeline's PHP lint job.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two-tier filtering during article fetch: keyword/pattern rules as a fast
first pass, then optional LLM classification for borderline cases via
OpenRouter. Configurable sensitivity (strict/moderate/lenient), editable
keyword lists, blocked domain list, and admin stats display. Includes
30 unit tests covering domain blocking, title/body keywords, URL paths,
edge cases, and prompt structure.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use $wpdb->prepare() with esc_like() in clear_article_cache()
- Add strict base64_decode() validation with error handling
- Add isset()/is_array()/is_string() checks for nested array access
- Add wp_http_validate_url() SSRF prevention on all image downloads
- Add limit_response_size (5MB) on image URL downloads
- Set chmod 0644 after file_put_contents()
- Use $wpdb->prepare() with esc_like() in clear_article_cache()
- Add wp_http_validate_url() SSRF prevention in download_thumbnail()
- Add limit_response_size (5MB) on image downloads
- Set chmod 0644 after file_put_contents()
- Add transient-based concurrent fetch lock