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,102 @@
<?php
declare(strict_types=1);
/**
* Value object representing an analyzed topic pattern (recurring question,
* complaint, or comparison detected in source data).
*
* Triggered by: Content_Analyzer creates these; Idea_Scorer consumes them.
* Dependencies: None — pure data object.
*
* @see core/class-content-analyzer.php — Creates these from source data + LLM analysis.
* @see core/class-idea-scorer.php — Ranks these into article ideas.
*/
class Autoblogger_Analysis_Result {
private int $id;
private string $analysis_type;
private string $topic;
private ?string $summary;
private int $frequency;
private float $relevance_score;
private array $source_ids;
private string $analyzed_at;
private ?array $metadata;
/**
* @param array{
* id?: int,
* analysis_type: string,
* topic: string,
* summary?: string|null,
* frequency?: int,
* relevance_score?: float,
* source_ids?: int[],
* analyzed_at?: string,
* metadata?: array|null,
* } $data
*/
public function __construct( array $data ) {
$this->id = (int) ( $data['id'] ?? 0 );
$this->analysis_type = $data['analysis_type'];
$this->topic = $data['topic'];
$this->summary = $data['summary'] ?? null;
$this->frequency = (int) ( $data['frequency'] ?? 1 );
$this->relevance_score = (float) ( $data['relevance_score'] ?? 0.0 );
$this->source_ids = $data['source_ids'] ?? [];
$this->analyzed_at = $data['analyzed_at'] ?? current_time( 'mysql' );
$this->metadata = $data['metadata'] ?? null;
}
public function get_id(): int {
return $this->id;
}
public function get_analysis_type(): string {
return $this->analysis_type;
}
public function get_topic(): string {
return $this->topic;
}
public function get_summary(): ?string {
return $this->summary;
}
public function get_frequency(): int {
return $this->frequency;
}
public function get_relevance_score(): float {
return $this->relevance_score;
}
public function get_source_ids(): array {
return $this->source_ids;
}
public function get_analyzed_at(): string {
return $this->analyzed_at;
}
public function get_metadata(): ?array {
return $this->metadata;
}
/**
* @return array<string, mixed>
*/
public function to_db_row(): array {
return [
'analysis_type' => $this->analysis_type,
'topic' => $this->topic,
'summary' => $this->summary,
'frequency' => $this->frequency,
'relevance_score' => $this->relevance_score,
'source_ids_json' => wp_json_encode( $this->source_ids ),
'analyzed_at' => $this->analyzed_at,
'metadata_json' => null !== $this->metadata ? wp_json_encode( $this->metadata ) : null,
];
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
/**
* Value object representing a scored article idea ready for content generation.
*
* Created by the Idea_Scorer from Analysis_Results. Consumed by Content_Generator.
*
* Triggered by: Idea_Scorer creates these; Content_Generator and Chief_Editor consume them.
* Dependencies: None — pure data object.
*
* @see core/class-idea-scorer.php — Creates and ranks these.
* @see core/class-content-generator.php — Uses these as generation input.
*/
class Autoblogger_Article_Idea {
private string $topic;
private string $article_type;
private string $suggested_title;
private string $summary;
private float $score;
private int $analysis_id;
private array $source_ids;
private array $key_points;
private array $target_keywords;
/**
* @param array{
* topic: string,
* article_type: string,
* suggested_title: string,
* summary: string,
* score?: float,
* analysis_id?: int,
* source_ids?: int[],
* key_points?: string[],
* target_keywords?: string[],
* } $data
*/
public function __construct( array $data ) {
$this->topic = $data['topic'];
$this->article_type = $data['article_type'];
$this->suggested_title = $data['suggested_title'];
$this->summary = $data['summary'];
$this->score = (float) ( $data['score'] ?? 0.0 );
$this->analysis_id = (int) ( $data['analysis_id'] ?? 0 );
$this->source_ids = $data['source_ids'] ?? [];
$this->key_points = $data['key_points'] ?? [];
$this->target_keywords = $data['target_keywords'] ?? [];
}
public function get_topic(): string {
return $this->topic;
}
public function get_article_type(): string {
return $this->article_type;
}
public function get_suggested_title(): string {
return $this->suggested_title;
}
public function get_summary(): string {
return $this->summary;
}
public function get_score(): float {
return $this->score;
}
public function get_analysis_id(): int {
return $this->analysis_id;
}
public function get_source_ids(): array {
return $this->source_ids;
}
public function get_key_points(): array {
return $this->key_points;
}
public function get_target_keywords(): array {
return $this->target_keywords;
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/**
* Value object encapsulating all parameters for a content generation request.
*
* Bundles the article idea with user-configured generation preferences
* (tone, word count, pipeline mode) into a single object passed through the pipeline.
*
* Triggered by: Content_Generator creates these from Article_Ideas + settings.
* Dependencies: None — pure data object.
*
* @see core/class-content-generator.php — Creates and consumes these.
*/
class Autoblogger_Content_Request {
private Autoblogger_Article_Idea $idea;
private string $pipeline_mode;
private string $tone;
private int $min_word_count;
private int $max_word_count;
private string $niche_description;
private array $topic_exclusions;
public function __construct(
Autoblogger_Article_Idea $idea,
string $pipeline_mode,
string $tone,
int $min_word_count,
int $max_word_count,
string $niche_description,
array $topic_exclusions = []
) {
$this->idea = $idea;
$this->pipeline_mode = $pipeline_mode;
$this->tone = $tone;
$this->min_word_count = $min_word_count;
$this->max_word_count = $max_word_count;
$this->niche_description = $niche_description;
$this->topic_exclusions = $topic_exclusions;
}
public function get_idea(): Autoblogger_Article_Idea {
return $this->idea;
}
public function get_pipeline_mode(): string {
return $this->pipeline_mode;
}
public function get_tone(): string {
return $this->tone;
}
public function get_min_word_count(): int {
return $this->min_word_count;
}
public function get_max_word_count(): int {
return $this->max_word_count;
}
public function get_niche_description(): string {
return $this->niche_description;
}
public function get_topic_exclusions(): array {
return $this->topic_exclusions;
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/**
* Value object for a post's composite performance score.
*
* Triggered by: Metrics_Collector creates these; Content_Analyzer reads them for self-improvement.
* Dependencies: None — pure data object.
*
* @see core/class-metrics-collector.php — Creates these from WP + GA4 data.
* @see core/class-content-analyzer.php — Reads past scores to refine analysis.
*/
class Autoblogger_Content_Score {
private int $id;
private int $post_id;
private int $pageviews;
private float $avg_time_on_page;
private float $bounce_rate;
private int $comment_count;
private float $composite_score;
private ?array $score_factors;
private string $measured_at;
/**
* @param array<string, mixed> $data
*/
public function __construct( array $data ) {
$this->id = (int) ( $data['id'] ?? 0 );
$this->post_id = (int) ( $data['post_id'] ?? 0 );
$this->pageviews = (int) ( $data['pageviews'] ?? 0 );
$this->avg_time_on_page = (float) ( $data['avg_time_on_page'] ?? 0.0 );
$this->bounce_rate = (float) ( $data['bounce_rate'] ?? 0.0 );
$this->comment_count = (int) ( $data['comment_count'] ?? 0 );
$this->composite_score = (float) ( $data['composite_score'] ?? 0.0 );
$this->score_factors = $data['score_factors'] ?? null;
$this->measured_at = $data['measured_at'] ?? current_time( 'mysql' );
}
public function get_id(): int { return $this->id; }
public function get_post_id(): int { return $this->post_id; }
public function get_pageviews(): int { return $this->pageviews; }
public function get_avg_time_on_page(): float { return $this->avg_time_on_page; }
public function get_bounce_rate(): float { return $this->bounce_rate; }
public function get_comment_count(): int { return $this->comment_count; }
public function get_composite_score(): float { return $this->composite_score; }
public function get_score_factors(): ?array { return $this->score_factors; }
public function get_measured_at(): string { return $this->measured_at; }
/**
* @return array<string, mixed>
*/
public function to_db_row(): array {
return [
'post_id' => $this->post_id,
'pageviews' => $this->pageviews,
'avg_time_on_page' => $this->avg_time_on_page,
'bounce_rate' => $this->bounce_rate,
'comment_count' => $this->comment_count,
'composite_score' => $this->composite_score,
'score_factors_json' => null !== $this->score_factors ? wp_json_encode( $this->score_factors ) : null,
'measured_at' => $this->measured_at,
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/**
* Value object representing the chief editor agent's review of generated content.
*
* Triggered by: Chief_Editor creates these; Publisher consumes them.
* Dependencies: None — pure data object.
*
* @see core/class-chief-editor.php — Creates these after reviewing content.
* @see core/class-publisher.php — Uses verdict to decide publish vs. draft.
*/
class Autoblogger_Editorial_Review {
private string $verdict;
private string $notes;
private ?string $revised_content;
private float $quality_score;
private float $seo_score;
private array $issues;
/**
* @param array{
* verdict: string,
* notes: string,
* revised_content?: string|null,
* quality_score?: float,
* seo_score?: float,
* issues?: string[],
* } $data
*/
public function __construct( array $data ) {
$this->verdict = $data['verdict']; // 'approved', 'revised', 'rejected'
$this->notes = $data['notes'];
$this->revised_content = $data['revised_content'] ?? null;
$this->quality_score = (float) ( $data['quality_score'] ?? 0.0 );
$this->seo_score = (float) ( $data['seo_score'] ?? 0.0 );
$this->issues = $data['issues'] ?? [];
}
public function get_verdict(): string {
return $this->verdict;
}
public function get_notes(): string {
return $this->notes;
}
public function get_revised_content(): ?string {
return $this->revised_content;
}
public function get_quality_score(): float {
return $this->quality_score;
}
public function get_seo_score(): float {
return $this->seo_score;
}
public function get_issues(): array {
return $this->issues;
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
/**
* Value object for an API call log entry (cost tracking).
*
* Triggered by: Cost_Tracker creates these; Metrics_Page displays them.
* Dependencies: None — pure data object.
*
* @see core/class-cost-tracker.php — Writes these to ab_generation_log table.
* @see admin/class-metrics-page.php — Reads and displays these.
*/
class Autoblogger_Generation_Log {
private int $id;
private ?int $post_id;
private ?string $run_id;
private string $stage;
private string $provider;
private string $model;
private int $prompt_tokens;
private int $completion_tokens;
private float $estimated_cost;
private ?string $request_json;
private string $response_status;
private ?string $error_message;
private string $created_at;
/**
* @param array<string, mixed> $data
*/
public function __construct( array $data ) {
$this->id = (int) ( $data['id'] ?? 0 );
$this->post_id = isset( $data['post_id'] ) ? (int) $data['post_id'] : null;
$this->run_id = $data['run_id'] ?? null;
$this->stage = $data['stage'] ?? '';
$this->provider = $data['provider'] ?? '';
$this->model = $data['model'] ?? '';
$this->prompt_tokens = (int) ( $data['prompt_tokens'] ?? 0 );
$this->completion_tokens = (int) ( $data['completion_tokens'] ?? 0 );
$this->estimated_cost = (float) ( $data['estimated_cost'] ?? 0.0 );
$this->request_json = $data['request_json'] ?? null;
$this->response_status = $data['response_status'] ?? 'success';
$this->error_message = $data['error_message'] ?? null;
$this->created_at = $data['created_at'] ?? current_time( 'mysql' );
}
public function get_id(): int { return $this->id; }
public function get_post_id(): ?int { return $this->post_id; }
public function get_run_id(): ?string { return $this->run_id; }
public function get_stage(): string { return $this->stage; }
public function get_provider(): string { return $this->provider; }
public function get_model(): string { return $this->model; }
public function get_prompt_tokens(): int { return $this->prompt_tokens; }
public function get_completion_tokens(): int { return $this->completion_tokens; }
public function get_estimated_cost(): float { return $this->estimated_cost; }
public function get_request_json(): ?string { return $this->request_json; }
public function get_response_status(): string { return $this->response_status; }
public function get_error_message(): ?string { return $this->error_message; }
public function get_created_at(): string { return $this->created_at; }
/**
* @return array<string, mixed>
*/
public function to_db_row(): array {
return [
'post_id' => $this->post_id,
'run_id' => $this->run_id,
'stage' => $this->stage,
'provider' => $this->provider,
'model' => $this->model,
'prompt_tokens' => $this->prompt_tokens,
'completion_tokens' => $this->completion_tokens,
'estimated_cost' => $this->estimated_cost,
'request_json' => $this->request_json,
'response_status' => $this->response_status,
'error_message' => $this->error_message,
'created_at' => $this->created_at,
];
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
/**
* Value object representing a single piece of collected social media data
* (a Reddit post, a TikTok comment, etc.).
*
* Triggered by: Source providers create these; Content_Analyzer consumes them.
* Dependencies: None — pure data object.
*
* @see providers/interface-source-provider.php — Providers return arrays of these.
* @see core/class-content-analyzer.php — Consumes these for pattern detection.
*/
class Autoblogger_Source_Data {
private int $id;
private string $source_type;
private string $source_id;
private ?string $subreddit;
private ?string $title;
private ?string $content;
private ?string $author;
private int $score;
private int $comment_count;
private ?string $permalink;
private string $collected_at;
private ?array $metadata;
/**
* @param array{
* id?: int,
* source_type: string,
* source_id: string,
* subreddit?: string|null,
* title?: string|null,
* content?: string|null,
* author?: string|null,
* score?: int,
* comment_count?: int,
* permalink?: string|null,
* collected_at?: string,
* metadata?: array|null,
* } $data
*/
public function __construct( array $data ) {
$this->id = (int) ( $data['id'] ?? 0 );
$this->source_type = $data['source_type'];
$this->source_id = $data['source_id'];
$this->subreddit = $data['subreddit'] ?? null;
$this->title = $data['title'] ?? null;
$this->content = $data['content'] ?? null;
$this->author = $data['author'] ?? null;
$this->score = (int) ( $data['score'] ?? 0 );
$this->comment_count = (int) ( $data['comment_count'] ?? 0 );
$this->permalink = $data['permalink'] ?? null;
$this->collected_at = $data['collected_at'] ?? current_time( 'mysql' );
$this->metadata = $data['metadata'] ?? null;
}
public function get_id(): int {
return $this->id;
}
public function get_source_type(): string {
return $this->source_type;
}
public function get_source_id(): string {
return $this->source_id;
}
public function get_subreddit(): ?string {
return $this->subreddit;
}
public function get_title(): ?string {
return $this->title;
}
public function get_content(): ?string {
return $this->content;
}
public function get_author(): ?string {
return $this->author;
}
public function get_score(): int {
return $this->score;
}
public function get_comment_count(): int {
return $this->comment_count;
}
public function get_permalink(): ?string {
return $this->permalink;
}
public function get_collected_at(): string {
return $this->collected_at;
}
public function get_metadata(): ?array {
return $this->metadata;
}
/**
* Convert to array for database insertion.
*
* @return array<string, mixed>
*/
public function to_db_row(): array {
return [
'source_type' => $this->source_type,
'source_id' => $this->source_id,
'subreddit' => $this->subreddit,
'title' => $this->title,
'content' => $this->content,
'author' => $this->author,
'score' => $this->score,
'comment_count' => $this->comment_count,
'permalink' => $this->permalink,
'collected_at' => $this->collected_at,
'metadata_json' => null !== $this->metadata ? wp_json_encode( $this->metadata ) : null,
];
}
}