diff --git a/includes/providers/class-pull-push-client.php b/includes/providers/class-pull-push-client.php new file mode 100644 index 0000000..72fa969 --- /dev/null +++ b/includes/providers/class-pull-push-client.php @@ -0,0 +1,352 @@ +> Array of submission data arrays. + */ + public function fetch_posts( string $subreddit, int $limit, string $time_filter = 'day' ): array { + $size = min( $limit, self::MAX_SIZE ); + $after = $this->time_filter_to_epoch( $time_filter ); + + $url = sprintf( + '%s/search/submission?subreddit=%s&sort=score&sort_type=desc&size=%d&after=%d', + self::API_BASE, + rawurlencode( $subreddit ), + $size, + $after + ); + + $response = $this->api_get( $url ); + + if ( null === $response ) { + return []; + } + + // PullPush returns { "data": [...] } or just [...] depending on endpoint version. + $items = $response['data'] ?? $response; + + if ( ! is_array( $items ) ) { + return []; + } + + return $items; + } + + /** + * Fetch recent comments from a subreddit, sorted by score. + * + * Unlike the Reddit API which fetches comments per-post, PullPush lets us + * search comments across an entire subreddit. This is more efficient for + * finding high-quality discussion content. + * + * Side effects: HTTP request to PullPush API, updates rate limit transient. + * + * @param string $subreddit Subreddit name (without r/ prefix). + * @param int $limit Max comments to fetch (capped at 100 by API). + * @param string $time_filter Time window for filtering. + * + * @return array> Array of comment data arrays. + */ + public function fetch_comments( string $subreddit, int $limit, string $time_filter = 'day' ): array { + $size = min( $limit, self::MAX_SIZE ); + $after = $this->time_filter_to_epoch( $time_filter ); + + $url = sprintf( + '%s/search/comment?subreddit=%s&sort=score&sort_type=desc&size=%d&after=%d', + self::API_BASE, + rawurlencode( $subreddit ), + $size, + $after + ); + + $response = $this->api_get( $url ); + + if ( null === $response ) { + return []; + } + + $items = $response['data'] ?? $response; + + if ( ! is_array( $items ) ) { + return []; + } + + return $items; + } + + /** + * Fetch comments for a specific post by its Reddit ID. + * + * Uses the link_id filter (t3_ prefixed) to get comments for one post. + * + * Side effects: HTTP request to PullPush API. + * + * @param string $post_id Reddit post ID (without t3_ prefix). + * @param int $limit Max comments to fetch. + * + * @return array> Array of comment data arrays. + */ + public function fetch_comments_for_post( string $post_id, int $limit = 5 ): array { + $size = min( $limit, self::MAX_SIZE ); + + $url = sprintf( + '%s/search/comment?link_id=t3_%s&sort=score&sort_type=desc&size=%d', + self::API_BASE, + rawurlencode( $post_id ), + $size + ); + + $response = $this->api_get( $url ); + + if ( null === $response ) { + return []; + } + + $items = $response['data'] ?? $response; + + return is_array( $items ) ? $items : []; + } + + /** + * Check if PullPush API is reachable. + * + * Makes a minimal request to verify connectivity. Used by the provider + * to decide whether to fall back to Reddit .json endpoints. + * + * Side effects: HTTP request. + * + * @return bool True if API responded with HTTP 200. + */ + public function is_available(): bool { + // Minimal query: 1 submission from a popular subreddit. + $url = self::API_BASE . '/search/submission?subreddit=all&size=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. + * + * PullPush doesn't return rate limit headers, so we track our own + * request count using a transient with a 60-second TTL. + * + * @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; + } + + // No tracking data yet — assume fresh window. + return [ + 'remaining' => 30, + 'limit' => 30, + 'resets_at' => '', + ]; + } + + /** + * Make an HTTP GET request to the PullPush API. + * + * Handles error logging, response parsing, and self-imposed rate limit tracking. + * No authentication required — PullPush is a free public API. + * + * Side effects: HTTP request, sets rate limit transient, logs errors. + * + * @param string $url Full API URL with query parameters. + * + * @return array|null Decoded JSON response, or null on failure. + */ + private function api_get( string $url ): ?array { + // Self-imposed rate limiting: track requests per minute. + $this->track_rate_limit(); + + $response = wp_remote_get( + $url, + [ + 'timeout' => self::TIMEOUT_SECONDS, + 'headers' => [ + 'User-Agent' => self::USER_AGENT, + 'Accept' => 'application/json', + ], + ] + ); + + if ( is_wp_error( $response ) ) { + PRAutoBlogger_Logger::instance()->error( + 'PullPush API GET failed: ' . $response->get_error_message(), + 'pullpush' + ); + return null; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + + if ( 200 !== $status_code ) { + PRAutoBlogger_Logger::instance()->error( + sprintf( + 'PullPush API HTTP %d: %s', + $status_code, + substr( wp_remote_retrieve_body( $response ), 0, 300 ) + ), + 'pullpush' + ); + return null; + } + + $body = wp_remote_retrieve_body( $response ); + $data = json_decode( $body, true ); + + if ( ! is_array( $data ) ) { + PRAutoBlogger_Logger::instance()->error( + 'PullPush API returned non-JSON response.', + 'pullpush' + ); + return null; + } + + return $data; + } + + /** + * Track our own request count to avoid exceeding PullPush's 30 req/min limit. + * + * Uses a transient that stores the request count and window start time. + * If we're close to the limit, we don't block — the caller should check + * get_rate_limit_status() before making bulk requests. + * + * Side effects: Sets transient. + */ + private function track_rate_limit(): void { + $status = get_transient( self::RATE_LIMIT_TRANSIENT ); + + if ( ! is_array( $status ) ) { + $status = [ + 'remaining' => 29, // This request uses one. + 'limit' => 30, + 'resets_at' => gmdate( 'Y-m-d H:i:s', time() + 60 ), + ]; + } else { + $status['remaining'] = max( 0, (int) $status['remaining'] - 1 ); + } + + // TTL of 60 seconds — the window auto-resets. + set_transient( self::RATE_LIMIT_TRANSIENT, $status, 60 ); + } + + /** + * Convert a human-readable time filter to a Unix epoch timestamp. + * + * PullPush uses `after` parameter as a Unix timestamp, not Reddit's + * `t=day` style. This maps between the two conventions. + * + * @param string $time_filter One of: 'hour', 'day', 'week', 'month', 'year', 'all'. + * + * @return int Unix timestamp to use as the `after` parameter. + */ + private function time_filter_to_epoch( string $time_filter ): int { + $now = time(); + + switch ( $time_filter ) { + case 'hour': + return $now - 3600; + case 'day': + return $now - 86400; + case 'week': + return $now - 604800; + case 'month': + return $now - 2592000; + case 'year': + return $now - 31536000; + case 'all': + return 0; + default: + return $now - 86400; // Default to 1 day. + } + } +} diff --git a/includes/providers/class-reddit-api-client.php b/includes/providers/class-reddit-api-client.php deleted file mode 100755 index 7d6008a..0000000 --- a/includes/providers/class-reddit-api-client.php +++ /dev/null @@ -1,207 +0,0 @@ - 15, - 'headers' => [ - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode - 'Authorization' => 'Basic ' . base64_encode( $client_id . ':' . $client_secret ), - 'User-Agent' => self::USER_AGENT, - ], - 'body' => [ - 'grant_type' => 'client_credentials', - ], - ] - ); - - if ( is_wp_error( $response ) ) { - PRAutoBlogger_Logger::instance()->error( 'Reddit OAuth failed: ' . $response->get_error_message(), 'reddit' ); - return ''; - } - - if ( 200 !== wp_remote_retrieve_response_code( $response ) ) { - PRAutoBlogger_Logger::instance()->error( 'Reddit OAuth HTTP ' . wp_remote_retrieve_response_code( $response ), 'reddit' ); - return ''; - } - - $data = json_decode( wp_remote_retrieve_body( $response ), true ); - if ( ! isset( $data['access_token'] ) ) { - PRAutoBlogger_Logger::instance()->error( 'Reddit OAuth response missing access_token.', 'reddit' ); - return ''; - } - - $token = $data['access_token']; - $expires = isset( $data['expires_in'] ) ? (int) $data['expires_in'] - 60 : 3540; - - set_transient( 'prautoblogger_reddit_token', $token, $expires ); - - return $token; - } - - /** - * Fetch posts from a subreddit. - * - * @param string $token OAuth access token. - * @param string $subreddit Subreddit name (without r/ prefix). - * @param int $limit Max posts to fetch. - * @param string $time_filter Time filter for /top (hour, day, week, month, year, all). - * - * @return array> Array of post data. - */ - public function fetch_posts( string $token, string $subreddit, int $limit, string $time_filter ): array { - $url = sprintf( - '%s/r/%s/hot?limit=%d&t=%s', - self::API_BASE, - rawurlencode( $subreddit ), - min( $limit, 100 ), - rawurlencode( $time_filter ) - ); - - $response = $this->api_get( $token, $url ); - if ( null === $response || ! isset( $response['data']['children'] ) ) { - return []; - } - - $posts = []; - foreach ( $response['data']['children'] as $child ) { - if ( isset( $child['data'] ) ) { - $posts[] = $child['data']; - } - } - - return $posts; - } - - /** - * Fetch top comments for a specific post. - * - * @param string $token OAuth access token. - * @param string $subreddit Subreddit name. - * @param string $post_id Reddit post ID (without t3_ prefix). - * @param int $limit Max comments to fetch. - * - * @return array> Array of comment data. - */ - public function fetch_comments( string $token, string $subreddit, string $post_id, int $limit ): array { - $url = sprintf( - '%s/r/%s/comments/%s?limit=%d&sort=top&depth=1', - self::API_BASE, - rawurlencode( $subreddit ), - rawurlencode( $post_id ), - min( $limit, 50 ) - ); - - $response = $this->api_get( $token, $url ); - - // Comments endpoint returns [post_listing, comments_listing]. - if ( ! is_array( $response ) || ! isset( $response[1]['data']['children'] ) ) { - return []; - } - - $comments = []; - foreach ( $response[1]['data']['children'] as $child ) { - if ( isset( $child['data'] ) && 't1' === ( $child['kind'] ?? '' ) ) { - $comments[] = $child['data']; - } - } - - return $comments; - } - - /** - * Make an authenticated GET request to the Reddit API. - * - * Updates rate limit tracking from response headers. - * - * @param string $token OAuth access token. - * @param string $url Full API URL. - * - * @return array|null Decoded response, or null on failure. - */ - public function api_get( string $token, string $url ): ?array { - $response = wp_remote_get( - $url, - [ - 'timeout' => 30, - 'headers' => [ - 'Authorization' => 'Bearer ' . $token, - 'User-Agent' => self::USER_AGENT, - ], - ] - ); - - if ( is_wp_error( $response ) ) { - PRAutoBlogger_Logger::instance()->error( 'Reddit API 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_limit = wp_remote_retrieve_header( $response, 'x-ratelimit-used' ); - $rate_reset = wp_remote_retrieve_header( $response, 'x-ratelimit-reset' ); - - if ( '' !== $rate_remaining ) { - set_transient( 'prautoblogger_reddit_rate_limit', [ - 'remaining' => (int) $rate_remaining, - 'limit' => (int) $rate_limit + (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 API 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 ); - } -} diff --git a/includes/providers/class-reddit-json-client.php b/includes/providers/class-reddit-json-client.php new file mode 100644 index 0000000..a26adf4 --- /dev/null +++ b/includes/providers/class-reddit-json-client.php @@ -0,0 +1,249 @@ +> Array of post data. + */ + public function fetch_posts( string $subreddit, int $limit, string $time_filter = 'day' ): array { + $url = sprintf( + '%s/r/%s/hot.json?limit=%d&t=%s&raw_json=1', + self::BASE_URL, + rawurlencode( $subreddit ), + min( $limit, 100 ), + rawurlencode( $time_filter ) + ); + + $response = $this->api_get( $url ); + + if ( null === $response || ! isset( $response['data']['children'] ) ) { + return []; + } + + $posts = []; + foreach ( $response['data']['children'] as $child ) { + if ( isset( $child['data'] ) ) { + $posts[] = $child['data']; + } + } + + return $posts; + } + + /** + * Fetch top comments for a specific post via Reddit's .json endpoint. + * + * Returns data in the same format as the old OAuth client's fetch_comments(): + * an array of comment data arrays with standard Reddit field names. + * + * Side effects: HTTP request, updates rate limit transient. + * + * @param string $subreddit Subreddit name (without r/ prefix). + * @param string $post_id Reddit post ID (without t3_ prefix). + * @param int $limit Max comments to fetch. + * + * @return array> Array of comment data. + */ + public function fetch_comments( string $subreddit, string $post_id, int $limit = 5 ): array { + $url = sprintf( + '%s/r/%s/comments/%s.json?limit=%d&sort=top&depth=1&raw_json=1', + self::BASE_URL, + rawurlencode( $subreddit ), + rawurlencode( $post_id ), + min( $limit, 100 ) + ); + + $response = $this->api_get( $url ); + + // Comments endpoint returns [post_listing, comments_listing]. + if ( ! is_array( $response ) || ! isset( $response[1]['data']['children'] ) ) { + return []; + } + + $comments = []; + foreach ( $response[1]['data']['children'] as $child ) { + // Filter out "more" type entries, keep only actual comments (kind=t1). + if ( isset( $child['data'] ) && 't1' === ( $child['kind'] ?? '' ) ) { + $comments[] = $child['data']; + } + } + + return $comments; + } + + /** + * Check if Reddit .json endpoints are reachable from this server. + * + * Datacenter IPs are often blocked by Reddit. This quick check lets the + * provider know whether this fallback is actually usable. + * + * Side effects: HTTP request. + * + * @return bool True if we got a valid JSON response. + */ + public function is_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; + } + + $code = wp_remote_retrieve_response_code( $response ); + + // Reddit returns 429 when rate-limited and 403 when datacenter IP is blocked. + return 200 === $code; + } + + /** + * Get current rate limit status. + * + * Reddit .json endpoints return rate limit headers similar to the OAuth API. + * We track them in a transient. + * + * @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|null Decoded JSON, or null on failure. + */ + private function api_get( string $url ): ?array { + $response = wp_remote_get( + $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_json' + ); + return null; + } + + // Track rate limits from response headers (same as OAuth API). + $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_json' + ); + return null; + } + + return json_decode( wp_remote_retrieve_body( $response ), true ); + } +} diff --git a/includes/providers/class-reddit-provider.php b/includes/providers/class-reddit-provider.php index b6b86aa..9dc75ad 100755 --- a/includes/providers/class-reddit-provider.php +++ b/includes/providers/class-reddit-provider.php @@ -2,121 +2,94 @@ declare(strict_types=1); /** - * Reddit API integration for collecting posts and comments from target subreddits. + * Reddit data provider — collects posts and comments from target subreddits. * - * Uses Reddit's OAuth2 "script" app type for server-to-server authentication. - * Collects hot/top posts and their top comments from configured subreddits, - * storing them in the ab_source_data table. + * Uses PullPush.io as the PRIMARY data source (free, no auth required, 30 req/min). + * Falls back to Reddit .json endpoints if PullPush is unavailable. + * Reddit OAuth was removed because Reddit rejected our API application. * * Triggered by: Source_Collector calls collect_data() on each enabled provider. - * Dependencies: PRAutoBlogger_Reddit_API_Client (for HTTP calls and auth). + * Dependencies: PRAutoBlogger_PullPush_Client (primary), PRAutoBlogger_Reddit_JSON_Client (fallback). * - * @see interface-source-provider.php — Interface this class implements. - * @see providers/class-reddit-api-client.php — HTTP client for Reddit API. - * @see core/class-source-collector.php — Orchestrates collection from all providers. - * @see ARCHITECTURE.md — External API integrations table. + * @see interface-source-provider.php — Interface this class implements. + * @see providers/class-pullpush-client.php — Primary HTTP client (PullPush.io). + * @see providers/class-reddit-json-client.php — Fallback HTTP client (Reddit .json). + * @see core/class-source-collector.php — Orchestrates collection from all providers. + * @see ARCHITECTURE.md — External API integrations table. */ class PRAutoBlogger_Reddit_Provider implements PRAutoBlogger_Source_Provider_Interface { + /** + * Transient key for caching PullPush availability check. + * Avoids re-checking on every request within a collection run. + * + * @var string + */ + private const PULLPUSH_AVAILABLE_TRANSIENT = 'prautoblogger_pullpush_available'; + + /** + * How long to cache PullPush availability status (5 minutes). + * + * @var int + */ + private const AVAILABILITY_CACHE_TTL = 300; + /** * Collect posts and comments from configured subreddits. * - * For each subreddit: fetches hot posts, then fetches top comments for + * Strategy: Try PullPush first (better API, subreddit-wide comment search). + * If PullPush is down, fall back to Reddit .json endpoints (same response + * format as the old OAuth API, but unauthenticated and rate-limited). + * + * For each subreddit: fetches top posts, then fetches top comments for * the most-engaged posts. Returns an array of Source_Data objects. * - * Side effects: HTTP requests to Reddit API, uses/sets transients for OAuth token. + * Side effects: HTTP requests to PullPush or Reddit, uses/sets transients. * * @param array{ - * subreddits?: string[], - * limit?: int, - * time_filter?: string, - * include_comments?: bool, - * comments_per_post?: int, + * subreddits?: string[], + * limit?: int, + * time_filter?: string, + * include_comments?: bool, + * comments_per_post?: int, * } $config Collection configuration. * * @return PRAutoBlogger_Source_Data[] - * - * @throws \RuntimeException On authentication failure or API error. */ public function collect_data( array $config ): array { - $api_client = new PRAutoBlogger_Reddit_API_Client(); - $token = $api_client->get_access_token(); - if ( '' === $token ) { - throw new \RuntimeException( - __( 'Reddit authentication failed. Check client ID and secret in settings.', 'prautoblogger' ) - ); - } - $subreddits = $config['subreddits'] ?? $this->get_configured_subreddits(); $limit = $config['limit'] ?? 25; $time_filter = $config['time_filter'] ?? 'day'; $include_comments = $config['include_comments'] ?? true; - $comments_limit = $config['comments_per_post'] ?? 10; + $comments_limit = $config['comments_per_post'] ?? 5; - $collected = []; + // Determine which client to use. + $use_pullpush = $this->is_pullpush_available(); + $collected = []; foreach ( $subreddits as $subreddit ) { $subreddit = sanitize_text_field( $subreddit ); try { - $posts = $api_client->fetch_posts( $token, $subreddit, $limit, $time_filter ); - - foreach ( $posts as $post ) { - $collected[] = new PRAutoBlogger_Source_Data( [ - 'source_type' => 'reddit', - 'source_id' => 't3_' . $post['id'], - 'subreddit' => $subreddit, - 'title' => $post['title'] ?? '', - 'content' => $post['selftext'] ?? '', - 'author' => $post['author'] ?? '[deleted]', - 'score' => (int) ( $post['score'] ?? 0 ), - 'comment_count' => (int) ( $post['num_comments'] ?? 0 ), - 'permalink' => 'https://reddit.com' . ( $post['permalink'] ?? '' ), - 'collected_at' => current_time( 'mysql' ), - 'metadata' => [ - 'post_type' => $post['is_self'] ? 'self' : 'link', - 'flair' => $post['link_flair_text'] ?? null, - 'created_utc' => $post['created_utc'] ?? 0, - 'upvote_ratio' => $post['upvote_ratio'] ?? 0, - 'is_original' => $post['is_original_content'] ?? false, - ], - ] ); - - // Collect top comments for high-engagement posts. - if ( $include_comments && ( $post['num_comments'] ?? 0 ) > 5 ) { - $comments = $api_client->fetch_comments( - $token, - $subreddit, - $post['id'], - $comments_limit - ); - - foreach ( $comments as $comment ) { - $collected[] = new PRAutoBlogger_Source_Data( [ - 'source_type' => 'reddit', - 'source_id' => 't1_' . $comment['id'], - 'subreddit' => $subreddit, - 'title' => null, - 'content' => $comment['body'] ?? '', - 'author' => $comment['author'] ?? '[deleted]', - 'score' => (int) ( $comment['score'] ?? 0 ), - 'comment_count' => 0, - 'permalink' => 'https://reddit.com' . ( $comment['permalink'] ?? '' ), - 'collected_at' => current_time( 'mysql' ), - 'metadata' => [ - 'parent_post_id' => 't3_' . $post['id'], - 'parent_title' => $post['title'] ?? '', - 'is_comment' => true, - 'created_utc' => $comment['created_utc'] ?? 0, - ], - ] ); - } - } + if ( $use_pullpush ) { + $collected = array_merge( + $collected, + $this->collect_from_pullpush( $subreddit, $limit, $time_filter, $include_comments, $comments_limit ) + ); + } else { + $collected = array_merge( + $collected, + $this->collect_from_reddit_json( $subreddit, $limit, $time_filter, $include_comments, $comments_limit ) + ); } } catch ( \Exception $e ) { // Log but continue with other subreddits. PRAutoBlogger_Logger::instance()->error( - sprintf( 'Reddit: Failed to collect from r/%s: %s', $subreddit, $e->getMessage() ), + sprintf( + 'Reddit: Failed to collect from r/%s: %s', + $subreddit, + $e->getMessage() + ), 'reddit' ); } @@ -133,37 +106,237 @@ class PRAutoBlogger_Reddit_Provider implements PRAutoBlogger_Source_Provider_Int } /** - * Validate Reddit API credentials. + * Validate that at least one data source is reachable. * - * Side effects: HTTP request to Reddit OAuth endpoint. + * No credentials needed anymore — PullPush and Reddit .json are both + * unauthenticated. We just check if either endpoint is responding. * - * @return bool + * Side effects: HTTP requests to PullPush and/or Reddit. + * + * @return bool True if at least one data source is available. */ public function validate_credentials(): bool { - $api_client = new PRAutoBlogger_Reddit_API_Client(); - $token = $api_client->get_access_token( true ); - return '' !== $token; + $pullpush = new PRAutoBlogger_PullPush_Client(); + if ( $pullpush->is_available() ) { + return true; + } + + $reddit_json = new PRAutoBlogger_Reddit_JSON_Client(); + return $reddit_json->is_available(); } /** - * Get current rate limit status from last response headers. + * Get current rate limit status from the active data source. * * @return array{remaining: int, limit: int, resets_at: string} */ public function get_rate_limit_status(): array { - // Reddit rate limits are tracked per-response via headers. - // We store the last seen values in a transient. - $status = get_transient( 'prautoblogger_reddit_rate_limit' ); - if ( is_array( $status ) ) { - return $status; + if ( $this->is_pullpush_available() ) { + $client = new PRAutoBlogger_PullPush_Client(); + return $client->get_rate_limit_status(); } - return [ - 'remaining' => 100, - 'limit' => 100, - 'resets_at' => '', - ]; + + $client = new PRAutoBlogger_Reddit_JSON_Client(); + return $client->get_rate_limit_status(); } + /** + * Collect data from PullPush.io API. + * + * PullPush returns flat arrays of Reddit objects with standard field names. + * We can search comments subreddit-wide (more efficient than per-post). + * + * @param string $subreddit Subreddit name. + * @param int $limit Max posts. + * @param string $time_filter Time window. + * @param bool $include_comments Whether to fetch comments. + * @param int $comments_limit Max comments per post. + * + * @return PRAutoBlogger_Source_Data[] + */ + private function collect_from_pullpush( + string $subreddit, + int $limit, + string $time_filter, + bool $include_comments, + int $comments_limit + ): array { + $client = new PRAutoBlogger_PullPush_Client(); + $collected = []; + + // Fetch posts. + $posts = $client->fetch_posts( $subreddit, $limit, $time_filter ); + + foreach ( $posts as $post ) { + $collected[] = new PRAutoBlogger_Source_Data( [ + 'source_type' => 'reddit', + 'source_id' => 't3_' . ( $post['id'] ?? '' ), + 'subreddit' => $subreddit, + 'title' => $post['title'] ?? '', + 'content' => $post['selftext'] ?? '', + 'author' => $post['author'] ?? '[deleted]', + 'score' => (int) ( $post['score'] ?? 0 ), + 'comment_count' => (int) ( $post['num_comments'] ?? 0 ), + 'permalink' => 'https://reddit.com' . ( $post['permalink'] ?? '' ), + 'collected_at' => current_time( 'mysql' ), + 'metadata' => [ + 'post_type' => ( $post['is_self'] ?? false ) ? 'self' : 'link', + 'flair' => $post['link_flair_text'] ?? null, + 'created_utc' => $post['created_utc'] ?? null, + 'upvote_ratio' => $post['upvote_ratio'] ?? null, + 'is_original' => $post['is_original_content'] ?? false, + 'data_source' => 'pullpush', + ], + ] ); + + // Fetch comments for high-engagement posts. + if ( $include_comments && ( $post['num_comments'] ?? 0 ) > 0 ) { + $post_id = $post['id'] ?? ''; + $comments = $client->fetch_comments_for_post( $post_id, $comments_limit ); + + foreach ( $comments as $comment ) { + $collected[] = new PRAutoBlogger_Source_Data( [ + 'source_type' => 'reddit', + 'source_id' => 't1_' . ( $comment['id'] ?? '' ), + 'subreddit' => $subreddit, + 'title' => null, + 'content' => $comment['body'] ?? '', + 'author' => $comment['author'] ?? '[deleted]', + 'score' => (int) ( $comment['score'] ?? 0 ), + 'comment_count' => 0, + 'permalink' => 'https://reddit.com' . ( $comment['permalink'] ?? '' ), + 'collected_at' => current_time( 'mysql' ), + 'metadata' => [ + 'parent_post_id' => 't3_' . $post_id, + 'parent_title' => $post['title'] ?? '', + 'is_comment' => true, + 'created_utc' => $comment['created_utc'] ?? null, + 'data_source' => 'pullpush', + ], + ] ); + } + } + } + + return $collected; + } + + /** + * Collect data from Reddit .json endpoints (fallback). + * + * Response format matches the old OAuth API: data.children[].data. + * Per-post comment fetching (not subreddit-wide like PullPush). + * + * @param string $subreddit Subreddit name. + * @param int $limit Max posts. + * @param string $time_filter Time window. + * @param bool $include_comments Whether to fetch comments. + * @param int $comments_limit Max comments per post. + * + * @return PRAutoBlogger_Source_Data[] + */ + private function collect_from_reddit_json( + string $subreddit, + int $limit, + string $time_filter, + bool $include_comments, + int $comments_limit + ): array { + $client = new PRAutoBlogger_Reddit_JSON_Client(); + $collected = []; + + // Fetch posts — same response format as old OAuth API. + $posts = $client->fetch_posts( $subreddit, $limit, $time_filter ); + + foreach ( $posts as $post ) { + $collected[] = new PRAutoBlogger_Source_Data( [ + 'source_type' => 'reddit', + 'source_id' => 't3_' . ( $post['id'] ?? '' ), + 'subreddit' => $subreddit, + 'title' => $post['title'] ?? '', + 'content' => $post['selftext'] ?? '', + 'author' => $post['author'] ?? '[deleted]', + 'score' => (int) ( $post['score'] ?? 0 ), + 'comment_count' => (int) ( $post['num_comments'] ?? 0 ), + 'permalink' => 'https://reddit.com' . ( $post['permalink'] ?? '' ), + 'collected_at' => current_time( 'mysql' ), + 'metadata' => [ + 'post_type' => ( $post['is_self'] ?? false ) ? 'self' : 'link', + 'flair' => $post['link_flair_text'] ?? null, + 'created_utc' => $post['created_utc'] ?? null, + 'upvote_ratio' => $post['upvote_ratio'] ?? null, + 'is_original' => $post['is_original_content'] ?? false, + 'data_source' => 'reddit_json', + ], + ] ); + + // Collect top comments for high-engagement posts. + if ( $include_comments && ( $post['num_comments'] ?? 0 ) > 0 ) { + $post_id = $post['id'] ?? ''; + $comments = $client->fetch_comments( $subreddit, $post_id, $comments_limit ); + + foreach ( $comments as $comment ) { + $collected[] = new PRAutoBlogger_Source_Data( [ + 'source_type' => 'reddit', + 'source_id' => 't1_' . ( $comment['id'] ?? '' ), + 'subreddit' => $subreddit, + 'title' => null, + 'content' => $comment['body'] ?? '', + 'author' => $comment['author'] ?? '[deleted]', + 'score' => (int) ( $comment['score'] ?? 0 ), + 'comment_count' => 0, + 'permalink' => 'https://reddit.com' . ( $comment['permalink'] ?? '' ), + 'collected_at' => current_time( 'mysql' ), + 'metadata' => [ + 'parent_post_id' => 't3_' . $post_id, + 'parent_title' => $post['title'] ?? '', + 'is_comment' => true, + 'created_utc' => $comment['created_utc'] ?? null, + 'data_source' => 'reddit_json', + ], + ] ); + } + } + } + + return $collected; + } + + /** + * Check if PullPush.io is currently available, with caching. + * + * Caches the result for 5 minutes to avoid re-checking availability + * on every subreddit within a single collection run. + * + * Side effects: May make HTTP request, sets transient. + * + * @return bool True if PullPush is reachable. + */ + private function is_pullpush_available(): bool { + $cached = get_transient( self::PULLPUSH_AVAILABLE_TRANSIENT ); + + if ( false !== $cached ) { + return '1' === $cached; + } + + $client = new PRAutoBlogger_PullPush_Client(); + $available = $client->is_available(); + + set_transient( + self::PULLPUSH_AVAILABLE_TRANSIENT, + $available ? '1' : '0', + self::AVAILABILITY_CACHE_TTL + ); + + if ( ! $available ) { + PRAutoBlogger_Logger::instance()->warning( + 'PullPush.io unavailable — falling back to Reddit .json endpoints.', + 'reddit' + ); + } + + return $available; + } /** * Get the list of subreddits configured in plugin settings. @@ -171,7 +344,7 @@ class PRAutoBlogger_Reddit_Provider implements PRAutoBlogger_Source_Provider_Int * @return string[] */ private function get_configured_subreddits(): array { - $json = get_option( 'prautoblogger_target_subreddits', '[]' ); + $json = get_option( 'prautoblogger_target_subreddits', '' ); $list = json_decode( $json, true ); return is_array( $list ) ? $list : []; }