Initial commit: AutoBlogger v0.1.0

WordPress plugin that auto-researches topics from Reddit, generates
SEO-optimized articles via OpenRouter LLMs, runs them through a chief
editor agent, and publishes on a daily schedule.

Includes:
- Full generation pipeline (collect → analyze → score → generate → review → publish)
- Cost tracking and budget enforcement
- Admin UI: settings, review queue, metrics, activity log
- CI/CD pipeline (GitHub Actions → WordPress REST deploy)
- Unit test suite (PHPUnit + Brain Monkey)
- Structured logging with configurable verbosity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Terence Ong
2026-04-10 16:10:27 +07:00
commit 68c6f67bcf
65 changed files with 8833 additions and 0 deletions

View File

@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
/**
* Chief Editor agent — LLM-powered editorial review of generated content.
*
* Reviews articles for quality, accuracy, SEO, and tone before publishing.
* Can approve, request revisions (and produce them), or reject content.
* Rejected content is saved as a draft for human review.
*
* Triggered by: Autoblogger::run_generation_pipeline() (step 5).
* Dependencies: LLM_Provider_Interface, Cost_Tracker.
*
* @see core/class-content-generator.php — Produces the content we review.
* @see core/class-publisher.php — Publishes approved content.
* @see ARCHITECTURE.md — Data flow step 5.
*/
class Autoblogger_Chief_Editor {
private Autoblogger_LLM_Provider_Interface $llm;
private Autoblogger_Cost_Tracker $cost_tracker;
public function __construct(
Autoblogger_LLM_Provider_Interface $llm,
Autoblogger_Cost_Tracker $cost_tracker
) {
$this->llm = $llm;
$this->cost_tracker = $cost_tracker;
}
/**
* Review generated content and return an editorial verdict.
*
* The editor evaluates: content quality, factual tone, SEO elements,
* readability, and alignment with the article idea. It can approve,
* revise (and return revised content), or reject.
*
* Side effects: LLM API call(s), cost logging.
*
* @param string $content The generated HTML content.
* @param Autoblogger_Article_Idea $idea The original article idea.
*
* @return Autoblogger_Editorial_Review
*/
public function review( string $content, Autoblogger_Article_Idea $idea ): Autoblogger_Editorial_Review {
$model = get_option( 'autoblogger_editor_model', AUTOBLOGGER_DEFAULT_EDITOR_MODEL );
$niche = get_option( 'autoblogger_niche_description', '' );
$system_prompt = $this->build_system_prompt( $niche );
$user_prompt = $this->build_review_prompt( $content, $idea );
$response = $this->llm->send_chat_completion(
[
[ 'role' => 'system', 'content' => $system_prompt ],
[ 'role' => 'user', 'content' => $user_prompt ],
],
$model,
[
'temperature' => 0.3,
'max_tokens' => 5000,
'response_format' => [ 'type' => 'json_object' ],
]
);
$this->cost_tracker->log_api_call(
null,
'review',
$this->llm->get_provider_name(),
$response['model'],
$response['prompt_tokens'],
$response['completion_tokens']
);
return $this->parse_review_response( $response['content'] );
}
/**
* @param string $niche
*
* @return string
*/
private function build_system_prompt( string $niche ): string {
$prompt = "You are a senior blog editor";
if ( '' !== $niche ) {
$prompt .= " specializing in {$niche} content";
}
$prompt .= ". Your job is to review article drafts before publication.\n\n";
$prompt .= "Evaluate the article on:\n";
$prompt .= "1. QUALITY: Is the writing clear, engaging, and well-structured?\n";
$prompt .= "2. ACCURACY: Does the content avoid making unsupported health/medical claims? Is the tone appropriately cautious for the topic?\n";
$prompt .= "3. SEO: Are headings properly structured? Are target keywords naturally used?\n";
$prompt .= "4. COMPLETENESS: Does it cover the key points? Does it have a proper intro and conclusion?\n";
$prompt .= "5. READABILITY: Is sentence variety good? Are paragraphs appropriate length?\n\n";
$prompt .= "You must respond with JSON in this format:\n";
$prompt .= "{\n";
$prompt .= ' "verdict": "approved" | "revised" | "rejected",' . "\n";
$prompt .= ' "quality_score": 0.0-1.0,' . "\n";
$prompt .= ' "seo_score": 0.0-1.0,' . "\n";
$prompt .= ' "issues": ["issue 1", "issue 2"],' . "\n";
$prompt .= ' "notes": "Brief editorial notes",' . "\n";
$prompt .= ' "revised_content": "Full revised HTML if verdict is revised, null otherwise"' . "\n";
$prompt .= "}\n\n";
$prompt .= "Rules:\n";
$prompt .= "- APPROVE if quality_score >= 0.7 and seo_score >= 0.6 and no critical issues\n";
$prompt .= "- REVISE if fixable issues exist — you MUST provide the full revised HTML\n";
$prompt .= "- REJECT only if the content is fundamentally flawed or off-topic\n";
$prompt .= "- When revising, fix the issues yourself. Don't just describe them.\n";
return $prompt;
}
/**
* @param string $content
* @param Autoblogger_Article_Idea $idea
*
* @return string
*/
private function build_review_prompt( string $content, Autoblogger_Article_Idea $idea ): string {
return sprintf(
"Review this article draft:\n\n" .
"ORIGINAL BRIEF:\n" .
"- Title: %s\n" .
"- Topic: %s\n" .
"- Type: %s\n" .
"- Key points: %s\n" .
"- Target keywords: %s\n\n" .
"ARTICLE CONTENT:\n%s",
$idea->get_suggested_title(),
$idea->get_topic(),
$idea->get_article_type(),
implode( ', ', $idea->get_key_points() ),
implode( ', ', $idea->get_target_keywords() ),
$content
);
}
/**
* Parse the LLM's editorial review response.
*
* @param string $content JSON response from LLM.
*
* @return Autoblogger_Editorial_Review
*/
private function parse_review_response( string $content ): Autoblogger_Editorial_Review {
$data = json_decode( $content, true );
if ( ! is_array( $data ) || ! isset( $data['verdict'] ) ) {
Autoblogger_Logger::instance()->error( 'Chief editor response was not valid JSON. Defaulting to rejected.', 'editor' );
return new Autoblogger_Editorial_Review( [
'verdict' => 'rejected',
'notes' => 'Editor response could not be parsed.',
'quality_score' => 0.0,
'seo_score' => 0.0,
'issues' => [ 'Unparseable editor response' ],
] );
}
$verdict = sanitize_text_field( $data['verdict'] ?? 'rejected' );
if ( ! in_array( $verdict, [ 'approved', 'revised', 'rejected' ], true ) ) {
$verdict = 'rejected';
}
return new Autoblogger_Editorial_Review( [
'verdict' => $verdict,
'notes' => sanitize_textarea_field( $data['notes'] ?? '' ),
'revised_content' => isset( $data['revised_content'] ) ? wp_kses_post( $data['revised_content'] ) : null,
'quality_score' => (float) ( $data['quality_score'] ?? 0.0 ),
'seo_score' => (float) ( $data['seo_score'] ?? 0.0 ),
'issues' => array_map( 'sanitize_text_field', $data['issues'] ?? [] ),
] );
}
}

View File

@@ -0,0 +1,293 @@
<?php
declare(strict_types=1);
/**
* Analyzes collected source data using an LLM to detect recurring patterns:
* questions people ask, complaints/pain points, and product comparisons.
*
* Uses a cheap model (configurable) to keep analysis costs low.
* Batches source data into manageable chunks for the LLM context window.
*
* Triggered by: Autoblogger::run_generation_pipeline() (step 2).
* Dependencies: LLM_Provider_Interface, Cost_Tracker, WordPress $wpdb.
*
* @see providers/interface-llm-provider.php — LLM used for analysis.
* @see core/class-cost-tracker.php — Logs API costs.
* @see core/class-idea-scorer.php — Consumes analysis results.
* @see ARCHITECTURE.md — Data flow step 2.
*/
class Autoblogger_Content_Analyzer {
private Autoblogger_LLM_Provider_Interface $llm;
private Autoblogger_Cost_Tracker $cost_tracker;
public function __construct(
Autoblogger_LLM_Provider_Interface $llm,
Autoblogger_Cost_Tracker $cost_tracker
) {
$this->llm = $llm;
$this->cost_tracker = $cost_tracker;
}
/**
* Analyze recently collected source data for content patterns.
*
* Fetches unanalyzed data from the last 24 hours, batches it, sends to LLM
* for pattern detection, and stores results in ab_analysis_results.
*
* Side effects: LLM API call, database reads/writes.
*
* @return Autoblogger_Analysis_Result[] Detected patterns.
*/
public function analyze_recent_data(): array {
$source_items = $this->fetch_recent_source_data();
if ( empty( $source_items ) ) {
Autoblogger_Logger::instance()->info( 'No new source data to analyze.', 'analyzer' );
return [];
}
$niche = get_option( 'autoblogger_niche_description', '' );
$model = get_option( 'autoblogger_analysis_model', AUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
// Build the source data summary for the LLM (trim to avoid huge contexts).
$summary = $this->build_source_summary( $source_items );
// Include past performance data for self-improvement.
$performance_context = $this->get_performance_context();
$system_prompt = $this->build_system_prompt( $niche, $performance_context );
$user_prompt = $this->build_user_prompt( $summary );
$response = $this->llm->send_chat_completion(
[
[ 'role' => 'system', 'content' => $system_prompt ],
[ 'role' => 'user', 'content' => $user_prompt ],
],
$model,
[
'temperature' => 0.3,
'max_tokens' => 4000,
'response_format' => [ 'type' => 'json_object' ],
]
);
// Log the cost.
$this->cost_tracker->log_api_call(
null,
'analysis',
$this->llm->get_provider_name(),
$response['model'],
$response['prompt_tokens'],
$response['completion_tokens']
);
// Parse LLM response into Analysis_Result objects.
$results = $this->parse_analysis_response( $response['content'], $source_items );
// Store in database.
$this->store_results( $results );
return $results;
}
/**
* Fetch source data collected in the last 24 hours.
*
* @return array<int, array<string, mixed>> Raw database rows.
*/
private function fetch_recent_source_data(): array {
global $wpdb;
$table = $wpdb->prefix . 'autoblogger_source_data';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
return $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE collected_at >= %s ORDER BY score DESC LIMIT 200",
gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS )
),
ARRAY_A
) ?: [];
}
/**
* Build a text summary of source data for the LLM to analyze.
*
* Prioritizes high-engagement content and keeps total text under ~8000 tokens.
*
* @param array<int, array<string, mixed>> $items Source data rows.
*
* @return string Formatted text summary.
*/
private function build_source_summary( array $items ): string {
$parts = [];
$char_limit = 30000; // Roughly ~7500 tokens.
$current_chars = 0;
foreach ( $items as $item ) {
$entry = sprintf(
"[%s] r/%s | Score: %d | Comments: %d\nTitle: %s\n%s\n---",
$item['source_type'],
$item['subreddit'] ?? 'unknown',
(int) $item['score'],
(int) $item['comment_count'],
$item['title'] ?? '(comment)',
mb_substr( $item['content'] ?? '', 0, 500 )
);
if ( $current_chars + strlen( $entry ) > $char_limit ) {
break;
}
$parts[] = $entry;
$current_chars += strlen( $entry );
}
return implode( "\n", $parts );
}
/**
* Get past content performance data to feed into analysis for self-improvement.
*
* @return string Summary of what topics performed well/poorly.
*/
private function get_performance_context(): string {
global $wpdb;
$scores_table = $wpdb->prefix . 'autoblogger_content_scores';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$top_posts = $wpdb->get_results(
"SELECT cs.post_id, cs.composite_score, p.post_title
FROM {$scores_table} cs
JOIN {$wpdb->posts} p ON p.ID = cs.post_id
WHERE cs.composite_score > 0
ORDER BY cs.composite_score DESC
LIMIT 5",
ARRAY_A
);
if ( empty( $top_posts ) ) {
return '';
}
$lines = [ 'Top performing past articles (learn from these):' ];
foreach ( $top_posts as $post ) {
$lines[] = sprintf(
'- "%s" (score: %.1f)',
$post['post_title'],
(float) $post['composite_score']
);
}
return implode( "\n", $lines );
}
/**
* Build the system prompt for content analysis.
*
* @param string $niche Niche description from settings.
* @param string $performance_context Past performance data.
*
* @return string
*/
private function build_system_prompt( string $niche, string $performance_context ): string {
$prompt = "You are a content strategist analyzing social media discussions to find article ideas for a blog";
if ( '' !== $niche ) {
$prompt .= " in the {$niche} niche";
}
$prompt .= ".\n\n";
$prompt .= "Analyze the provided social media posts and comments to identify:\n";
$prompt .= "1. QUESTIONS: Recurring questions people ask (\"How do I...\", \"What is...\", \"Is it safe to...\")\n";
$prompt .= "2. COMPLAINTS: Pain points, frustrations, or problems people report\n";
$prompt .= "3. COMPARISONS: Product/method comparisons people discuss (\"X vs Y\", \"Which is better\")\n\n";
$prompt .= "For each pattern found, provide:\n";
$prompt .= "- type: 'question', 'complaint', or 'comparison'\n";
$prompt .= "- topic: A clear, specific topic description\n";
$prompt .= "- summary: 1-2 sentence summary of the pattern\n";
$prompt .= "- frequency: How many of the provided posts relate to this pattern\n";
$prompt .= "- relevance_score: 0.0 to 1.0 indicating how relevant and article-worthy this topic is\n";
$prompt .= "- suggested_title: A compelling blog post title for this topic\n";
$prompt .= "- key_points: Array of 3-5 key points the article should cover\n";
$prompt .= "- target_keywords: Array of SEO keywords for this topic\n\n";
if ( '' !== $performance_context ) {
$prompt .= $performance_context . "\n\n";
}
$prompt .= "Respond with valid JSON in this format:\n";
$prompt .= '{"patterns": [{"type": "...", "topic": "...", "summary": "...", "frequency": N, "relevance_score": 0.X, "suggested_title": "...", "key_points": [...], "target_keywords": [...]}]}';
return $prompt;
}
/**
* Build the user prompt containing the actual source data.
*
* @param string $summary Formatted source data summary.
*
* @return string
*/
private function build_user_prompt( string $summary ): string {
return "Here are the recent social media posts and comments to analyze:\n\n" . $summary;
}
/**
* Parse the LLM's JSON response into Analysis_Result objects.
*
* @param string $content LLM response content (JSON).
* @param array<int, array<string, mixed>> $source_items Original source data for ID mapping.
*
* @return Autoblogger_Analysis_Result[]
*/
private function parse_analysis_response( string $content, array $source_items ): array {
$data = json_decode( $content, true );
if ( ! isset( $data['patterns'] ) || ! is_array( $data['patterns'] ) ) {
Autoblogger_Logger::instance()->error( 'Analysis response was not valid JSON or missing patterns key.', 'analyzer' );
return [];
}
$source_ids = array_column( $source_items, 'id' );
$results = [];
foreach ( $data['patterns'] as $pattern ) {
$results[] = new Autoblogger_Analysis_Result( [
'analysis_type' => sanitize_text_field( $pattern['type'] ?? 'question' ),
'topic' => sanitize_text_field( $pattern['topic'] ?? '' ),
'summary' => sanitize_textarea_field( $pattern['summary'] ?? '' ),
'frequency' => absint( $pattern['frequency'] ?? 1 ),
'relevance_score' => (float) ( $pattern['relevance_score'] ?? 0.0 ),
'source_ids' => $source_ids,
'analyzed_at' => current_time( 'mysql' ),
'metadata' => [
'suggested_title' => $pattern['suggested_title'] ?? '',
'key_points' => $pattern['key_points'] ?? [],
'target_keywords' => $pattern['target_keywords'] ?? [],
],
] );
}
return $results;
}
/**
* Store analysis results in the database.
*
* Side effects: database inserts.
*
* @param Autoblogger_Analysis_Result[] $results
*
* @return void
*/
private function store_results( array $results ): void {
global $wpdb;
$table = $wpdb->prefix . 'autoblogger_analysis_results';
foreach ( $results as $result ) {
$row = $result->to_db_row();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert( $table, $row );
}
}
}

View File

@@ -0,0 +1,297 @@
<?php
declare(strict_types=1);
/**
* Writer agent — generates blog post content from article ideas.
*
* Supports two configurable pipeline modes:
* - single_pass: One LLM call produces the complete article (cheaper, faster).
* - multi_step: Outline → Draft → Polish pipeline (higher quality, more cost).
*
* Triggered by: Autoblogger::run_generation_pipeline() (step 4).
* Dependencies: LLM_Provider_Interface, Cost_Tracker.
*
* @see core/class-chief-editor.php — Reviews output of this class.
* @see providers/interface-llm-provider.php — LLM used for generation.
* @see ARCHITECTURE.md — Data flow step 4.
*/
class Autoblogger_Content_Generator {
private Autoblogger_LLM_Provider_Interface $llm;
private Autoblogger_Cost_Tracker $cost_tracker;
public function __construct(
Autoblogger_LLM_Provider_Interface $llm,
Autoblogger_Cost_Tracker $cost_tracker
) {
$this->llm = $llm;
$this->cost_tracker = $cost_tracker;
}
/**
* Generate a blog post from an article idea.
*
* Reads pipeline mode from settings and dispatches to the appropriate method.
*
* Side effects: LLM API calls (1-3 depending on mode), cost logging.
*
* @param Autoblogger_Article_Idea $idea The scored idea to generate content for.
*
* @return string Generated HTML content.
*
* @throws \RuntimeException On generation failure.
*/
public function generate( Autoblogger_Article_Idea $idea ): string {
$mode = get_option( 'autoblogger_writing_pipeline', 'multi_step' );
$request = new Autoblogger_Content_Request(
$idea,
$mode,
get_option( 'autoblogger_tone', 'informational' ),
absint( get_option( 'autoblogger_min_word_count', 800 ) ),
absint( get_option( 'autoblogger_max_word_count', 2000 ) ),
get_option( 'autoblogger_niche_description', '' ),
json_decode( get_option( 'autoblogger_topic_exclusions', '[]' ), true ) ?: []
);
if ( 'single_pass' === $mode ) {
return $this->generate_single_pass( $request );
}
return $this->generate_multi_step( $request );
}
/**
* Single-pass generation: one LLM call produces the complete article.
*
* @param Autoblogger_Content_Request $request
*
* @return string Generated HTML content.
*/
private function generate_single_pass( Autoblogger_Content_Request $request ): string {
$model = get_option( 'autoblogger_writing_model', AUTOBLOGGER_DEFAULT_WRITING_MODEL );
$idea = $request->get_idea();
$system = $this->build_writer_system_prompt( $request );
$user = $this->build_single_pass_prompt( $request );
$response = $this->llm->send_chat_completion(
[
[ 'role' => 'system', 'content' => $system ],
[ 'role' => 'user', 'content' => $user ],
],
$model,
[
'temperature' => 0.7,
'max_tokens' => 4000,
]
);
$this->cost_tracker->log_api_call(
null,
'draft',
$this->llm->get_provider_name(),
$response['model'],
$response['prompt_tokens'],
$response['completion_tokens']
);
return $response['content'];
}
/**
* Multi-step generation: Outline → Draft → Polish.
*
* @param Autoblogger_Content_Request $request
*
* @return string Generated HTML content.
*/
private function generate_multi_step( Autoblogger_Content_Request $request ): string {
$model = get_option( 'autoblogger_writing_model', AUTOBLOGGER_DEFAULT_WRITING_MODEL );
// Step 1: Generate outline.
$outline = $this->stage_outline( $request, $model );
// Step 2: Generate draft from outline.
$draft = $this->stage_draft( $request, $outline, $model );
// Step 3: Polish the draft.
$polished = $this->stage_polish( $request, $draft, $model );
return $polished;
}
/**
* Multi-step stage 1: Generate an article outline.
*
* @param Autoblogger_Content_Request $request
* @param string $model
*
* @return string The outline text.
*/
private function stage_outline( Autoblogger_Content_Request $request, string $model ): string {
$idea = $request->get_idea();
$prompt = sprintf(
"Create a detailed outline for a blog post titled: \"%s\"\n\n" .
"Topic: %s\nArticle type: %s\n\nKey points to cover:\n%s\n\n" .
"Target keywords: %s\n\n" .
"The outline should have 4-6 main sections with bullet points under each. " .
"Include an introduction hook and a conclusion with a call to action. " .
"Word count target: %d-%d words.",
$idea->get_suggested_title(),
$idea->get_topic(),
$idea->get_article_type(),
implode( "\n- ", $idea->get_key_points() ),
implode( ', ', $idea->get_target_keywords() ),
$request->get_min_word_count(),
$request->get_max_word_count()
);
$response = $this->llm->send_chat_completion(
[
[ 'role' => 'system', 'content' => $this->build_writer_system_prompt( $request ) ],
[ 'role' => 'user', 'content' => $prompt ],
],
$model,
[ 'temperature' => 0.5, 'max_tokens' => 1500 ]
);
$this->cost_tracker->log_api_call(
null, 'outline', $this->llm->get_provider_name(),
$response['model'], $response['prompt_tokens'], $response['completion_tokens']
);
return $response['content'];
}
/**
* Multi-step stage 2: Write the full draft from the outline.
*
* @param Autoblogger_Content_Request $request
* @param string $outline
* @param string $model
*
* @return string Draft HTML content.
*/
private function stage_draft( Autoblogger_Content_Request $request, string $outline, string $model ): string {
$prompt = sprintf(
"Using this outline, write the full blog post in HTML format.\n\n" .
"OUTLINE:\n%s\n\n" .
"Requirements:\n" .
"- Write in a %s tone\n" .
"- Target %d-%d words\n" .
"- Use proper HTML headings (h2, h3), paragraphs, and lists\n" .
"- Include an engaging introduction and strong conclusion\n" .
"- Naturally incorporate these keywords: %s\n" .
"- Do NOT include the title in the HTML (it will be set separately)\n" .
"- Do NOT wrap in <html>, <head>, or <body> tags — just the article content",
$outline,
$request->get_tone(),
$request->get_min_word_count(),
$request->get_max_word_count(),
implode( ', ', $request->get_idea()->get_target_keywords() )
);
$response = $this->llm->send_chat_completion(
[
[ 'role' => 'system', 'content' => $this->build_writer_system_prompt( $request ) ],
[ 'role' => 'user', 'content' => $prompt ],
],
$model,
[ 'temperature' => 0.7, 'max_tokens' => 4000 ]
);
$this->cost_tracker->log_api_call(
null, 'draft', $this->llm->get_provider_name(),
$response['model'], $response['prompt_tokens'], $response['completion_tokens']
);
return $response['content'];
}
/**
* Multi-step stage 3: Polish and refine the draft.
*
* @param Autoblogger_Content_Request $request
* @param string $draft
* @param string $model
*
* @return string Polished HTML content.
*/
private function stage_polish( Autoblogger_Content_Request $request, string $draft, string $model ): string {
$prompt = "Review and polish this blog post draft. Improve:\n" .
"1. Flow and readability\n" .
"2. SEO optimization (headings, keyword placement)\n" .
"3. Engagement (hooks, transitions, call-to-action)\n" .
"4. Accuracy and clarity\n" .
"5. Remove any filler or redundant sentences\n\n" .
"Return the polished HTML content only. Do not add commentary.\n\n" .
"DRAFT:\n" . $draft;
$response = $this->llm->send_chat_completion(
[
[ 'role' => 'system', 'content' => $this->build_writer_system_prompt( $request ) ],
[ 'role' => 'user', 'content' => $prompt ],
],
$model,
[ 'temperature' => 0.4, 'max_tokens' => 4000 ]
);
$this->cost_tracker->log_api_call(
null, 'polish', $this->llm->get_provider_name(),
$response['model'], $response['prompt_tokens'], $response['completion_tokens']
);
return $response['content'];
}
/**
* Build the system prompt shared across all writing stages.
*
* @param Autoblogger_Content_Request $request
*
* @return string
*/
private function build_writer_system_prompt( Autoblogger_Content_Request $request ): string {
$niche = $request->get_niche_description();
$prompt = "You are an expert blog writer";
if ( '' !== $niche ) {
$prompt .= " specializing in {$niche}";
}
$prompt .= ". Write well-researched, engaging, SEO-friendly content. ";
$prompt .= "Use a {$request->get_tone()} tone. ";
$prompt .= "Output HTML content only — no markdown, no code fences, no commentary.";
return $prompt;
}
/**
* Build the user prompt for single-pass generation.
*
* @param Autoblogger_Content_Request $request
*
* @return string
*/
private function build_single_pass_prompt( Autoblogger_Content_Request $request ): string {
$idea = $request->get_idea();
return sprintf(
"Write a complete blog post in HTML format.\n\n" .
"Title: %s\nTopic: %s\nType: %s\n\nKey points:\n- %s\n\n" .
"Keywords: %s\n\n" .
"Requirements:\n" .
"- %d-%d words\n" .
"- Proper HTML (h2, h3, p, ul/li)\n" .
"- Engaging intro, strong conclusion with CTA\n" .
"- Do NOT include the title or <html>/<body> tags\n" .
"- Output HTML only, no markdown or commentary",
$idea->get_suggested_title(),
$idea->get_topic(),
$idea->get_article_type(),
implode( "\n- ", $idea->get_key_points() ),
implode( ', ', $idea->get_target_keywords() ),
$request->get_min_word_count(),
$request->get_max_word_count()
);
}
}

View File

@@ -0,0 +1,254 @@
<?php
declare(strict_types=1);
/**
* Tracks all API costs, enforces monthly budget limits, and provides
* cost reporting data for the admin dashboard.
*
* 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
* API calls are made until the next month or the budget is increased.
*
* Triggered by: Every class that makes an LLM API call (Content_Analyzer,
* Content_Generator, Chief_Editor, Metrics_Collector).
* Dependencies: WordPress $wpdb, Autoblogger_OpenRouter_Provider (for cost estimation).
*
* @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-chief-editor.php — Calls log_api_call() after review.
* @see admin/class-metrics-page.php — Displays cost data from this class.
* @see ARCHITECTURE.md — ab_generation_log table schema.
*/
class Autoblogger_Cost_Tracker {
/**
* Running cost for the current pipeline execution.
* Reset each time the pipeline starts.
*/
private float $current_run_cost = 0.0;
/**
* Unique identifier for the current pipeline run.
* Used to link generation log entries to specific posts without timestamp-based guessing.
*/
private ?string $run_id = null;
/**
* Set the run_id for the current pipeline execution.
*
* All subsequent log_api_call() entries will be tagged with this ID so
* link_generation_logs() can attribute them to the correct post.
*
* @param string $run_id UUID or unique string for this run.
*
* @return void
*/
public function set_run_id( string $run_id ): void {
$this->run_id = $run_id;
}
/**
* Get the current run_id.
*
* @return string|null
*/
public function get_run_id(): ?string {
return $this->run_id;
}
/**
* Log an API call with its token usage and estimated cost.
*
* Side effects: database insert into ab_generation_log.
*
* @param int|null $post_id WordPress post ID (null during generation, set later).
* @param string $stage Pipeline stage: 'analysis', 'outline', 'draft', 'polish', 'review'.
* @param string $provider Provider name: 'OpenRouter'.
* @param string $model Model identifier used.
* @param int $prompt_tokens Input tokens consumed.
* @param int $completion_tokens Output tokens generated.
* @param string $response_status 'success', 'error', or 'timeout'.
* @param string $error_message Error details if status is not 'success'.
*
* @return void
*/
public function log_api_call(
?int $post_id,
string $stage,
string $provider,
string $model,
int $prompt_tokens,
int $completion_tokens,
string $response_status = 'success',
string $error_message = ''
): void {
$llm = new Autoblogger_OpenRouter_Provider();
$cost = $llm->estimate_cost( $model, $prompt_tokens, $completion_tokens );
$this->current_run_cost += $cost;
$log_entry = new Autoblogger_Generation_Log( [
'post_id' => $post_id,
'run_id' => $this->run_id,
'stage' => $stage,
'provider' => $provider,
'model' => $model,
'prompt_tokens' => $prompt_tokens,
'completion_tokens' => $completion_tokens,
'estimated_cost' => $cost,
'response_status' => $response_status,
'error_message' => $error_message,
'created_at' => current_time( 'mysql' ),
] );
global $wpdb;
$table = $wpdb->prefix . 'autoblogger_generation_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert( $table, $log_entry->to_db_row() );
if ( false === $wpdb->insert_id ) {
Autoblogger_Logger::instance()->error( 'Failed to log API call: ' . $wpdb->last_error, 'cost-tracker' );
}
}
/**
* Check if the monthly budget has been exceeded.
*
* Uses round() to 4 decimal places to avoid floating-point comparison edge
* cases where e.g. $49.999999997 could be treated as >= $50.00.
*
* @return bool True if monthly spend >= configured budget.
*/
public function is_budget_exceeded(): bool {
$budget = (float) get_option( 'autoblogger_monthly_budget_usd', 50.00 );
if ( $budget <= 0 ) {
// Budget of 0 means no limit.
return false;
}
$monthly_spend = $this->get_monthly_spend();
// Round to 4 decimal places (0.01 cent precision) to avoid
// floating-point representation artifacts triggering false positives.
return round( $monthly_spend, 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 . 'autoblogger_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.
*
* @return float USD cost for this run.
*/
public function get_current_run_cost(): float {
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 . 'autoblogger_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 . 'autoblogger_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( 'autoblogger_monthly_budget_usd', 50.00 );
if ( $budget <= 0 ) {
return 0.0;
}
return ( $this->get_monthly_spend() / $budget ) * 100.0;
}
}

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
/**
* Google Analytics 4 API client for fetching post performance metrics.
*
* Handles OAuth2 service account authentication and the GA4 Data API.
* Decoupled from metrics collection logic for testability and reusability.
*
* Triggered by: Autoblogger_Metrics_Collector::fetch_ga4_data() calls methods here.
* Dependencies: Autoblogger_Encryption (for credentials decryption), wp_remote_post/get().
*
* @see core/class-metrics-collector.php — Instantiates this class.
* @see ARCHITECTURE.md — External API integrations table.
*/
class Autoblogger_GA4_Client {
/**
* Fetch Google Analytics 4 data for given posts.
*
* Returns empty data if GA4 is not configured or API call fails.
*
* Side effects: HTTP request to GA4 API (if configured).
*
* @param \WP_Post[] $posts
*
* @return array<int, array{pageviews: int, avg_time_on_page: float, bounce_rate: float}>
*/
public function fetch_data( array $posts ): array {
$property_id = get_option( 'autoblogger_ga4_property_id', '' );
if ( '' === $property_id ) {
return [];
}
$credentials_encrypted = get_option( 'autoblogger_ga4_credentials_json', '' );
if ( '' === $credentials_encrypted ) {
return [];
}
$credentials = Autoblogger_Encryption::decrypt( $credentials_encrypted );
if ( '' === $credentials ) {
return [];
}
// Build page paths for the GA4 query.
$page_paths = [];
$path_to_id = [];
foreach ( $posts as $post ) {
$path = wp_parse_url( get_permalink( $post->ID ), PHP_URL_PATH );
if ( $path ) {
$page_paths[] = $path;
$path_to_id[ $path ] = (int) $post->ID;
}
}
if ( empty( $page_paths ) ) {
return [];
}
// GA4 Data API request.
// Using the REST API directly since we don't want a full Google SDK dependency.
$access_token = $this->get_access_token( $credentials );
if ( '' === $access_token ) {
Autoblogger_Logger::instance()->error( 'GA4: Failed to get access token. Possible causes: invalid service account JSON, expired or revoked private key, or network error reaching oauth2.googleapis.com. Verify credentials in AutoBlogger → Settings.', 'ga4' );
return [];
}
$request_body = [
'dateRanges' => [ [ 'startDate' => '30daysAgo', 'endDate' => 'today' ] ],
'dimensions' => [ [ 'name' => 'pagePath' ] ],
'metrics' => [
[ 'name' => 'screenPageViews' ],
[ 'name' => 'averageSessionDuration' ],
[ 'name' => 'bounceRate' ],
],
'dimensionFilter' => [
'filter' => [
'fieldName' => 'pagePath',
'inListFilter' => [ 'values' => $page_paths ],
],
],
];
$response = wp_remote_post(
"https://analyticsdata.googleapis.com/v1beta/properties/{$property_id}:runReport",
[
'timeout' => 30,
'headers' => [
'Authorization' => 'Bearer ' . $access_token,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode( $request_body ),
]
);
if ( is_wp_error( $response ) ) {
Autoblogger_Logger::instance()->error( 'GA4 API request failed (WP HTTP error): ' . $response->get_error_message(), 'ga4' );
return [];
}
$ga4_status = wp_remote_retrieve_response_code( $response );
if ( 200 !== $ga4_status ) {
$ga4_body = wp_remote_retrieve_body( $response );
Autoblogger_Logger::instance()->error(
sprintf( 'GA4 API request failed (HTTP %d): %s', $ga4_status, substr( $ga4_body, 0, 500 ) ),
'ga4'
);
return [];
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
$results = [];
foreach ( ( $data['rows'] ?? [] ) as $row ) {
$path = $row['dimensionValues'][0]['value'] ?? '';
if ( isset( $path_to_id[ $path ] ) ) {
$results[ $path_to_id[ $path ] ] = [
'pageviews' => (int) ( $row['metricValues'][0]['value'] ?? 0 ),
'avg_time_on_page' => (float) ( $row['metricValues'][1]['value'] ?? 0 ),
'bounce_rate' => (float) ( $row['metricValues'][2]['value'] ?? 0 ),
];
}
}
return $results;
}
/**
* Get a GA4 access token from service account credentials.
*
* @param string $credentials_json JSON service account key.
*
* @return string Access token, or empty string on failure.
*/
private function get_access_token( string $credentials_json ): string {
$creds = json_decode( $credentials_json, true );
if ( ! isset( $creds['client_email'], $creds['private_key'] ) ) {
return '';
}
// Build JWT for service account auth.
$now = time();
$header = wp_json_encode( [ 'alg' => 'RS256', 'typ' => 'JWT' ] );
$claim = wp_json_encode( [
'iss' => $creds['client_email'],
'scope' => 'https://www.googleapis.com/auth/analytics.readonly',
'aud' => 'https://oauth2.googleapis.com/token',
'exp' => $now + 3600,
'iat' => $now,
] );
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
$base64_header = rtrim( strtr( base64_encode( $header ), '+/', '-_' ), '=' );
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
$base64_claim = rtrim( strtr( base64_encode( $claim ), '+/', '-_' ), '=' );
$signing_input = $base64_header . '.' . $base64_claim;
$signature = '';
$key = openssl_pkey_get_private( $creds['private_key'] );
if ( false === $key ) {
Autoblogger_Logger::instance()->error( 'GA4: Failed to parse private key from service account credentials. The key may be malformed or the OpenSSL extension may be misconfigured.', 'ga4' );
return '';
}
openssl_sign( $signing_input, $signature, $key, OPENSSL_ALGO_SHA256 );
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
$base64_signature = rtrim( strtr( base64_encode( $signature ), '+/', '-_' ), '=' );
$jwt = $signing_input . '.' . $base64_signature;
$response = wp_remote_post(
'https://oauth2.googleapis.com/token',
[
'timeout' => 15,
'body' => [
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
],
]
);
if ( is_wp_error( $response ) ) {
Autoblogger_Logger::instance()->error( 'GA4: OAuth token exchange failed (WP HTTP error): ' . $response->get_error_message(), 'ga4' );
return '';
}
$token_status = wp_remote_retrieve_response_code( $response );
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 200 !== $token_status || ! isset( $data['access_token'] ) ) {
$error_desc = $data['error_description'] ?? $data['error'] ?? 'unknown';
Autoblogger_Logger::instance()->error(
sprintf( 'GA4: OAuth token exchange failed (HTTP %d): %s', $token_status, $error_desc ),
'ga4'
);
return '';
}
return $data['access_token'];
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
/**
* Ranks and deduplicates article ideas from analysis results.
*
* Compares against existing published posts to avoid topic overlap,
* scores ideas by relevance, frequency, and freshness, then returns
* the top N ideas matching the daily target.
*
* Triggered by: Autoblogger::run_generation_pipeline() (step 3).
* Dependencies: WordPress $wpdb, WP_Query.
*
* @see core/class-content-analyzer.php — Produces the analysis results we consume.
* @see core/class-content-generator.php — Consumes the ranked ideas we produce.
* @see ARCHITECTURE.md — Data flow step 3.
*/
class Autoblogger_Idea_Scorer {
/**
* Score, deduplicate, and rank article ideas.
*
* @param Autoblogger_Analysis_Result[] $analysis_results Raw analysis output.
* @param int $target_count Number of ideas to return.
*
* @return Autoblogger_Article_Idea[] Top-ranked ideas, sorted by score descending.
*/
public function score_and_rank( array $analysis_results, int $target_count ): array {
$exclusions = json_decode( get_option( 'autoblogger_topic_exclusions', '[]' ), true ) ?: [];
$ideas = [];
foreach ( $analysis_results as $result ) {
// Skip excluded topics.
if ( $this->is_excluded( $result->get_topic(), $exclusions ) ) {
continue;
}
// Skip if we've already published something very similar.
if ( $this->has_similar_post( $result->get_topic() ) ) {
continue;
}
$metadata = $result->get_metadata() ?? [];
$ideas[] = new Autoblogger_Article_Idea( [
'topic' => $result->get_topic(),
'article_type' => $this->map_type( $result->get_analysis_type() ),
'suggested_title' => $metadata['suggested_title'] ?? $result->get_topic(),
'summary' => $result->get_summary() ?? '',
'score' => $this->compute_score( $result ),
'analysis_id' => $result->get_id(),
'source_ids' => $result->get_source_ids(),
'key_points' => $metadata['key_points'] ?? [],
'target_keywords' => $metadata['target_keywords'] ?? [],
] );
}
// Sort by score descending.
usort( $ideas, static function ( Autoblogger_Article_Idea $a, Autoblogger_Article_Idea $b ): int {
return $b->get_score() <=> $a->get_score();
} );
return array_slice( $ideas, 0, $target_count );
}
/**
* Compute a composite score for an analysis result.
*
* Factors: LLM relevance score (50%), frequency (30%), recency bonus (20%).
*
* @param Autoblogger_Analysis_Result $result
*
* @return float Score between 0 and 1.
*/
private function compute_score( Autoblogger_Analysis_Result $result ): float {
$relevance = min( $result->get_relevance_score(), 1.0 );
$frequency = min( $result->get_frequency() / 10.0, 1.0 );
// Recency: analyzed within last 24h gets full bonus.
$hours_ago = ( time() - strtotime( $result->get_analyzed_at() ) ) / 3600.0;
$recency = max( 0.0, 1.0 - ( $hours_ago / 48.0 ) );
return ( $relevance * 0.5 ) + ( $frequency * 0.3 ) + ( $recency * 0.2 );
}
/**
* Check if a topic should be excluded based on user-configured exclusions.
*
* @param string $topic Topic text.
* @param string[] $exclusions List of excluded terms/phrases.
*
* @return bool
*/
private function is_excluded( string $topic, array $exclusions ): bool {
$topic_lower = strtolower( $topic );
foreach ( $exclusions as $exclusion ) {
if ( false !== strpos( $topic_lower, strtolower( trim( $exclusion ) ) ) ) {
return true;
}
}
return false;
}
/**
* Check if a substantially similar post already exists.
*
* Uses a simple keyword overlap check against existing published post titles.
*
* @param string $topic Topic to check.
*
* @return bool True if a similar post exists.
*/
private function has_similar_post( string $topic ): bool {
$query = new \WP_Query( [
'post_type' => 'post',
'post_status' => [ 'publish', 'draft', 'pending' ],
'meta_key' => '_autoblogger_generated',
'meta_value' => '1',
's' => $topic,
'posts_per_page' => 1,
'fields' => 'ids',
] );
return $query->found_posts > 0;
}
/**
* Map analysis type to a reader-friendly article type.
*
* @param string $analysis_type 'question', 'complaint', 'comparison'.
*
* @return string Article type label.
*/
private function map_type( string $analysis_type ): string {
$map = [
'question' => 'guide',
'complaint' => 'solution',
'comparison' => 'comparison',
];
return $map[ $analysis_type ] ?? 'article';
}
}

220
includes/core/class-logger.php Executable file
View File

@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
/**
* Structured logging with configurable verbosity levels.
*
* Writes log entries to a custom database table and optionally forwards
* to PHP's error_log() for server-level monitoring. The log level is
* user-configurable via Settings → AutoBlogger → General.
*
* Levels (ascending verbosity): error → warning → info → debug.
* Setting the level to "info" captures error, warning, and info — not debug.
*
* Triggered by: Every class that previously called error_log() directly.
* Dependencies: WordPress $wpdb.
*
* @see admin/class-log-viewer.php — Reads and displays log entries.
* @see class-activator.php — Creates the ab_event_log table.
* @see ARCHITECTURE.md — Logging architecture section.
*/
class Autoblogger_Logger {
/** Log levels, lower = more severe. */
public const LEVEL_ERROR = 0;
public const LEVEL_WARNING = 1;
public const LEVEL_INFO = 2;
public const LEVEL_DEBUG = 3;
private const LEVEL_MAP = [
'error' => self::LEVEL_ERROR,
'warning' => self::LEVEL_WARNING,
'info' => self::LEVEL_INFO,
'debug' => self::LEVEL_DEBUG,
];
private const LEVEL_LABELS = [
self::LEVEL_ERROR => 'error',
self::LEVEL_WARNING => 'warning',
self::LEVEL_INFO => 'info',
self::LEVEL_DEBUG => 'debug',
];
/** Singleton instance. */
private static ?self $instance = null;
/** Cached numeric threshold from settings. */
private int $threshold;
private function __construct() {
$setting = get_option( 'autoblogger_log_level', 'info' );
$this->threshold = self::LEVEL_MAP[ $setting ] ?? self::LEVEL_INFO;
}
/** Get the singleton logger instance. */
public static function instance(): self {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Log an error (always captured unless logging is completely off).
*
* @param string $message Human-readable description.
* @param string $context Origin class or pipeline stage.
* @param array<string, mixed> $meta Optional structured data.
*/
public function error( string $message, string $context = '', array $meta = [] ): void {
$this->log( self::LEVEL_ERROR, $message, $context, $meta );
}
/**
* Log a warning (budget approaching, retries, degraded behavior).
*
* @param string $message
* @param string $context
* @param array<string, mixed> $meta
*/
public function warning( string $message, string $context = '', array $meta = [] ): void {
$this->log( self::LEVEL_WARNING, $message, $context, $meta );
}
/**
* Log an informational event (pipeline start/stop, articles generated).
*
* @param string $message
* @param string $context
* @param array<string, mixed> $meta
*/
public function info( string $message, string $context = '', array $meta = [] ): void {
$this->log( self::LEVEL_INFO, $message, $context, $meta );
}
/**
* Log debug detail (API token counts, timing, intermediate values).
*
* @param string $message
* @param string $context
* @param array<string, mixed> $meta
*/
public function debug( string $message, string $context = '', array $meta = [] ): void {
$this->log( self::LEVEL_DEBUG, $message, $context, $meta );
}
/**
* Core logging method. Writes to DB if level meets threshold.
*
* Side effects: database INSERT, optionally PHP error_log().
*
* @param int $level Numeric level constant.
* @param string $message Human-readable message.
* @param string $context Origin (class/stage).
* @param array<string, mixed> $meta Structured data.
*/
private function log( int $level, string $message, string $context, array $meta ): void {
if ( $level > $this->threshold ) {
return;
}
$label = self::LEVEL_LABELS[ $level ] ?? 'info';
// Always forward errors to PHP error_log for server-level monitoring.
if ( $level <= self::LEVEL_WARNING ) {
error_log( sprintf( '[AutoBlogger][%s] %s — %s', strtoupper( $label ), $context, $message ) );
}
global $wpdb;
$table = $wpdb->prefix . 'autoblogger_event_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert( $table, [
'level' => $label,
'context' => substr( $context, 0, 100 ),
'message' => substr( $message, 0, 2000 ),
'meta_json' => ! empty( $meta ) ? wp_json_encode( $meta ) : null,
'created_at' => current_time( 'mysql' ),
] );
}
/**
* Query log entries for the admin viewer.
*
* @param string $level_filter Filter by level: 'all', 'error', 'warning', 'info', 'debug'.
* @param string $search Search within message/context.
* @param int $page Page number (1-based).
* @param int $per_page Results per page.
*
* @return array{rows: array, total: int}
*/
public static function query(
string $level_filter = 'all',
string $search = '',
int $page = 1,
int $per_page = 50
): array {
global $wpdb;
$table = $wpdb->prefix . 'autoblogger_event_log';
$where = [];
$params = [];
if ( 'all' !== $level_filter && isset( self::LEVEL_MAP[ $level_filter ] ) ) {
$where[] = 'level = %s';
$params[] = $level_filter;
}
if ( '' !== $search ) {
$like = '%' . $wpdb->esc_like( $search ) . '%';
$where[] = '(message LIKE %s OR context LIKE %s)';
$params[] = $like;
$params[] = $like;
}
$where_sql = ! empty( $where ) ? 'WHERE ' . implode( ' AND ', $where ) : '';
$offset = ( $page - 1 ) * $per_page;
// Total count.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$total = (int) $wpdb->get_var(
empty( $params )
? "SELECT COUNT(*) FROM {$table} {$where_sql}"
: $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params )
);
// Rows.
$query_params = $params;
$query_params[] = $per_page;
$query_params[] = $offset;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d",
...$query_params
),
ARRAY_A
);
return [ 'rows' => $rows ?: [], 'total' => $total ];
}
/**
* Prune log entries older than a given number of days.
*
* @param int $days Keep entries from the last N days.
* @return int Number of rows deleted.
*/
public static function prune( int $days = 30 ): int {
global $wpdb;
$table = $wpdb->prefix . 'autoblogger_event_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
return (int) $wpdb->query(
$wpdb->prepare(
"DELETE FROM {$table} WHERE created_at < %s",
gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) )
)
);
}
}

View File

@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
/**
* Collects post performance metrics from WordPress native data and Google Analytics 4.
*
* Runs on a separate cron schedule (every 6 hours) to gather pageviews, bounce rate,
* time on page, and comments for AutoBlogger-generated posts. Computes a composite
* "content score" that feeds back into the analysis engine for self-improvement.
*
* Triggered by: WP-Cron hook `autoblogger_collect_metrics` (every 6 hours).
* Dependencies: WordPress $wpdb, Autoblogger_GA4_Client (for GA4 API calls).
*
* @see core/class-ga4-client.php — Handles GA4 API authentication and requests.
* @see core/class-content-analyzer.php — Reads scores for self-improvement loop.
* @see admin/class-metrics-page.php — Displays collected metrics.
* @see ARCHITECTURE.md — Data flow step 7.
*/
class Autoblogger_Metrics_Collector {
/**
* Collect metrics for all AutoBlogger-generated posts.
*
* Gathers WordPress native metrics (comments) and GA4 data (if configured),
* then computes composite scores.
*
* Side effects: GA4 API calls (if configured), database writes.
*
* @return int Number of posts scored.
*/
public function collect_all(): int {
$posts = $this->get_generated_posts();
if ( empty( $posts ) ) {
return 0;
}
$ga4_client = new Autoblogger_GA4_Client();
$ga4_data = $ga4_client->fetch_data( $posts );
$scored = 0;
foreach ( $posts as $post ) {
$post_id = (int) $post->ID;
$wp_metrics = $this->get_wp_native_metrics( $post_id );
// Skip posts that were deleted between the query and metrics collection.
if ( null === $wp_metrics ) {
continue;
}
$ga_metrics = $ga4_data[ $post_id ] ?? [];
$score = $this->compute_composite_score( $wp_metrics, $ga_metrics, $post );
$this->store_score( $post_id, $wp_metrics, $ga_metrics, $score );
$scored++;
}
Autoblogger_Logger::instance()->info( sprintf( 'Metrics collected for %d posts.', $scored ), 'metrics' );
return $scored;
}
/**
* Get all AutoBlogger-generated published posts.
*
* @return \WP_Post[]
*/
private function get_generated_posts(): array {
$query = new \WP_Query( [
'post_type' => 'post',
'post_status' => 'publish',
'meta_key' => '_autoblogger_generated',
'meta_value' => '1',
'posts_per_page' => 100,
'orderby' => 'date',
'order' => 'DESC',
] );
return $query->posts;
}
/**
* Get WordPress native metrics for a post.
*
* @param int $post_id
*
* @return array{comment_count: int, days_published: int}|null Null if post no longer exists.
*/
private function get_wp_native_metrics( int $post_id ): ?array {
$post = get_post( $post_id );
// Post may have been deleted between the WP_Query and this call.
if ( null === $post ) {
Autoblogger_Logger::instance()->debug( sprintf( 'Metrics: Post ID %d no longer exists, skipping.', $post_id ), 'metrics' );
return null;
}
return [
'comment_count' => (int) ( $post->comment_count ?? 0 ),
'days_published' => max( 1, (int) ( ( time() - strtotime( $post->post_date ) ) / DAY_IN_SECONDS ) ),
];
}
/**
* Compute a composite content score (0-100) from multiple metrics.
*
* @param array $wp_metrics WordPress native metrics.
* @param array $ga_metrics GA4 metrics (may be empty).
* @param \WP_Post $post The post object.
*
* @return array{score: float, factors: array<string, float>}
*/
private function compute_composite_score( array $wp_metrics, array $ga_metrics, \WP_Post $post ): array {
$factors = [];
// Comment engagement (0-25 points).
$comments_per_day = $wp_metrics['comment_count'] / max( 1, $wp_metrics['days_published'] );
$factors['comments'] = min( 25.0, $comments_per_day * 25.0 );
// Pageview performance (0-30 points, if GA4 available).
if ( isset( $ga_metrics['pageviews'] ) ) {
$views_per_day = $ga_metrics['pageviews'] / max( 1, $wp_metrics['days_published'] );
$factors['pageviews'] = min( 30.0, $views_per_day * 3.0 );
} else {
$factors['pageviews'] = 0.0;
}
// Time on page (0-25 points, if GA4 available).
if ( isset( $ga_metrics['avg_time_on_page'] ) ) {
// 3+ minutes is excellent for a blog post.
$factors['time_on_page'] = min( 25.0, ( $ga_metrics['avg_time_on_page'] / 180.0 ) * 25.0 );
} else {
$factors['time_on_page'] = 0.0;
}
// Low bounce rate bonus (0-20 points, if GA4 available).
if ( isset( $ga_metrics['bounce_rate'] ) ) {
// Lower bounce rate = better. 40% or below is excellent.
$factors['bounce_rate'] = max( 0.0, 20.0 - ( $ga_metrics['bounce_rate'] * 20.0 ) );
} else {
$factors['bounce_rate'] = 0.0;
}
$total = array_sum( $factors );
return [
'score' => round( $total, 2 ),
'factors' => $factors,
];
}
/**
* Store a content score in the database.
*
* @param int $post_id
* @param array $wp_metrics
* @param array $ga_metrics
* @param array $score_data {score: float, factors: array}.
*
* @return void
*/
private function store_score( int $post_id, array $wp_metrics, array $ga_metrics, array $score_data ): void {
global $wpdb;
$table = $wpdb->prefix . 'autoblogger_content_scores';
$entry = new Autoblogger_Content_Score( [
'post_id' => $post_id,
'pageviews' => $ga_metrics['pageviews'] ?? 0,
'avg_time_on_page' => $ga_metrics['avg_time_on_page'] ?? 0.0,
'bounce_rate' => $ga_metrics['bounce_rate'] ?? 0.0,
'comment_count' => $wp_metrics['comment_count'] ?? 0,
'composite_score' => $score_data['score'],
'score_factors' => $score_data['factors'],
'measured_at' => current_time( 'mysql' ),
] );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert( $table, $entry->to_db_row() );
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
/**
* Executes the full content generation pipeline.
*
* Orchestrates: Budget check → Collect source data → Analyze patterns →
* Score ideas → Generate content → Editorial review → Publish.
*
* Triggered by: Autoblogger::on_daily_generation() and Autoblogger::on_ajax_generate_now().
* Dependencies: All core content classes (Collector, Analyzer, Scorer, Generator, Editor, Publisher).
*
* @see class-autoblogger.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.
*/
class Autoblogger_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.
*/
public function run(): array {
$cost_tracker = new Autoblogger_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.', 'autoblogger' )
);
}
$target_count = absint( get_option( 'autoblogger_daily_article_target', 1 ) );
// Step 1: Collect source data.
$collector = new Autoblogger_Source_Collector();
$collector->collect_from_all_sources();
// Step 2: Analyze collected data for patterns.
$llm = new Autoblogger_OpenRouter_Provider();
$analyzer = new Autoblogger_Content_Analyzer( $llm, $cost_tracker );
$analysis = $analyzer->analyze_recent_data();
// Step 3: Score and deduplicate ideas.
$scorer = new Autoblogger_Idea_Scorer();
$ideas = $scorer->score_and_rank( $analysis, $target_count );
if ( empty( $ideas ) ) {
Autoblogger_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 Autoblogger_Content_Generator( $llm, $cost_tracker );
$editor = new Autoblogger_Chief_Editor( $llm, $cost_tracker );
$publisher = new Autoblogger_Publisher();
$auto_publish = in_array( get_option( 'autoblogger_auto_publish', '0' ), [ '1', 'yes' ], true );
$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() ) {
Autoblogger_Logger::instance()->warning( 'Budget limit reached during generation. Stopping early.', 'pipeline' );
break;
}
try {
$content = $generator->generate( $idea );
$generated++;
$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;
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++;
Autoblogger_Logger::instance()->info( 'Article rejected by editor: ' . $idea->get_topic(), 'pipeline' );
}
} catch ( \Exception $e ) {
Autoblogger_Logger::instance()->error( 'Failed to generate article for topic "' . $idea->get_topic() . '": ' . $e->getMessage(), 'pipeline' );
// Continue with next idea — don't let one failure stop the batch.
}
}
$total_cost = $cost_tracker->get_current_run_cost();
Autoblogger_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,
];
}
}

269
includes/core/class-publisher.php Executable file
View File

@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
/**
* Creates WordPress posts from generated and editor-approved content.
*
* Handles both publishing (editor-approved) and saving as draft (editor-rejected).
* Stores all generation metadata as post_meta for transparency and auditability.
*
* Triggered by: Autoblogger::run_generation_pipeline() (step 6).
* Dependencies: WordPress wp_insert_post(), post meta API.
*
* @see core/class-chief-editor.php — Produces the editorial review we consume.
* @see core/class-content-generator.php — Produces the content we publish.
* @see ARCHITECTURE.md — Data flow step 6.
*/
class Autoblogger_Publisher {
/**
* Publish an editor-approved article.
*
* Creates a WordPress post with 'publish' status and stores all generation
* metadata as post_meta.
*
* Side effects: Creates a WordPress post, writes post_meta.
*
* @param string $content The final HTML content.
* @param Autoblogger_Article_Idea $idea The original article idea.
* @param Autoblogger_Editorial_Review $review The editor's review.
* @param string|null $run_id Pipeline run ID for log linking.
*
* @return int The created post ID.
*
* @throws \RuntimeException If post creation fails.
*/
public function publish(
string $content,
Autoblogger_Article_Idea $idea,
Autoblogger_Editorial_Review $review,
?string $run_id = null
): int {
return $this->create_post( $content, $idea, $review, 'publish', $run_id );
}
/**
* Save an editor-rejected article as a draft for human review.
*
* @param string $content The generated HTML content (pre-revision).
* @param Autoblogger_Article_Idea $idea The original article idea.
* @param Autoblogger_Editorial_Review $review The editor's review with rejection notes.
* @param string|null $run_id Pipeline run ID for log linking.
*
* @return int The created post ID.
*
* @throws \RuntimeException If post creation fails.
*/
public function save_as_draft(
string $content,
Autoblogger_Article_Idea $idea,
Autoblogger_Editorial_Review $review,
?string $run_id = null
): int {
return $this->create_post( $content, $idea, $review, 'draft', $run_id );
}
/**
* Create a WordPress post with generation metadata.
*
* @param string $content HTML content.
* @param Autoblogger_Article_Idea $idea Article idea.
* @param Autoblogger_Editorial_Review $review Editorial review.
* @param string $post_status 'publish' or 'draft'.
* @param string|null $run_id Pipeline run ID for log linking.
*
* @return int Post ID.
*
* @throws \RuntimeException If wp_insert_post fails.
*/
private function create_post(
string $content,
Autoblogger_Article_Idea $idea,
Autoblogger_Editorial_Review $review,
string $post_status,
?string $run_id = null
): int {
$post_data = [
'post_title' => sanitize_text_field( $idea->get_suggested_title() ),
'post_content' => wp_kses_post( $content ),
'post_status' => $post_status,
'post_type' => 'post',
'post_author' => $this->get_default_author_id(),
'meta_input' => $this->build_meta( $idea, $review ),
];
/**
* Fires before an AutoBlogger post is created.
*
* Listeners registered in: class-autoblogger.php (main loader).
*
* @param array $post_data Post data array.
* @param Autoblogger_Article_Idea $idea The article idea.
* @param Autoblogger_Editorial_Review $review The editorial review.
*/
$post_data = apply_filters( 'autoblogger_filter_post_data', $post_data, $idea, $review );
$post_id = wp_insert_post( $post_data, true );
if ( is_wp_error( $post_id ) ) {
throw new \RuntimeException(
sprintf(
/* translators: %s: WordPress error message */
__( 'Failed to create post: %s', 'autoblogger' ),
$post_id->get_error_message()
)
);
}
// Assign categories/tags based on article type and keywords.
$this->assign_taxonomy_terms( $post_id, $idea );
// Update generation log entries to reference this post.
$this->link_generation_logs( $post_id, $run_id );
Autoblogger_Logger::instance()->info(
sprintf( 'Post created: ID=%d, status=%s, title="%s"', $post_id, $post_status, $idea->get_suggested_title() ),
'publisher'
);
/**
* Fires after an AutoBlogger post is successfully created.
*
* @param int $post_id The new post ID.
* @param string $post_status 'publish' or 'draft'.
* @param Autoblogger_Article_Idea $idea The article idea.
* @param Autoblogger_Editorial_Review $review The editorial review.
*/
do_action( 'autoblogger_post_created', $post_id, $post_status, $idea, $review );
return $post_id;
}
/**
* Build post_meta array for generation metadata.
*
* @param Autoblogger_Article_Idea $idea
* @param Autoblogger_Editorial_Review $review
*
* @return array<string, mixed>
*/
private function build_meta(
Autoblogger_Article_Idea $idea,
Autoblogger_Editorial_Review $review
): array {
$pipeline_mode = get_option( 'autoblogger_writing_pipeline', 'multi_step' );
$model = get_option( 'autoblogger_writing_model', AUTOBLOGGER_DEFAULT_WRITING_MODEL );
return [
'_autoblogger_generated' => '1',
'_autoblogger_analysis_id' => $idea->get_analysis_id(),
'_autoblogger_source_ids' => wp_json_encode( $idea->get_source_ids() ),
'_autoblogger_model_used' => $model,
'_autoblogger_pipeline_mode' => $pipeline_mode,
'_autoblogger_editor_verdict' => $review->get_verdict(),
'_autoblogger_editor_notes' => $review->get_notes(),
'_autoblogger_quality_score' => $review->get_quality_score(),
'_autoblogger_seo_score' => $review->get_seo_score(),
'_autoblogger_generated_at' => gmdate( 'c' ),
'_autoblogger_topic' => $idea->get_topic(),
'_autoblogger_article_type' => $idea->get_article_type(),
'_autoblogger_target_keywords' => wp_json_encode( $idea->get_target_keywords() ),
];
}
/**
* Assign category and tags to a generated post.
*
* @param int $post_id
* @param Autoblogger_Article_Idea $idea
*
* @return void
*/
private function assign_taxonomy_terms( int $post_id, Autoblogger_Article_Idea $idea ): void {
$type_category_map = [
'guide' => 'Guides',
'solution' => 'Solutions',
'comparison' => 'Comparisons',
'article' => 'Articles',
];
$category_name = $type_category_map[ $idea->get_article_type() ] ?? 'Articles';
$category = get_term_by( 'name', $category_name, 'category' );
if ( ! $category ) {
$result = wp_insert_term( $category_name, 'category' );
if ( ! is_wp_error( $result ) ) {
wp_set_post_categories( $post_id, [ $result['term_id'] ] );
}
} else {
wp_set_post_categories( $post_id, [ $category->term_id ] );
}
$keywords = $idea->get_target_keywords();
if ( ! empty( $keywords ) ) {
wp_set_post_tags( $post_id, $keywords, true );
}
}
/**
* Link generation log entries to the newly created post using run_id.
*
* The previous timestamp-based approach could misattribute costs in batch runs
* where multiple articles are generated in the same pipeline execution. Using
* run_id ensures each post is linked only to the log entries from its own run.
*
* Falls back to timestamp-based linking if no run_id is set (e.g. legacy data
* from before the run_id migration).
*
* @param int $post_id The newly created post ID.
* @param string|null $run_id Pipeline run identifier, or null for legacy fallback.
*
* @return void
*/
private function link_generation_logs( int $post_id, ?string $run_id = null ): void {
global $wpdb;
$table = $wpdb->prefix . 'autoblogger_generation_log';
if ( null !== $run_id && '' !== $run_id ) {
// Precise linking via run_id — no risk of cross-article misattribution.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare(
"UPDATE {$table} SET post_id = %d WHERE post_id IS NULL AND run_id = %s",
$post_id,
$run_id
)
);
} else {
// Legacy fallback: timestamp-based linking for pre-migration log entries.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare(
"UPDATE {$table} SET post_id = %d WHERE post_id IS NULL AND created_at >= %s",
$post_id,
gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS )
)
);
}
}
/**
* Get the default author ID for generated posts.
*
* @return int WordPress user ID.
*/
private function get_default_author_id(): int {
$author_id = absint( get_option( 'autoblogger_default_author', 0 ) );
if ( $author_id > 0 ) {
return $author_id;
}
$admins = get_users( [
'role' => 'administrator',
'number' => 1,
'fields' => 'ID',
] );
return ! empty( $admins ) ? (int) $admins[0] : 1;
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
/**
* Orchestrates data collection from all enabled source providers.
*
* Iterates through enabled sources (Reddit, TikTok, etc.), calls their
* collect_data() method, and stores results in the ab_source_data table.
* Handles deduplication via UNIQUE index on (source_type, source_id).
*
* Triggered by: Autoblogger::run_generation_pipeline() (step 1).
* Dependencies: Source provider implementations, WordPress $wpdb.
*
* @see providers/interface-source-provider.php — Interface providers implement.
* @see providers/class-reddit-provider.php — Primary source provider.
* @see ARCHITECTURE.md — Data flow step 1.
*/
class Autoblogger_Source_Collector {
/**
* Collect data from all enabled source providers and store in database.
*
* Side effects: HTTP requests to source APIs, database inserts.
*
* @return int Total number of new records inserted.
*/
public function collect_from_all_sources(): int {
$enabled = json_decode( get_option( 'autoblogger_enabled_sources', '["reddit"]' ), true );
if ( ! is_array( $enabled ) ) {
$enabled = [ 'reddit' ];
}
$total_inserted = 0;
foreach ( $enabled as $source_type ) {
$provider = $this->get_provider( $source_type );
if ( null === $provider ) {
Autoblogger_Logger::instance()->warning( "No provider found for source type: {$source_type}", 'collector' );
continue;
}
try {
$data = $provider->collect_data( $this->get_config_for_source( $source_type ) );
$inserted = $this->store_data( $data );
$total_inserted += $inserted;
Autoblogger_Logger::instance()->info(
sprintf( 'Collected %d items from %s (%d new).', count( $data ), $source_type, $inserted ),
'collector'
);
} catch ( \Exception $e ) {
Autoblogger_Logger::instance()->error( "Collection from {$source_type} FAILED: " . $e->getMessage(), 'collector' );
// Continue with other sources — one failure shouldn't stop everything.
}
}
return $total_inserted;
}
/**
* Store collected source data in the database.
*
* Uses INSERT IGNORE to handle duplicates gracefully (UNIQUE index).
*
* Side effects: database inserts.
*
* @param Autoblogger_Source_Data[] $items
*
* @return int Number of new records inserted.
*/
private function store_data( array $items ): int {
global $wpdb;
$table = $wpdb->prefix . 'autoblogger_source_data';
$inserted = 0;
foreach ( $items as $item ) {
$row = $item->to_db_row();
// Use INSERT IGNORE so duplicate (source_type, source_id) pairs are silently skipped.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$result = $wpdb->query(
$wpdb->prepare(
"INSERT IGNORE INTO {$table}
(source_type, source_id, subreddit, title, content, author, score, comment_count, permalink, collected_at, metadata_json)
VALUES (%s, %s, %s, %s, %s, %s, %d, %d, %s, %s, %s)",
$row['source_type'],
$row['source_id'],
$row['subreddit'],
$row['title'],
$row['content'],
$row['author'],
$row['score'],
$row['comment_count'],
$row['permalink'],
$row['collected_at'],
$row['metadata_json']
)
);
if ( false !== $result && $result > 0 ) {
$inserted++;
}
}
return $inserted;
}
/**
* Get the provider instance for a given source type.
*
* @param string $source_type Source type identifier.
*
* @return Autoblogger_Source_Provider_Interface|null
*/
private function get_provider( string $source_type ): ?Autoblogger_Source_Provider_Interface {
$providers = [
'reddit' => Autoblogger_Reddit_Provider::class,
// Future providers: 'tiktok', 'instagram', 'youtube'
];
/**
* Filter the registered source providers.
*
* @param array<string, string> $providers Map of source_type => class name.
*/
$providers = apply_filters( 'autoblogger_filter_source_providers', $providers );
if ( ! isset( $providers[ $source_type ] ) ) {
return null;
}
$class = $providers[ $source_type ];
// Guard against unloadable provider classes (e.g. stub providers not yet
// autoloadable, or classes removed during development).
if ( ! class_exists( $class ) ) {
Autoblogger_Logger::instance()->error(
sprintf( 'Provider class "%s" for source type "%s" does not exist.', $class, $source_type ),
'collector'
);
return null;
}
return new $class();
}
/**
* Build the configuration array for a specific source type from plugin settings.
*
* @param string $source_type Source type identifier.
*
* @return array<string, mixed> Configuration for the provider.
*/
private function get_config_for_source( string $source_type ): array {
if ( 'reddit' === $source_type ) {
return [
'subreddits' => json_decode( get_option( 'autoblogger_target_subreddits', '[]' ), true ) ?: [],
'limit' => 25,
'time_filter' => 'day',
'include_comments' => true,
'comments_per_post' => 10,
];
}
return [];
}
}