Compare commits

..

5 Commits

Author SHA1 Message Date
peptiderepo
f81ae1fdaa refactor: extract availability checks and HTTP transport from Reddit client
Move is_rss_available(), is_json_available(), get_rate_limit_status(),
and api_get() to new PRAutoBlogger_Reddit_Availability class.
Brings client from 371 to 267 lines (under 300-line cap).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:54:50 +07:00
peptiderepo
ebf4f87f98 refactor: split Reddit JSON Client into RSS parser class
Extract parse_atom_feed() and RSS-specific XML parsing logic into a new
PRAutoBlogger_Reddit_RSS_Parser class. This parser is a self-contained data
transformation (Atom XML → normalized post array) with minimal dependencies.

File sizes after split:
- class-reddit-json-client.php: 371 lines (was 468)
- class-reddit-rss-parser.php: 137 lines (new)

Both files now under the 300-line cap. The JSON client retains post/comment
fetching, availability checks, and HTTP infrastructure, while the parser
handles the XML-to-array transformation in isolation.

No callers need updating — PRAutoBlogger_Reddit_Provider uses
fetch_posts() which internally delegates to the parser.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-16 20:46:44 +07:00
peptiderepo
e7d498574f Merge pull request #17 from peptiderepo/refactor/split-cost-reporter
Refactor: Split cost reporting into separate class
2026-04-16 20:29:02 +07:00
peptiderepo
49d094b4f8 Refactor: Split cost reporting into separate class
Extract read-only reporting methods from PRAutoBlogger_Cost_Tracker into a new
PRAutoBlogger_Cost_Reporter class. This reduces Cost_Tracker from 313 to 206
lines and Reporter to 141 lines, both under the 300-line cap.

Changed files:
- Create includes/core/class-cost-reporter.php with reporting methods:
  * get_monthly_spend(): float
  * get_daily_spend(int $days = 30): array
  * get_spend_by_stage(string $start_date, string $end_date): array
  * get_budget_utilization(): float

- Update includes/core/class-cost-tracker.php:
  * Remove reporting methods
  * Update is_budget_exceeded() and would_exceed_budget() to use Cost_Reporter
  * Keep get_current_run_cost() (tracks current pipeline run only)
  * Add @see cross-reference to Cost_Reporter

- Update templates and admin classes to instantiate Cost_Reporter:
  * templates/admin/metrics-page.php
  * templates/admin/settings-page.php
  * includes/admin/class-admin-notices.php
  * includes/admin/class-dashboard-widget.php

- Split tests/unit/Core/CostTrackerTest.php:
  * Create tests/unit/Core/CostReporterTest.php for reporter methods
  * Keep run ID and budget enforcement tests in CostTrackerTest

Autoloader automatically discovers PRAutoBlogger_Cost_Reporter class.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:27:57 +07:00
peptiderepo
7197249cab Merge PR #16: fix missing cost tracker methods + logger null guard
fix: missing cost tracker methods + logger null guard
2026-04-16 18:20:09 +07:00
11 changed files with 602 additions and 394 deletions

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
* Displays admin notices for onboarding, errors, and budget warnings. * Displays admin notices for onboarding, errors, and budget warnings.
* *
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_notices`. * Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_notices`.
* Dependencies: PRAutoBlogger_Cost_Tracker (for budget warnings). * Dependencies: PRAutoBlogger_Cost_Reporter (for budget warnings).
* *
* @see class-prautoblogger.php — Registers the hook. * @see class-prautoblogger.php — Registers the hook.
*/ */
@@ -51,8 +51,8 @@ class PRAutoBlogger_Admin_Notices {
* @return void * @return void
*/ */
private function check_budget_notice(): void { private function check_budget_notice(): void {
$cost_tracker = new PRAutoBlogger_Cost_Tracker(); $cost_reporter = new PRAutoBlogger_Cost_Reporter();
$utilization = $cost_tracker->get_budget_utilization(); $utilization = $cost_reporter->get_budget_utilization();
if ( $utilization >= 100.0 ) { if ( $utilization >= 100.0 ) {
printf( printf(

View File

@@ -8,7 +8,7 @@ declare(strict_types=1);
* and budget utilization. * and budget utilization.
* *
* Triggered by: Could be registered via wp_dashboard_setup hook. * Triggered by: Could be registered via wp_dashboard_setup hook.
* Dependencies: PRAutoBlogger_Cost_Tracker. * Dependencies: PRAutoBlogger_Cost_Reporter.
* *
* @see class-prautoblogger.php — Can register this on wp_dashboard_setup. * @see class-prautoblogger.php — Can register this on wp_dashboard_setup.
*/ */
@@ -37,10 +37,10 @@ class PRAutoBlogger_Dashboard_Widget {
* @return void * @return void
*/ */
public function render_widget(): void { public function render_widget(): void {
$cost_tracker = new PRAutoBlogger_Cost_Tracker(); $cost_reporter = new PRAutoBlogger_Cost_Reporter();
$monthly_spend = $cost_tracker->get_monthly_spend(); $monthly_spend = $cost_reporter->get_monthly_spend();
$budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 ); $budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
$utilization = $cost_tracker->get_budget_utilization(); $utilization = $cost_reporter->get_budget_utilization();
$next_run = wp_next_scheduled( 'prautoblogger_daily_generation' ); $next_run = wp_next_scheduled( 'prautoblogger_daily_generation' );

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
/**
* Provides historical cost reporting and analysis methods.
*
* Extracted from PRAutoBlogger_Cost_Tracker, this class handles all read-only
* reporting queries: monthly spend, daily spend trends, spend-by-stage breakdowns,
* and budget utilization percentage.
*
* Called by: Metrics dashboard, admin notices, and dashboard widget.
* Dependencies: WordPress $wpdb, get_option() for budget configuration.
*
* @see core/class-cost-tracker.php — Complementary class for logging and budget enforcement.
* @see admin/class-metrics-page.php — Displays cost reports.
* @see admin/class-admin-notices.php — Shows budget warnings via get_budget_utilization().
* @see admin/class-dashboard-widget.php — Displays current month costs.
*/
class PRAutoBlogger_Cost_Reporter {
/**
* Get total estimated spend for the current calendar month.
*
* @return float Total USD spend this month.
*/
public function get_monthly_spend(): float {
global $wpdb;
if ( null === $wpdb ) {
return 0.0;
}
$table = $wpdb->prefix . 'prautoblogger_generation_log';
$first_of_month = gmdate( 'Y-m-01 00:00:00' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$result = $wpdb->get_var(
$wpdb->prepare(
"SELECT COALESCE(SUM(estimated_cost), 0) FROM {$table} WHERE created_at >= %s AND response_status = 'success'",
$first_of_month
)
);
return (float) $result;
}
/**
* Get daily spend for the last N days (for the metrics dashboard chart).
*
* @param int $days Number of days to look back.
*
* @return array<string, float> Associative array of date => total_cost.
*/
public function get_daily_spend( int $days = 30 ): array {
global $wpdb;
if ( null === $wpdb ) {
return [];
}
$table = $wpdb->prefix . 'prautoblogger_generation_log';
$start_date = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost
FROM {$table}
WHERE created_at >= %s AND response_status = 'success'
GROUP BY DATE(created_at)
ORDER BY day ASC",
$start_date . ' 00:00:00'
),
ARRAY_A
);
$daily = [];
foreach ( ( $results ?: [] ) as $row ) {
$daily[ $row['day'] ] = (float) $row['total_cost'];
}
return $daily;
}
/**
* Get spend breakdown by pipeline stage for a given period.
*
* @param string $start_date Start date (Y-m-d).
* @param string $end_date End date (Y-m-d).
*
* @return array<string, array{cost: float, tokens: int, calls: int}> Breakdown by stage.
*/
public function get_spend_by_stage( string $start_date, string $end_date ): array {
global $wpdb;
if ( null === $wpdb ) {
return [];
}
$table = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT stage,
SUM(estimated_cost) as total_cost,
SUM(prompt_tokens + completion_tokens) as total_tokens,
COUNT(*) as call_count
FROM {$table}
WHERE created_at BETWEEN %s AND %s AND response_status = 'success'
GROUP BY stage",
$start_date . ' 00:00:00',
$end_date . ' 23:59:59'
),
ARRAY_A
);
$breakdown = [];
foreach ( ( $results ?: [] ) as $row ) {
$breakdown[ $row['stage'] ] = [
'cost' => (float) $row['total_cost'],
'tokens' => (int) $row['total_tokens'],
'calls' => (int) $row['call_count'],
];
}
return $breakdown;
}
/**
* Get budget utilization percentage for the current month.
*
* @return float Percentage (0-100+). Can exceed 100 if overspent.
*/
public function get_budget_utilization(): float {
$budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
if ( $budget <= 0 ) {
return 0.0;
}
return ( $this->get_monthly_spend() / $budget ) * 100.0;
}
}

View File

@@ -2,8 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* Tracks all API costs, enforces monthly budget limits, and provides * Tracks all API costs and enforces monthly budget limits.
* cost reporting data for the admin dashboard.
* *
* Every LLM API call in the plugin is logged through this class. The budget * Every LLM API call in the plugin is logged through this class. The budget
* enforcement is a hard stop — if the monthly budget is exceeded, no further * enforcement is a hard stop — if the monthly budget is exceeded, no further
@@ -16,7 +15,8 @@ declare(strict_types=1);
* @see core/class-content-analyzer.php — Calls log_api_call() after analysis. * @see core/class-content-analyzer.php — Calls log_api_call() after analysis.
* @see core/class-content-generator.php — Calls log_api_call() after each stage. * @see core/class-content-generator.php — Calls log_api_call() after each stage.
* @see core/class-chief-editor.php — Calls log_api_call() after review. * @see core/class-chief-editor.php — Calls log_api_call() after review.
* @see admin/class-metrics-page.php — Displays cost data from this class. * @see core/class-cost-reporter.php — Extracted reporting methods (read-only queries).
* @see admin/class-metrics-page.php — Displays cost data via Cost_Reporter.
* @see ARCHITECTURE.md — prab_generation_log table schema. * @see ARCHITECTURE.md — prab_generation_log table schema.
*/ */
class PRAutoBlogger_Cost_Tracker { class PRAutoBlogger_Cost_Tracker {
@@ -127,7 +127,8 @@ class PRAutoBlogger_Cost_Tracker {
return false; return false;
} }
$monthly_spend = $this->get_monthly_spend(); $reporter = new PRAutoBlogger_Cost_Reporter();
$monthly_spend = $reporter->get_monthly_spend();
// Round to 4 decimal places (0.01 cent precision) to avoid // Round to 4 decimal places (0.01 cent precision) to avoid
// floating-point representation artifacts triggering false positives. // floating-point representation artifacts triggering false positives.
@@ -189,32 +190,11 @@ class PRAutoBlogger_Cost_Tracker {
return false; return false;
} }
$projected = $this->get_monthly_spend() + $estimated_cost_usd; $reporter = new PRAutoBlogger_Cost_Reporter();
$projected = $reporter->get_monthly_spend() + $estimated_cost_usd;
return round( $projected, 4 ) >= round( $budget, 4 ); return round( $projected, 4 ) >= round( $budget, 4 );
} }
/**
* Get total estimated spend for the current calendar month.
*
* @return float Total USD spend this month.
*/
public function get_monthly_spend(): float {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_generation_log';
$first_of_month = gmdate( 'Y-m-01 00:00:00' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$result = $wpdb->get_var(
$wpdb->prepare(
"SELECT COALESCE(SUM(estimated_cost), 0) FROM {$table} WHERE created_at >= %s AND response_status = 'success'",
$first_of_month
)
);
return (float) $result;
}
/** /**
* Get the cost accumulated during the current pipeline run. * Get the cost accumulated during the current pipeline run.
* *
@@ -223,91 +203,4 @@ class PRAutoBlogger_Cost_Tracker {
public function get_current_run_cost(): float { public function get_current_run_cost(): float {
return $this->current_run_cost; return $this->current_run_cost;
} }
/**
* Get daily spend for the last N days (for the metrics dashboard chart).
*
* @param int $days Number of days to look back.
*
* @return array<string, float> Associative array of date => total_cost.
*/
public function get_daily_spend( int $days = 30 ): array {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_generation_log';
$start_date = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost
FROM {$table}
WHERE created_at >= %s AND response_status = 'success'
GROUP BY DATE(created_at)
ORDER BY day ASC",
$start_date . ' 00:00:00'
),
ARRAY_A
);
$daily = [];
foreach ( ( $results ?: [] ) as $row ) {
$daily[ $row['day'] ] = (float) $row['total_cost'];
}
return $daily;
}
/**
* Get spend breakdown by pipeline stage for a given period.
*
* @param string $start_date Start date (Y-m-d).
* @param string $end_date End date (Y-m-d).
*
* @return array<string, array{cost: float, tokens: int, calls: int}> Breakdown by stage.
*/
public function get_spend_by_stage( string $start_date, string $end_date ): array {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT stage,
SUM(estimated_cost) as total_cost,
SUM(prompt_tokens + completion_tokens) as total_tokens,
COUNT(*) as call_count
FROM {$table}
WHERE created_at BETWEEN %s AND %s AND response_status = 'success'
GROUP BY stage",
$start_date . ' 00:00:00',
$end_date . ' 23:59:59'
),
ARRAY_A
);
$breakdown = [];
foreach ( ( $results ?: [] ) as $row ) {
$breakdown[ $row['stage'] ] = [
'cost' => (float) $row['total_cost'],
'tokens' => (int) $row['total_tokens'],
'calls' => (int) $row['call_count'],
];
}
return $breakdown;
}
/**
* Get budget utilization percentage for the current month.
*
* @return float Percentage (0-100+). Can exceed 100 if overspent.
*/
public function get_budget_utilization(): float {
$budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
if ( $budget <= 0 ) {
return 0.0;
}
return ( $this->get_monthly_spend() / $budget ) * 100.0;
}
} }

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
/**
* Reddit endpoint availability checker.
*
* Probes Reddit's RSS and .json endpoints to determine which data sources
* are reachable from the current server. Datacenter IPs (Hostinger) are
* often blocked by Reddit for .json but not RSS, so the client must check
* both before deciding which strategy to use.
*
* Triggered by: PRAutoBlogger_Reddit_JSON_Client::is_available() delegation,
* Admin settings page "test connection" button.
* Dependencies: wp_remote_get(), PRAutoBlogger_Logger.
*
* @see providers/class-reddit-json-client.php — Instantiates and delegates to this class.
* @see ARCHITECTURE.md — External API integrations table.
*/
class PRAutoBlogger_Reddit_Availability {
/**
* Reddit base URL for all endpoints.
*
* @var string
*/
private const BASE_URL = 'https://www.reddit.com';
/**
* User agent — Reddit requires a descriptive UA for unauthenticated requests.
*
* @var string
*/
private const USER_AGENT = 'Mozilla/5.0 (compatible; PRAutoBlogger/0.2.1; +https://peptiderepo.com)';
/**
* Transient key for rate limit tracking (.json endpoints only).
*
* @var string
*/
private const RATE_LIMIT_TRANSIENT = 'prautoblogger_reddit_json_rate_limit';
/**
* Check if any Reddit endpoint is reachable (RSS or .json).
*
* Side effects: HTTP request to Reddit.
*
* @return bool True if at least one endpoint is accessible.
*/
public function is_available(): bool {
return $this->is_rss_available() || $this->is_json_available();
}
/**
* Check if Reddit RSS endpoints are reachable.
*
* RSS endpoints are rarely blocked by Reddit, even for datacenter IPs.
*
* Side effects: One HTTP GET to Reddit.
*
* @return bool True if RSS endpoint returns HTTP 200.
*/
public function is_rss_available(): bool {
$url = self::BASE_URL . '/r/all/hot.rss?limit=1';
$response = wp_remote_get(
$url,
[
'timeout' => 10,
'headers' => [
'User-Agent' => self::USER_AGENT,
],
]
);
if ( is_wp_error( $response ) ) {
return false;
}
return 200 === wp_remote_retrieve_response_code( $response );
}
/**
* Check if Reddit .json endpoints are reachable.
*
* Datacenter IPs are often blocked by Reddit for .json endpoints.
*
* Side effects: One HTTP GET to Reddit.
*
* @return bool True if .json endpoint returns HTTP 200.
*/
public function is_json_available(): bool {
$url = self::BASE_URL . '/r/all/hot.json?limit=1';
$response = wp_remote_get(
$url,
[
'timeout' => 10,
'headers' => [
'User-Agent' => self::USER_AGENT,
],
]
);
if ( is_wp_error( $response ) ) {
return false;
}
return 200 === wp_remote_retrieve_response_code( $response );
}
/**
* Get current rate limit status from cached .json response headers.
*
* Reddit .json endpoints return rate limit headers. RSS has no rate limits.
* The rate data is written by api_get() and read here.
*
* @return array{remaining: int, limit: int, resets_at: string}
*/
public function get_rate_limit_status(): array {
$status = get_transient( self::RATE_LIMIT_TRANSIENT );
if ( is_array( $status ) ) {
return $status;
}
return [
'remaining' => 10,
'limit' => 10,
'resets_at' => '',
];
}
/**
* Make an HTTP GET request to a Reddit .json endpoint.
*
* No authentication — just a plain GET with a descriptive User-Agent.
* Tracks rate limits from response headers when available.
*
* Side effects: HTTP request, sets rate limit transient, logs errors.
*
* @param string $url Full URL with .json suffix and query params.
*
* @return array<string, mixed>|null Decoded JSON, or null on failure.
*/
public function api_get( string $url ): ?array {
$response = wp_remote_get(
$url,
[
'timeout' => 30,
'headers' => [
'User-Agent' => self::USER_AGENT,
'Accept' => 'application/json',
],
]
);
if ( is_wp_error( $response ) ) {
PRAutoBlogger_Logger::instance()->error(
'Reddit .json GET failed: ' . $response->get_error_message(),
'reddit'
);
return null;
}
// Track rate limits from response headers.
$rate_remaining = wp_remote_retrieve_header( $response, 'x-ratelimit-remaining' );
$rate_used = wp_remote_retrieve_header( $response, 'x-ratelimit-used' );
$rate_reset = wp_remote_retrieve_header( $response, 'x-ratelimit-reset' );
if ( '' !== $rate_remaining ) {
set_transient(
self::RATE_LIMIT_TRANSIENT,
[
'remaining' => (int) $rate_remaining,
'limit' => (int) $rate_used + (int) $rate_remaining,
'resets_at' => gmdate( 'Y-m-d H:i:s', time() + (int) $rate_reset ),
],
120
);
}
$status_code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $status_code ) {
PRAutoBlogger_Logger::instance()->error(
sprintf(
'Reddit .json HTTP %d: %s',
$status_code,
substr( wp_remote_retrieve_body( $response ), 0, 300 )
),
'reddit'
);
return null;
}
return json_decode( wp_remote_retrieve_body( $response ), true );
}
}

View File

@@ -10,9 +10,11 @@ declare(strict_types=1);
* for comment fetching (comments are not available in RSS feeds). * for comment fetching (comments are not available in RSS feeds).
* *
* Triggered by: PRAutoBlogger_Reddit_Provider::collect_data(). * Triggered by: PRAutoBlogger_Reddit_Provider::collect_data().
* Dependencies: wp_remote_get(), PRAutoBlogger_Logger. * Dependencies: wp_remote_get(), PRAutoBlogger_Logger, PRAutoBlogger_Reddit_RSS_Parser.
* *
* @see providers/class-reddit-provider.php — Instantiates and calls this class. * @see providers/class-reddit-provider.php — Instantiates and calls this class.
* @see providers/class-reddit-rss-parser.php — Parses Atom XML into post arrays.
* @see providers/class-reddit-availability.php — Endpoint health checks.
* @see ARCHITECTURE.md — External API integrations table. * @see ARCHITECTURE.md — External API integrations table.
*/ */
class PRAutoBlogger_Reddit_JSON_Client { class PRAutoBlogger_Reddit_JSON_Client {
@@ -39,13 +41,6 @@ class PRAutoBlogger_Reddit_JSON_Client {
*/ */
private const TIMEOUT_SECONDS = 30; private const TIMEOUT_SECONDS = 30;
/**
* Transient key for rate limit tracking (.json endpoints only).
*
* @var string
*/
private const RATE_LIMIT_TRANSIENT = 'prautoblogger_reddit_json_rate_limit';
/** /**
* Fetch hot posts from a subreddit. Tries RSS first, .json fallback. * Fetch hot posts from a subreddit. Tries RSS first, .json fallback.
* *
@@ -129,7 +124,8 @@ class PRAutoBlogger_Reddit_JSON_Client {
} }
$body = wp_remote_retrieve_body( $response ); $body = wp_remote_retrieve_body( $response );
return $this->parse_atom_feed( $body, $subreddit ); $parser = new PRAutoBlogger_Reddit_RSS_Parser();
return $parser->parse( $body, $subreddit );
} }
/** /**
@@ -173,105 +169,6 @@ class PRAutoBlogger_Reddit_JSON_Client {
return $posts; return $posts;
} }
/**
* Parse a Reddit Atom feed into post-data arrays matching .json format.
*
* Maps Atom entry fields to the standard Reddit post fields so the rest
* of the pipeline can consume RSS data without changes. Fields unavailable
* in RSS (score, num_comments, upvote_ratio) are set to reasonable defaults.
*
* @param string $xml Raw Atom XML string.
* @param string $subreddit The subreddit name for context.
*
* @return array<int, array<string, mixed>> Parsed post data.
*/
private function parse_atom_feed( string $xml, string $subreddit ): array {
// Suppress XML errors for malformed feeds.
$prev_errors = libxml_use_internal_errors( true );
$feed = simplexml_load_string( $xml );
libxml_use_internal_errors( $prev_errors );
if ( false === $feed ) {
PRAutoBlogger_Logger::instance()->error( 'Failed to parse Reddit RSS XML.', 'reddit' );
return [];
}
// Register the Atom namespace.
$feed->registerXPathNamespace( 'atom', 'http://www.w3.org/2005/Atom' );
$entries = $feed->xpath( '//atom:entry' );
if ( empty( $entries ) ) {
return [];
}
$posts = [];
foreach ( $entries as $entry ) {
$title = (string) $entry->title;
$link = '';
$content = '';
// Get the HTML link (type="text/html").
foreach ( $entry->link as $link_el ) {
if ( 'text/html' === (string) $link_el['type'] || 'alternate' === (string) $link_el['rel'] ) {
$link = (string) $link_el['href'];
break;
}
}
// Content is in <content> tag as HTML.
if ( isset( $entry->content ) ) {
$content = wp_strip_all_tags( (string) $entry->content );
}
// Extract post ID from the entry id (format: /r/subreddit/comments/ID/...).
$entry_id = (string) $entry->id;
$post_id = '';
if ( preg_match( '#/comments/([a-z0-9]+)#', $entry_id, $m ) ) {
$post_id = $m[1];
}
// Extract author name.
$author = '[deleted]';
if ( isset( $entry->author->name ) ) {
$author = str_replace( '/u/', '', (string) $entry->author->name );
}
// Parse published date to Unix timestamp.
$published = (string) ( $entry->published ?? $entry->updated ?? '' );
$created_utc = '' !== $published ? (int) strtotime( $published ) : time();
// Extract permalink (relative path) from full URL.
$permalink = '';
if ( '' !== $link ) {
$parsed = wp_parse_url( $link );
$permalink = $parsed['path'] ?? '';
}
$posts[] = [
'id' => $post_id,
'title' => $title,
'selftext' => $content,
'author' => $author,
'score' => 1, // Not available in RSS.
'num_comments' => 0, // Not available in RSS.
'permalink' => $permalink,
'created_utc' => $created_utc,
'is_self' => true,
'link_flair_text' => null,
'upvote_ratio' => null,
'is_original_content' => false,
'data_source' => 'reddit_rss',
];
}
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Parsed %d posts from Reddit RSS for r/%s.', count( $posts ), $subreddit ),
'reddit'
);
return $posts;
}
/** /**
* Fetch top comments for a specific post via Reddit's .json endpoint. * Fetch top comments for a specific post via Reddit's .json endpoint.
* *
@@ -324,145 +221,47 @@ class PRAutoBlogger_Reddit_JSON_Client {
* @return bool True if we got a valid response from either endpoint. * @return bool True if we got a valid response from either endpoint.
*/ */
public function is_available(): bool { public function is_available(): bool {
return $this->is_rss_available() || $this->is_json_available(); return ( new PRAutoBlogger_Reddit_Availability() )->is_available();
} }
/** /**
* Check if Reddit RSS endpoints are reachable. * Check if Reddit RSS endpoints are reachable.
* *
* RSS endpoints are rarely blocked by Reddit, even for datacenter IPs.
*
* @return bool True if RSS endpoint returns HTTP 200. * @return bool True if RSS endpoint returns HTTP 200.
*/ */
public function is_rss_available(): bool { public function is_rss_available(): bool {
$url = self::BASE_URL . '/r/all/hot.rss?limit=1'; return ( new PRAutoBlogger_Reddit_Availability() )->is_rss_available();
$response = wp_remote_get(
$url,
[
'timeout' => 10,
'headers' => [
'User-Agent' => self::USER_AGENT,
],
]
);
if ( is_wp_error( $response ) ) {
return false;
}
return 200 === wp_remote_retrieve_response_code( $response );
} }
/** /**
* Check if Reddit .json endpoints are reachable. * Check if Reddit .json endpoints are reachable.
* *
* Datacenter IPs are often blocked by Reddit for .json endpoints.
*
* @return bool True if .json endpoint returns HTTP 200. * @return bool True if .json endpoint returns HTTP 200.
*/ */
public function is_json_available(): bool { public function is_json_available(): bool {
$url = self::BASE_URL . '/r/all/hot.json?limit=1'; return ( new PRAutoBlogger_Reddit_Availability() )->is_json_available();
$response = wp_remote_get(
$url,
[
'timeout' => 10,
'headers' => [
'User-Agent' => self::USER_AGENT,
],
]
);
if ( is_wp_error( $response ) ) {
return false;
}
return 200 === wp_remote_retrieve_response_code( $response );
} }
/** /**
* Get current rate limit status. * Get current rate limit status.
* *
* Reddit .json endpoints return rate limit headers. RSS has no rate limits.
*
* @return array{remaining: int, limit: int, resets_at: string} * @return array{remaining: int, limit: int, resets_at: string}
*/ */
public function get_rate_limit_status(): array { public function get_rate_limit_status(): array {
$status = get_transient( self::RATE_LIMIT_TRANSIENT ); return ( new PRAutoBlogger_Reddit_Availability() )->get_rate_limit_status();
if ( is_array( $status ) ) {
return $status;
}
return [
'remaining' => 10,
'limit' => 10,
'resets_at' => '',
];
} }
/** /**
* Make an HTTP GET request to a Reddit .json endpoint. * Make an HTTP GET request to a Reddit .json endpoint.
* *
* No authentication — just a plain GET with a descriptive User-Agent. * Delegates to PRAutoBlogger_Reddit_Availability which owns the HTTP
* Tracks rate limits from response headers when available. * transport layer and rate-limit tracking.
*
* Side effects: HTTP request, sets rate limit transient, logs errors.
* *
* @param string $url Full URL with .json suffix and query params. * @param string $url Full URL with .json suffix and query params.
* *
* @return array<string, mixed>|null Decoded JSON, or null on failure. * @return array<string, mixed>|null Decoded JSON, or null on failure.
*/ */
private function api_get( string $url ): ?array { private function api_get( string $url ): ?array {
$response = wp_remote_get( return ( new PRAutoBlogger_Reddit_Availability() )->api_get( $url );
$url,
[
'timeout' => self::TIMEOUT_SECONDS,
'headers' => [
'User-Agent' => self::USER_AGENT,
'Accept' => 'application/json',
],
]
);
if ( is_wp_error( $response ) ) {
PRAutoBlogger_Logger::instance()->error(
'Reddit .json GET failed: ' . $response->get_error_message(),
'reddit'
);
return null;
}
// Track rate limits from response headers.
$rate_remaining = wp_remote_retrieve_header( $response, 'x-ratelimit-remaining' );
$rate_used = wp_remote_retrieve_header( $response, 'x-ratelimit-used' );
$rate_reset = wp_remote_retrieve_header( $response, 'x-ratelimit-reset' );
if ( '' !== $rate_remaining ) {
set_transient(
self::RATE_LIMIT_TRANSIENT,
[
'remaining' => (int) $rate_remaining,
'limit' => (int) $rate_used + (int) $rate_remaining,
'resets_at' => gmdate( 'Y-m-d H:i:s', time() + (int) $rate_reset ),
],
120
);
}
$status_code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $status_code ) {
PRAutoBlogger_Logger::instance()->error(
sprintf(
'Reddit .json HTTP %d: %s',
$status_code,
substr( wp_remote_retrieve_body( $response ), 0, 300 )
),
'reddit'
);
return null;
}
return json_decode( wp_remote_retrieve_body( $response ), true );
} }
} }

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
/**
* Reddit RSS/Atom feed parser — parses Atom XML into normalized post arrays.
*
* Handles parsing of Reddit's Atom/RSS feeds and mapping them to the standard
* Reddit post data format (same shape as .json responses). This parser is
* self-contained: it takes raw XML and produces normalized arrays.
*
* Fields unavailable in RSS feeds (score, num_comments, upvote_ratio) are set
* to sensible defaults so the rest of the pipeline can treat RSS and .json data uniformly.
*
* Usage: Called by PRAutoBlogger_Reddit_JSON_Client::fetch_posts_via_rss()
* to parse the response body.
*
* Dependencies: wp_strip_all_tags(), PRAutoBlogger_Logger, built-in SimpleXML.
*
* @see providers/class-reddit-json-client.php — Main client that calls this parser.
* @see ARCHITECTURE.md — External API integrations table.
*/
class PRAutoBlogger_Reddit_RSS_Parser {
/**
* Parse a Reddit Atom feed into post-data arrays matching .json format.
*
* Maps Atom entry fields to the standard Reddit post fields so the rest
* of the pipeline can consume RSS data without changes. Fields unavailable
* in RSS (score, num_comments, upvote_ratio) are set to reasonable defaults.
*
* @param string $xml Raw Atom XML string.
* @param string $subreddit The subreddit name for context (logging only).
*
* @return array<int, array<string, mixed>> Parsed post data.
*/
public function parse( string $xml, string $subreddit ): array {
// Suppress XML errors for malformed feeds.
$prev_errors = libxml_use_internal_errors( true );
$feed = simplexml_load_string( $xml );
libxml_use_internal_errors( $prev_errors );
if ( false === $feed ) {
PRAutoBlogger_Logger::instance()->error( 'Failed to parse Reddit RSS XML.', 'reddit' );
return [];
}
// Register the Atom namespace.
$feed->registerXPathNamespace( 'atom', 'http://www.w3.org/2005/Atom' );
$entries = $feed->xpath( '//atom:entry' );
if ( empty( $entries ) ) {
return [];
}
$posts = [];
foreach ( $entries as $entry ) {
$post = $this->parse_entry( $entry, $subreddit );
if ( null !== $post ) {
$posts[] = $post;
}
}
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Parsed %d posts from Reddit RSS for r/%s.', count( $posts ), $subreddit ),
'reddit'
);
return $posts;
}
/**
* Parse a single Atom entry element into a normalized post array.
*
* @param \SimpleXMLElement $entry An Atom entry element.
* @param string $subreddit The subreddit name (for context/logging).
*
* @return array<string, mixed>|null The parsed post data, or null if parsing fails.
*/
private function parse_entry( $entry, string $subreddit ): ?array {
$title = (string) $entry->title;
$link = '';
$content = '';
// Get the HTML link (type="text/html").
foreach ( $entry->link as $link_el ) {
if ( 'text/html' === (string) $link_el['type'] || 'alternate' === (string) $link_el['rel'] ) {
$link = (string) $link_el['href'];
break;
}
}
// Content is in <content> tag as HTML.
if ( isset( $entry->content ) ) {
$content = wp_strip_all_tags( (string) $entry->content );
}
// Extract post ID from the entry id (format: /r/subreddit/comments/ID/...).
$entry_id = (string) $entry->id;
$post_id = '';
if ( preg_match( '#/comments/([a-z0-9]+)#', $entry_id, $m ) ) {
$post_id = $m[1];
}
// Extract author name.
$author = '[deleted]';
if ( isset( $entry->author->name ) ) {
$author = str_replace( '/u/', '', (string) $entry->author->name );
}
// Parse published date to Unix timestamp.
$published = (string) ( $entry->published ?? $entry->updated ?? '' );
$created_utc = '' !== $published ? (int) strtotime( $published ) : time();
// Extract permalink (relative path) from full URL.
$permalink = '';
if ( '' !== $link ) {
$parsed = wp_parse_url( $link );
$permalink = $parsed['path'] ?? '';
}
return [
'id' => $post_id,
'title' => $title,
'selftext' => $content,
'author' => $author,
'score' => 1, // Not available in RSS.
'num_comments' => 0, // Not available in RSS.
'permalink' => $permalink,
'created_utc' => $created_utc,
'is_self' => true,
'link_flair_text' => null,
'upvote_ratio' => null,
'is_original_content' => false,
'data_source' => 'reddit_rss',
];
}
}

View File

@@ -9,15 +9,15 @@ if ( ! defined( 'ABSPATH' ) ) {
exit; exit;
} }
$cost_tracker = new PRAutoBlogger_Cost_Tracker(); $cost_reporter = new PRAutoBlogger_Cost_Reporter();
$monthly_spend = $cost_tracker->get_monthly_spend(); $monthly_spend = $cost_reporter->get_monthly_spend();
$budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 ); $budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
$utilization = $cost_tracker->get_budget_utilization(); $utilization = $cost_reporter->get_budget_utilization();
$daily_spend = $cost_tracker->get_daily_spend( 30 ); $daily_spend = $cost_reporter->get_daily_spend( 30 );
$first_of_month = gmdate( 'Y-m-01' ); $first_of_month = gmdate( 'Y-m-01' );
$today = gmdate( 'Y-m-d' ); $today = gmdate( 'Y-m-d' );
$stage_breakdown = $cost_tracker->get_spend_by_stage( $first_of_month, $today ); $stage_breakdown = $cost_reporter->get_spend_by_stage( $first_of_month, $today );
?> ?>
<div class="wrap prautoblogger-metrics"> <div class="wrap prautoblogger-metrics">
<h1><?php esc_html_e( 'PRAutoBlogger — Metrics & Costs', 'prautoblogger' ); ?></h1> <h1><?php esc_html_e( 'PRAutoBlogger — Metrics & Costs', 'prautoblogger' ); ?></h1>

View File

@@ -14,8 +14,8 @@ if ( ! defined( 'ABSPATH' ) ) {
} }
$active_tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'prautoblogger_api'; $active_tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'prautoblogger_api';
$cost_tracker = new PRAutoBlogger_Cost_Tracker(); $cost_reporter = new PRAutoBlogger_Cost_Reporter();
$monthly_spend = $cost_tracker->get_monthly_spend(); $monthly_spend = $cost_reporter->get_monthly_spend();
$budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 ); $budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
$utilization = $budget > 0 ? ( $monthly_spend / $budget ) * 100.0 : 0; $utilization = $budget > 0 ? ( $monthly_spend / $budget ) * 100.0 : 0;
$next_run = wp_next_scheduled( 'prautoblogger_daily_generation' ); $next_run = wp_next_scheduled( 'prautoblogger_daily_generation' );

View File

@@ -0,0 +1,93 @@
<?php
/**
* Tests for PRAutoBlogger_Cost_Reporter.
*
* Validates monthly spend, daily spend trends, spend-by-stage breakdowns,
* and budget utilization reporting methods.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class CostReporterTest extends BaseTestCase {
/**
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb instance.
*/
private $wpdb;
protected function setUp(): void {
parent::setUp();
$this->wpdb = $this->create_mock_wpdb();
$GLOBALS['wpdb'] = $this->wpdb;
// CostReporter methods call get_option for budget settings.
$this->stub_get_option( [
'prautoblogger_monthly_budget_usd' => '50.00',
] );
// Stub current_time used in date-based queries.
$this->stub_current_time( '2026-04-12 10:00:00' );
}
protected function tearDown(): void {
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/**
* Test get_monthly_spend returns float.
*/
public function test_get_monthly_spend_returns_float(): void {
$this->wpdb->method( 'prepare' )->willReturn( 'prepared' );
$this->wpdb->method( 'get_var' )->willReturn( '10.50' );
$reporter = new \PRAutoBlogger_Cost_Reporter();
$spend = $reporter->get_monthly_spend();
$this->assertIsFloat( $spend );
$this->assertGreaterThanOrEqual( 0.0, $spend );
}
/**
* Test get_daily_spend returns array.
*/
public function test_get_daily_spend_returns_array(): void {
$this->wpdb->method( 'prepare' )->willReturn( 'prepared' );
$this->wpdb->method( 'get_results' )->willReturn( [] );
$reporter = new \PRAutoBlogger_Cost_Reporter();
$daily = $reporter->get_daily_spend( 30 );
$this->assertIsArray( $daily );
}
/**
* Test get_spend_by_stage returns array.
*/
public function test_get_spend_by_stage_returns_array(): void {
$this->wpdb->method( 'prepare' )->willReturn( 'prepared' );
$this->wpdb->method( 'get_results' )->willReturn( [] );
$reporter = new \PRAutoBlogger_Cost_Reporter();
$spend = $reporter->get_spend_by_stage( '2026-04-01', '2026-04-30' );
$this->assertIsArray( $spend );
}
/**
* Test get_budget_utilization returns float between 0 and 100+.
*/
public function test_get_budget_utilization_returns_float(): void {
$reporter = new \PRAutoBlogger_Cost_Reporter();
$utilization = $reporter->get_budget_utilization();
$this->assertIsFloat( $utilization );
$this->assertGreaterThanOrEqual( 0.0, $utilization );
}
}

View File

@@ -68,20 +68,6 @@ class CostTrackerTest extends BaseTestCase {
$this->assertFalse( $tracker->is_budget_exceeded() ); $this->assertFalse( $tracker->is_budget_exceeded() );
} }
/**
* Test get_monthly_spend returns float.
*/
public function test_get_monthly_spend_returns_float(): void {
$this->wpdb->method( 'prepare' )->willReturn( 'prepared' );
$this->wpdb->method( 'get_var' )->willReturn( '10.50' );
$tracker = new \PRAutoBlogger_Cost_Tracker();
$spend = $tracker->get_monthly_spend();
$this->assertIsFloat( $spend );
$this->assertGreaterThanOrEqual( 0.0, $spend );
}
/** /**
* Test get_current_run_cost returns float. * Test get_current_run_cost returns float.
*/ */
@@ -98,43 +84,6 @@ class CostTrackerTest extends BaseTestCase {
$this->assertGreaterThanOrEqual( 0.0, $cost ); $this->assertGreaterThanOrEqual( 0.0, $cost );
} }
/**
* Test get_daily_spend returns array.
*/
public function test_get_daily_spend_returns_array(): void {
$this->wpdb->method( 'prepare' )->willReturn( 'prepared' );
$this->wpdb->method( 'get_results' )->willReturn( [] );
$tracker = new \PRAutoBlogger_Cost_Tracker();
$daily = $tracker->get_daily_spend( 30 );
$this->assertIsArray( $daily );
}
/**
* Test get_spend_by_stage returns array.
*/
public function test_get_spend_by_stage_returns_array(): void {
$this->wpdb->method( 'prepare' )->willReturn( 'prepared' );
$this->wpdb->method( 'get_results' )->willReturn( [] );
$tracker = new \PRAutoBlogger_Cost_Tracker();
$spend = $tracker->get_spend_by_stage( '2026-04-01', '2026-04-30' );
$this->assertIsArray( $spend );
}
/**
* Test get_budget_utilization returns float between 0 and 1.
*/
public function test_get_budget_utilization_returns_float(): void {
$tracker = new \PRAutoBlogger_Cost_Tracker();
$utilization = $tracker->get_budget_utilization();
$this->assertIsFloat( $utilization );
$this->assertGreaterThanOrEqual( 0.0, $utilization );
}
/** /**
* Test log_api_call with mock database. * Test log_api_call with mock database.
*/ */