feat: chain per-article cron jobs to eliminate execution time ceiling

The pipeline previously ran all articles sequentially in a single PHP
process. On Hostinger, this timed out at ~120s — enough for 1 article
but not 2+. Now the pipeline generates article 1 in-process, then
queues articles 2..N as individual WP-Cron events. Each article gets
its own PHP process with its own execution time budget, so we can
generate any number of articles regardless of server time limits.

Architecture:
- Pipeline_Runner: orchestrate (collect+analyze+score) + first article,
  then serialize remaining ideas to wp_options and chain cron events
- Article_Worker (new): focused single-article worker extracted from
  the pipeline loop — content gen + editorial + publish
- Executor: new on_process_article_queue() handler for chained events;
  status poller re-schedules orphaned queue jobs automatically
- Article_Idea: added to_array() for queue serialization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
peptiderepo
2026-04-19 03:26:37 +07:00
parent c866637a95
commit ef40ce91d4
6 changed files with 465 additions and 193 deletions

View File

@@ -138,7 +138,8 @@ prautoblogger/
│ │ ├── class-image-media-sideloader.php # Imports images into WordPress media library
│ │ ├── class-cost-tracker.php # Logs all API costs, enforces budget limits
│ │ ├── class-logger.php # Structured logging singleton (error/warning/info/debug)
│ │ ├── class-pipeline-runner.php # Orchestrates the full generation pipeline
│ │ ├── class-article-worker.php # Single-article generation (content + edit + publish)
│ │ ├── class-pipeline-runner.php # Orchestrates pipeline; chains per-article cron jobs
│ │ ├── class-ga4-client.php # Google Analytics 4 API client (OAuth + Data API)
│ │ └── class-metrics-collector.php# Collects post performance data (WP + GA4)
│ │

View File

@@ -24,12 +24,14 @@ class PRAutoBlogger_Executor {
/**
* Handle the daily generation cron event.
* Uses an atomic DB mutex to prevent overlapping runs.
*
* Uses an atomic DB mutex. If the pipeline queues additional articles,
* they fire as chained cron events and the lock is released by the
* last article job — not here.
*
* Side effects: API calls, database writes, WordPress post creation.
*/
public function on_daily_generation(): void {
// Refresh model registry before lock — stuck lock shouldn't block refresh.
do_action( 'prautoblogger_refresh_model_registry', false );
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
@@ -39,12 +41,16 @@ class PRAutoBlogger_Executor {
try {
( new PRAutoBlogger_Pipeline_Runner() )->run();
// Release lock only if no articles were queued for chained processing.
if ( ! get_option( 'prautoblogger_article_queue' ) ) {
PRAutoBlogger_Generation_Lock::release();
}
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Daily generation %s: %s', get_class( $e ), $e->getMessage() ),
'scheduler'
);
} finally {
PRAutoBlogger_Generation_Lock::release();
}
}
@@ -132,11 +138,14 @@ class PRAutoBlogger_Executor {
] );
}
/** Cron handler: runs pipeline in background, writes results to transient. */
/**
* Cron handler: runs pipeline orchestration + first article.
*
* If the pipeline queues additional articles (2..N), they will fire as
* chained cron events — each in its own PHP process. The lock is released
* by the pipeline when the last article completes, not here.
*/
public function on_manual_generation(): void {
// Keep PHP running even after LiteSpeed closes the HTTP connection.
// On Hostinger shared hosting, the web server kills HTTP connections
// at 120 seconds, but ignore_user_abort lets the PHP process continue.
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@ignore_user_abort( true );
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@@ -153,17 +162,21 @@ class PRAutoBlogger_Executor {
try {
$this->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) );
$results = ( new PRAutoBlogger_Pipeline_Runner() )
->set_skip_dedup( true )
->run();
$runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true );
$results = $runner->run();
set_transient( self::STATUS_TRANSIENT, [
'status' => 'complete',
'generated' => $results['generated'],
'published' => $results['published'],
'rejected' => $results['rejected'],
'cost' => $results['cost'],
], self::STATUS_TTL );
// If no articles were queued (target=1 or 0 ideas), finalize here.
// When articles ARE queued, the pipeline handles status + lock release.
if ( ! get_option( 'prautoblogger_article_queue' ) ) {
set_transient( self::STATUS_TRANSIENT, [
'status' => 'complete',
'generated' => $results['generated'],
'published' => $results['published'],
'rejected' => $results['rejected'],
'cost' => $results['cost'],
], self::STATUS_TTL );
PRAutoBlogger_Generation_Lock::release();
}
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Manual generation %s: %s', get_class( $e ), $e->getMessage() ),
@@ -173,93 +186,87 @@ class PRAutoBlogger_Executor {
'status' => 'error',
'message' => $e->getMessage(),
], self::STATUS_TTL );
} finally {
PRAutoBlogger_Generation_Lock::release();
}
}
/**
* Cron handler: process the next queued article in its own PHP process.
*
* Chained by Pipeline_Runner — each article gets its own execution time.
*/
public function on_process_article_queue(): void {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@ignore_user_abort( true );
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@set_time_limit( 300 );
try {
( new PRAutoBlogger_Pipeline_Runner() )->process_next_queued_article();
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Queued article generation %s: %s', get_class( $e ), $e->getMessage() ),
'pipeline'
);
// Clean up on catastrophic failure.
delete_option( 'prautoblogger_article_queue' );
PRAutoBlogger_Generation_Lock::release();
set_transient( self::STATUS_TRANSIENT, [
'status' => 'error',
'message' => $e->getMessage(),
], self::STATUS_TTL );
}
}
/** AJAX: return generation status for frontend polling. Recovers orphaned runs. */
public function on_ajax_generation_status(): void {
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
return;
}
$status = get_transient( self::STATUS_TRANSIENT );
if ( ! is_array( $status ) ) {
wp_send_json_success( [ 'status' => 'idle' ] );
return;
}
// Fallback: detect orphaned "running" state when PHP process was killed.
// On Hostinger shared hosting, set_time_limit() is often ignored and
// PHP kills the cron process at max_execution_time (120–180s). The
// pipeline creates the post first and images last, so the post exists
// even when the process died during image generation.
if ( 'running' === $status['status'] ) {
$started = $status['started'] ?? 0;
$elapsed = time() - $started;
$elapsed = time() - ( $status['started'] ?? 0 );
// After 10 minutes, give up unconditionally.
// Absolute timeout — give up after 10 minutes.
if ( $elapsed > self::STATUS_TTL ) {
delete_transient( self::STATUS_TRANSIENT );
wp_send_json_success( [
'status' => 'error',
'message' => __( 'Generation timed out. Check the Activity Log for details.', 'prautoblogger' ),
] );
$this->abort_orphaned_run( __( 'Generation timed out. Check the Activity Log.', 'prautoblogger' ) );
return;
}
// After 90 seconds, proactively check if the pipeline already
// produced a post. This catches the common case where the cron
// process was killed by max_execution_time after publishing but
// before updating the transient. Without this, the user stares
// at "Generating…" for 5+ minutes despite the work being done.
if ( $elapsed > 90 ) {
$recent = get_posts( [
'post_type' => 'post',
'post_status' => [ 'publish', 'draft' ],
'posts_per_page' => 1,
'date_query' => [ [ 'after' => gmdate( 'Y-m-d H:i:s', $started ) ] ],
'meta_key' => '_prautoblogger_run_id',
'orderby' => 'date',
'order' => 'DESC',
] );
if ( ! empty( $recent ) ) {
delete_transient( self::STATUS_TRANSIENT );
PRAutoBlogger_Generation_Lock::release();
wp_send_json_success( [
'status' => 'complete',
'generated' => 1,
'published' => 1,
'rejected' => 0,
'cost' => 0.0,
'note' => __( 'Generation completed (status recovered).', 'prautoblogger' ),
] );
return;
// After 90s, check if a queued job died and needs re-scheduling.
if ( $elapsed > 90 && get_option( 'prautoblogger_article_queue' ) ) {
if ( ! wp_next_scheduled( PRAutoBlogger_Pipeline_Runner::CRON_ACTION ) ) {
wp_schedule_single_event( time(), PRAutoBlogger_Pipeline_Runner::CRON_ACTION );
spawn_cron();
}
}
// No post found yet — only declare failure after 5 minutes.
// The pipeline genuinely needs 2–4 min for article + images.
if ( $elapsed > 300 ) {
delete_transient( self::STATUS_TRANSIENT );
PRAutoBlogger_Generation_Lock::release();
wp_send_json_success( [
'status' => 'error',
'message' => __( 'Generation process ended without producing content. Check Activity Log.', 'prautoblogger' ),
] );
return;
}
// After 5 min with no progress, declare failure.
if ( $elapsed > 300 ) {
$this->abort_orphaned_run( __( 'Generation stalled. Check Activity Log.', 'prautoblogger' ) );
return;
}
}
wp_send_json_success( $status );
}
/** Clean up an orphaned generation run and report error. */
private function abort_orphaned_run( string $message ): void {
delete_transient( self::STATUS_TRANSIENT );
delete_option( 'prautoblogger_article_queue' );
PRAutoBlogger_Generation_Lock::release();
wp_send_json_success( [ 'status' => 'error', 'message' => $message ] );
}
/**
* Helper: update the generation stage for frontend polling.
*

View File

@@ -95,6 +95,13 @@ class PRAutoBlogger {
// Hostinger's 120-second web-server connection timeout.
add_action( 'prautoblogger_manual_generation', [ $this->executor, 'on_manual_generation' ] );
// Chained article generation — each queued article fires as its own
// cron event so it gets a fresh PHP process and execution time budget.
add_action(
PRAutoBlogger_Pipeline_Runner::CRON_ACTION,
[ $this->executor, 'on_process_article_queue' ]
);
$registry = $this->executor->get_model_registry();
add_action( 'prautoblogger_refresh_model_registry', [ $registry, 'refresh' ] );
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
/**
* Generates a single article: content → editorial review → publish/draft.
*
* Extracted from Pipeline_Runner so each chained cron job has a focused,
* single-responsibility worker. One worker = one article = one PHP process.
*
* Triggered by: Pipeline_Runner (directly for article 1, via cron for 2..N).
* Dependencies: Content_Generator, Chief_Editor, Publisher, Cost_Tracker.
*
* @see core/class-pipeline-runner.php — Orchestrates and dispatches workers.
* @see core/class-content-generator.php — LLM content generation.
* @see core/class-chief-editor.php — Editorial quality gate.
* @see core/class-publisher.php — WordPress post creation.
*/
class PRAutoBlogger_Article_Worker {
private PRAutoBlogger_Cost_Tracker $cost_tracker;
/**
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Shared cost tracker.
*/
public function __construct( PRAutoBlogger_Cost_Tracker $cost_tracker ) {
$this->cost_tracker = $cost_tracker;
}
/**
* Generate one article from an idea.
*
* Runs the full single-article pipeline: LLM content generation →
* editorial review → publish or save as draft.
*
* Side effects: LLM API calls, database writes, WordPress post creation,
* image generation, cost logging.
*
* @param PRAutoBlogger_Article_Idea $idea The scored idea to generate from.
*
* @return array{generated: int, published: int, rejected: int, cost: float}
*/
public function generate( PRAutoBlogger_Article_Idea $idea ): array {
$llm = new PRAutoBlogger_OpenRouter_Provider();
$generator = new PRAutoBlogger_Content_Generator( $llm, $this->cost_tracker );
$editor = new PRAutoBlogger_Chief_Editor( $llm, $this->cost_tracker );
$publisher = new PRAutoBlogger_Publisher();
$auto_publish = in_array(
get_option( 'prautoblogger_auto_publish', '0' ),
[ '1', 'yes' ],
true
);
$result = [
'generated' => 0,
'published' => 0,
'rejected' => 0,
'cost' => 0.0,
];
try {
$this->broadcast_stage( __( 'Generating article draft via AI…', 'prautoblogger' ) );
$content = $generator->generate( $idea );
$result['generated'] = 1;
$this->broadcast_stage( __( 'Running editorial pass…', 'prautoblogger' ) );
$review = $editor->review( $content, $idea );
$this->broadcast_stage( __( 'Saving and publishing…', 'prautoblogger' ) );
$this->publish_or_draft(
$content, $idea, $review, $publisher, $auto_publish, $result
);
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf(
'Article generation %s for "%s": %s',
get_class( $e ),
$idea->get_topic(),
$e->getMessage()
),
'pipeline'
);
}
$result['cost'] = $this->cost_tracker->get_current_run_cost();
return $result;
}
/**
* Publish approved content or save as draft.
*
* @param string $content Generated HTML content.
* @param PRAutoBlogger_Article_Idea $idea Source idea.
* @param PRAutoBlogger_Editorial_Review $review Editor verdict.
* @param PRAutoBlogger_Publisher $publisher Publisher instance.
* @param bool $auto_publish Whether to auto-publish.
* @param array &$result Result counters (by ref).
*/
private function publish_or_draft(
string $content,
PRAutoBlogger_Article_Idea $idea,
PRAutoBlogger_Editorial_Review $review,
PRAutoBlogger_Publisher $publisher,
bool $auto_publish,
array &$result
): void {
$verdict = $review->get_verdict();
$run_id = $this->cost_tracker->get_run_id();
if ( 'approved' === $verdict || 'revised' === $verdict ) {
$final = 'revised' === $verdict
? ( $review->get_revised_content() ?? $content )
: $content;
if ( $auto_publish ) {
$publisher->publish( $final, $idea, $review, $run_id );
$result['published']++;
} else {
$publisher->save_as_draft( $final, $idea, $review, $run_id );
}
} else {
$publisher->save_as_draft( $content, $idea, $review, $run_id );
$result['rejected']++;
PRAutoBlogger_Logger::instance()->info(
'Article rejected by editor: ' . $idea->get_topic(),
'pipeline'
);
}
}
/**
* Update the generation status transient with the current stage.
*
* @param string $stage Human-readable stage description.
*/
private function broadcast_stage( string $stage ): void {
$transient_key = 'prautoblogger_generation_status';
$current = get_transient( $transient_key );
if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) {
$current['stage'] = $stage;
set_transient( $transient_key, $current, 600 );
}
}
}

View File

@@ -2,41 +2,37 @@
declare(strict_types=1);
/**
* Executes the full content generation pipeline.
* Executes the content generation pipeline using a chained-job architecture.
*
* Orchestrates: Budget check → Collect source data → Analyze patterns →
* Score ideas → Generate content → Editorial review → Publish.
* Score ideas → Generate article 1 in-process → Queue articles 2..N as
* individual WP-Cron events so each gets its own PHP execution time budget.
*
* Triggered by: PRAutoBlogger::on_daily_generation() and PRAutoBlogger::on_ajax_generate_now().
* Dependencies: All core content classes (Collector, Analyzer, Scorer, Generator, Editor, Publisher).
* Why chained jobs: Hostinger kills PHP processes at ~120s. A single article
* (LLM generation + editorial + 2 images) takes ~60-90s, so one-per-process
* is safe. Running N articles sequentially in one process times out at N≥2.
*
* @see class-prautoblogger.php — Instantiates this class and calls run().
* @see core/class-cost-tracker.php — Tracks cumulative API costs throughout the run.
* @see ARCHITECTURE.md — Data flow diagram showing pipeline steps.
* Triggered by: PRAutoBlogger_Executor (daily cron + manual "Generate Now").
* Dependencies: Core content classes, Article_Worker, WP-Cron for chaining.
*
* @see class-executor.php — Cron/AJAX handlers that call us.
* @see core/class-article-worker.php — Single-article generation worker.
* @see core/class-cost-tracker.php — Tracks costs throughout the run.
* @see ARCHITECTURE.md — Data flow diagram.
*/
class PRAutoBlogger_Pipeline_Runner {
/**
* Execute the full content generation pipeline.
*
* Steps: Budget check → Collect source data → Analyze patterns →
* Score ideas → Generate content → Editorial review → Publish.
*
* Side effects: API calls (OpenRouter, Reddit), database writes, post creation.
*
* @return array{generated: int, published: int, rejected: int, cost: float} Pipeline results.
*
* @throws \RuntimeException If budget is exceeded or critical step fails.
*/
/** @var bool Whether to skip duplicate-topic detection (used for manual runs). */
/** Option key for the article generation queue. */
private const QUEUE_KEY = 'prautoblogger_article_queue';
/** WP-Cron action for chained article generation. */
public const CRON_ACTION = 'prautoblogger_generate_queued_article';
/** @var bool Whether to skip duplicate-topic detection. */
private bool $skip_dedup = false;
/**
* Allow manual runs to skip the has_similar_post() check so "Generate Now"
* always produces content even when a related article was recently published.
*
* @param bool $skip True to skip deduplication.
*
* @return $this
*/
public function set_skip_dedup( bool $skip ): self {
@@ -44,128 +40,224 @@ class PRAutoBlogger_Pipeline_Runner {
return $this;
}
/**
* Run orchestration and generate the first article in-process.
*
* Steps: Budget check → Collect → Analyze → Score → Generate article 1.
* Remaining articles are queued as chained WP-Cron events.
*
* Side effects: API calls, DB writes, post creation, cron scheduling.
*
* @return array{generated: int, published: int, rejected: int, cost: float}
* @throws \RuntimeException If budget exceeded.
*/
public function run(): array {
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
// Tag all log entries in this run with a unique ID so link_generation_logs()
// can accurately attribute costs to the correct post in batch runs.
$cost_tracker->set_run_id( wp_generate_uuid4() );
// Hard stop if monthly budget is exceeded.
if ( $cost_tracker->is_budget_exceeded() ) {
throw new \RuntimeException(
__( 'Monthly API budget exceeded. Generation halted.', 'prautoblogger' )
);
}
$target_count = absint( get_option( 'prautoblogger_daily_article_target', 1 ) );
// Step 1: Collect source data.
$this->broadcast_stage( __( 'Collecting sources from Reddit…', 'prautoblogger' ) );
$collector = new PRAutoBlogger_Source_Collector();
$collector->collect_from_all_sources();
// Step 2: Analyze collected data for patterns.
// Pass a generous target so the LLM returns enough diverse ideas for
// scoring to pick from — ask for 2× the daily target so dedup has room.
$this->broadcast_stage( __( 'Analyzing topics and scoring…', 'prautoblogger' ) );
$llm = new PRAutoBlogger_OpenRouter_Provider();
$analyzer = new PRAutoBlogger_Content_Analyzer( $llm, $cost_tracker );
$analysis = $analyzer->analyze_recent_data( max( $target_count * 2, 6 ) );
// Step 3: Score and deduplicate ideas.
$this->broadcast_stage( __( 'Selecting best topic…', 'prautoblogger' ) );
$scorer = new PRAutoBlogger_Idea_Scorer();
$scorer->set_skip_dedup( $this->skip_dedup );
$ideas = $scorer->score_and_rank( $analysis, $target_count );
$ideas = $this->orchestrate( $cost_tracker );
if ( empty( $ideas ) ) {
PRAutoBlogger_Logger::instance()->info( 'No viable article ideas found. Skipping generation.', 'pipeline' );
return [ 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ];
}
// Step 4 & 5: Generate content and run through editor for each idea.
$generator = new PRAutoBlogger_Content_Generator( $llm, $cost_tracker );
$editor = new PRAutoBlogger_Chief_Editor( $llm, $cost_tracker );
$publisher = new PRAutoBlogger_Publisher();
$auto_publish = in_array( get_option( 'prautoblogger_auto_publish', '0' ), [ '1', 'yes' ], true );
// Generate article 1 in this process — always fits within time limit.
$first_idea = array_shift( $ideas );
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
$result = $worker->generate( $first_idea );
$generated = 0;
$published = 0;
$rejected = 0;
foreach ( $ideas as $idea ) {
// Re-check budget before each article — stop early if we hit the limit.
if ( $cost_tracker->is_budget_exceeded() ) {
PRAutoBlogger_Logger::instance()->warning( 'Budget limit reached during generation. Stopping early.', 'pipeline' );
break;
}
try {
$this->broadcast_stage( __( 'Generating article draft via AI…', 'prautoblogger' ) );
$content = $generator->generate( $idea );
$generated++;
$this->broadcast_stage( __( 'Running editorial pass…', 'prautoblogger' ) );
$review = $editor->review( $content, $idea );
if ( 'approved' === $review->get_verdict() || 'revised' === $review->get_verdict() ) {
// Null-coalesce: if editor returns 'revised' verdict but
// get_revised_content() is null (e.g. malformed LLM response),
// fall back to the original generated content.
$final_content = 'revised' === $review->get_verdict()
? ( $review->get_revised_content() ?? $content )
: $content;
$this->broadcast_stage( __( 'Saving and publishing…', 'prautoblogger' ) );
if ( $auto_publish ) {
$publisher->publish( $final_content, $idea, $review, $cost_tracker->get_run_id() );
$published++;
} else {
// Auto-publish disabled: send to review queue as draft.
$publisher->save_as_draft( $final_content, $idea, $review, $cost_tracker->get_run_id() );
}
} else {
$publisher->save_as_draft( $content, $idea, $review, $cost_tracker->get_run_id() );
$rejected++;
PRAutoBlogger_Logger::instance()->info( 'Article rejected by editor: ' . $idea->get_topic(), 'pipeline' );
}
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error( sprintf( 'Article generation %s for "%s": %s', get_class( $e ), $idea->get_topic(), $e->getMessage() ), 'pipeline' );
// Continue with next idea — don't let one failure stop the batch.
}
// Queue remaining articles as chained cron events.
if ( ! empty( $ideas ) ) {
$this->queue_remaining( $ideas, $cost_tracker->get_run_id(), $result );
} else {
$this->log_summary( $result );
}
$total_cost = $cost_tracker->get_current_run_cost();
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Pipeline complete: %d generated, %d published, %d rejected. Cost: $%.4f', $generated, $published, $rejected, $total_cost ),
'pipeline'
);
return [
'generated' => $generated,
'published' => $published,
'rejected' => $rejected,
'cost' => $total_cost,
];
return $result;
}
/**
* Update the generation status transient with the current pipeline stage.
* Process the next article from the persistent queue.
*
* Called at key checkpoints during the pipeline so the frontend status
* poller can show real progress instead of fake timers. No-ops if the
* transient doesn't exist (e.g., during daily cron runs).
* Called by WP-Cron for articles 2, 3, …N. Pops one idea, generates
* it, updates running totals, and chains the next job or finalizes.
*
* @param string $stage Human-readable stage description.
* Side effects: API calls, DB writes, post creation, cron scheduling.
*/
public function process_next_queued_article(): void {
$queue = get_option( self::QUEUE_KEY );
if ( ! is_array( $queue ) || empty( $queue['ideas'] ) ) {
$this->finish_queue();
return;
}
$idea_data = array_shift( $queue['ideas'] );
$idea = new PRAutoBlogger_Article_Idea( $idea_data );
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
$cost_tracker->set_run_id( $queue['run_id'] );
if ( $cost_tracker->is_budget_exceeded() ) {
PRAutoBlogger_Logger::instance()->warning(
'Budget limit reached. Aborting remaining queued articles.',
'pipeline'
);
$this->finish_queue();
return;
}
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
$article_result = $worker->generate( $idea );
// Merge into running totals.
$queue['results']['generated'] += $article_result['generated'];
$queue['results']['published'] += $article_result['published'];
$queue['results']['rejected'] += $article_result['rejected'];
$queue['results']['cost'] += $article_result['cost'];
if ( ! empty( $queue['ideas'] ) ) {
update_option( self::QUEUE_KEY, $queue, false );
$this->update_queue_status( $queue );
$this->schedule_next();
} else {
$this->write_final_status( $queue['results'] );
delete_option( self::QUEUE_KEY );
PRAutoBlogger_Generation_Lock::release();
$this->log_summary( $queue['results'] );
}
}
// ── Private helpers ─────────────────────────────────────────────────
/**
* Collect → Analyze → Score. Returns ranked ideas.
*
* @param PRAutoBlogger_Cost_Tracker $cost_tracker
* @return PRAutoBlogger_Article_Idea[]
*/
private function orchestrate( PRAutoBlogger_Cost_Tracker $cost_tracker ): array {
$target = absint( get_option( 'prautoblogger_daily_article_target', 1 ) );
$this->broadcast_stage( __( 'Collecting sources from Reddit…', 'prautoblogger' ) );
( new PRAutoBlogger_Source_Collector() )->collect_from_all_sources();
$this->broadcast_stage( __( 'Analyzing topics and scoring…', 'prautoblogger' ) );
$llm = new PRAutoBlogger_OpenRouter_Provider();
$analyzer = new PRAutoBlogger_Content_Analyzer( $llm, $cost_tracker );
$analysis = $analyzer->analyze_recent_data( max( $target * 2, 6 ) );
$this->broadcast_stage( __( 'Selecting best topic…', 'prautoblogger' ) );
$scorer = new PRAutoBlogger_Idea_Scorer();
$scorer->set_skip_dedup( $this->skip_dedup );
$ideas = $scorer->score_and_rank( $analysis, $target );
if ( empty( $ideas ) ) {
PRAutoBlogger_Logger::instance()->info(
'No viable article ideas found. Skipping generation.',
'pipeline'
);
}
return $ideas;
}
/**
* Store remaining ideas and schedule the next chained job.
*
* @param PRAutoBlogger_Article_Idea[] $ideas Remaining ideas (2..N).
* @param string $run_id Pipeline run UUID.
* @param array $first Results from article 1.
*/
private function queue_remaining( array $ideas, string $run_id, array $first ): void {
$serialized = array_map(
static fn( PRAutoBlogger_Article_Idea $i ) => $i->to_array(),
$ideas
);
$queue = [
'run_id' => $run_id,
'ideas' => $serialized,
'results' => $first,
];
update_option( self::QUEUE_KEY, $queue, false );
$this->update_queue_status( $queue );
$this->schedule_next();
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Article 1 done. Queued %d more as chained jobs.', count( $serialized ) ),
'pipeline'
);
}
/** Schedule the next chained cron event and fire it immediately. */
private function schedule_next(): void {
if ( ! wp_next_scheduled( self::CRON_ACTION ) ) {
wp_schedule_single_event( time(), self::CRON_ACTION );
}
spawn_cron();
wp_remote_post(
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
[ 'timeout' => 0.01, 'blocking' => false, 'sslverify' => false ]
);
}
/** Update the frontend status transient with queue progress. */
private function update_queue_status( array $queue ): void {
$done = $queue['results']['generated'] + $queue['results']['rejected'];
$total = $done + count( $queue['ideas'] );
$current = get_transient( 'prautoblogger_generation_status' );
set_transient( 'prautoblogger_generation_status', [
'status' => 'running',
'stage' => sprintf( __( 'Generating article %1$d of %2$d…', 'prautoblogger' ), $done + 1, $total ),
'started' => is_array( $current ) ? ( $current['started'] ?? time() ) : time(),
], 600 );
}
/** Write the final "complete" status transient. */
private function write_final_status( array $r ): void {
set_transient( 'prautoblogger_generation_status', [
'status' => 'complete',
'generated' => $r['generated'],
'published' => $r['published'],
'rejected' => $r['rejected'],
'cost' => $r['cost'],
], 600 );
}
/** Clean up a stale or empty queue. */
private function finish_queue(): void {
$queue = get_option( self::QUEUE_KEY );
if ( is_array( $queue ) && isset( $queue['results'] ) ) {
$this->write_final_status( $queue['results'] );
}
delete_option( self::QUEUE_KEY );
PRAutoBlogger_Generation_Lock::release();
}
/** Log the pipeline summary. */
private function log_summary( array $r ): void {
PRAutoBlogger_Logger::instance()->info(
sprintf(
'Pipeline complete: %d generated, %d published, %d rejected. Cost: $%.4f',
$r['generated'], $r['published'], $r['rejected'], $r['cost']
),
'pipeline'
);
}
/** Update the generation status transient with the current stage. */
private function broadcast_stage( string $stage ): void {
$transient_key = 'prautoblogger_generation_status';
$current = get_transient( $transient_key );
$key = 'prautoblogger_generation_status';
$current = get_transient( $key );
if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) {
$current['stage'] = $stage;
set_transient( $transient_key, $current, 600 );
set_transient( $key, $current, 600 );
}
}
}

View File

@@ -84,4 +84,25 @@ class PRAutoBlogger_Article_Idea {
public function get_target_keywords(): array {
return $this->target_keywords;
}
/**
* Serialize to array for storage in options/transients.
*
* Used by the pipeline queue to persist ideas across chained cron jobs.
*
* @return array<string, mixed> Constructor-compatible array.
*/
public function to_array(): array {
return [
'topic' => $this->topic,
'article_type' => $this->article_type,
'suggested_title' => $this->suggested_title,
'summary' => $this->summary,
'score' => $this->score,
'analysis_id' => $this->analysis_id,
'source_ids' => $this->source_ids,
'key_points' => $this->key_points,
'target_keywords' => $this->target_keywords,
];
}
}