> 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 AND has reasonably fresh data. * * PullPush can return HTTP 200 but have a stale index (months behind Reddit). * We check that the most recent post is no older than 7 days. If the index * is stale, we return false so the caller falls back to Reddit .json. * * Side effects: HTTP request. * * @return bool True if API responded with HTTP 200 and has data < 7 days old. */ public function is_available(): bool { // Fetch 1 recent submission sorted by date to check freshness. $url = self::API_BASE . '/search/submission?subreddit=all&size=1&sort_type=created_utc&sort=desc'; $response = wp_remote_get( $url, [ 'timeout' => 10, 'headers' => [ 'User-Agent' => self::USER_AGENT, ], ] ); if ( is_wp_error( $response ) ) { return false; } if ( 200 !== wp_remote_retrieve_response_code( $response ) ) { return false; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); $items = $body['data'] ?? []; if ( empty( $items ) ) { return false; } // Check that the most recent post is within the last 7 days. // If PullPush's index is stale (e.g. months behind), we should // fall back to Reddit .json which always has live data. $latest_utc = (int) ( $items[0]['created_utc'] ?? 0 ); $seven_days = 7 * DAY_IN_SECONDS; $is_fresh = ( time() - $latest_utc ) < $seven_days; if ( ! $is_fresh ) { PRAutoBlogger_Logger::instance()->warning( sprintf( 'PullPush index is stale — latest post is from %s (%d days old). Falling back to Reddit .json.', gmdate( 'Y-m-d', $latest_utc ), (int) ( ( time() - $latest_utc ) / DAY_IN_SECONDS ) ), 'pullpush' ); } return $is_fresh; } /** * 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. } } }