diff --git a/src/Admin/Admin.php b/src/Admin/Admin.php
index 7d80b15..cfc40a9 100644
--- a/src/Admin/Admin.php
+++ b/src/Admin/Admin.php
@@ -136,6 +136,7 @@ public function assets( string $hook ): void {
}
wp_enqueue_style( 'aggregate-it-admin', AGGREGATE_IT_URL . 'assets/css/admin.css', [], $this->asset_version( 'assets/css/admin.css' ) );
+ wp_enqueue_media();
wp_enqueue_script( 'jquery-ui-sortable' );
wp_enqueue_script( 'aggregate-it-charts', AGGREGATE_IT_URL . 'assets/js/charts.js', [], $this->asset_version( 'assets/js/charts.js' ), true );
wp_enqueue_script( 'aggregate-it-admin', AGGREGATE_IT_URL . 'assets/js/admin.js', [ 'aggregate-it-charts' ], $this->asset_version( 'assets/js/admin.js' ), true );
@@ -908,6 +909,7 @@ public function handle_save_settings(): void {
'daily_spend_cap_usd' => max( 0, (float) ( $_POST['daily_spend_cap_usd'] ?? 5 ) ),
'image_mode' => sanitize_key( wp_unslash( $_POST['image_mode'] ?? 'import' ) ),
'image_source' => sanitize_key( wp_unslash( $_POST['image_source'] ?? 'share' ) ),
+ 'default_image_id' => max( 0, (int) ( $_POST['default_image_id'] ?? 0 ) ),
'indexnow_enabled' => isset( $_POST['indexnow_enabled'] ),
'wikipedia_research' => isset( $_POST['wikipedia_research'] ),
'related_articles' => isset( $_POST['related_articles'] ),
diff --git a/src/Admin/views/settings.php b/src/Admin/views/settings.php
index 4b1d802..b28e337 100644
--- a/src/Admin/views/settings.php
+++ b/src/Admin/views/settings.php
@@ -149,6 +149,22 @@
+
+ |
+
+ default_image_id();
+ $default_image_src = $default_image_id ? wp_get_attachment_image_url( $default_image_id, 'medium' ) : '';
+ ?>
+
+
+
+
+
+
+
+ |
+
|
|
@@ -244,6 +260,35 @@ function sync() {
if ( imageMode ) { imageMode.addEventListener( 'change', sync ); }
sync();
+ var chooseBtn = document.getElementById( 'default_image_choose' );
+ var clearBtn = document.getElementById( 'default_image_clear' );
+ var imageIdField = document.getElementById( 'default_image_id' );
+ var imagePreview = document.getElementById( 'default_image_preview' );
+ var mediaFrame;
+ if ( chooseBtn && imageIdField && window.wp && wp.media ) {
+ chooseBtn.addEventListener( 'click', function () {
+ if ( mediaFrame ) { mediaFrame.open(); return; }
+ mediaFrame = wp.media( { title: chooseBtn.textContent, library: { type: 'image' }, multiple: false } );
+ mediaFrame.on( 'select', function () {
+ var att = mediaFrame.state().get( 'selection' ).first().toJSON();
+ imageIdField.value = att.id;
+ if ( imagePreview ) {
+ imagePreview.src = ( att.sizes && att.sizes.medium ) ? att.sizes.medium.url : att.url;
+ imagePreview.style.display = 'block';
+ }
+ if ( clearBtn ) { clearBtn.style.display = 'inline'; }
+ } );
+ mediaFrame.open();
+ } );
+ }
+ if ( clearBtn && imageIdField ) {
+ clearBtn.addEventListener( 'click', function () {
+ imageIdField.value = '0';
+ if ( imagePreview ) { imagePreview.src = ''; imagePreview.style.display = 'none'; }
+ clearBtn.style.display = 'none';
+ } );
+ }
+
var test = document.getElementById( 'ai-test-key' );
var result = document.getElementById( 'ai-test-result' );
if ( test && result ) {
diff --git a/src/Ai/OpenAiProvider.php b/src/Ai/OpenAiProvider.php
index be66b59..0e7495a 100644
--- a/src/Ai/OpenAiProvider.php
+++ b/src/Ai/OpenAiProvider.php
@@ -28,8 +28,38 @@ public function key(): string {
}
public function structured( string $prompt, array $schema, array $opts = [] ): array {
- $model = $this->settings->ai_model() ?: self::DEFAULT_MODEL;
+ $model = $this->settings->ai_model() ?: self::DEFAULT_MODEL;
+ $message = $prompt . $this->schema_hint( $schema );
+
+ // Strict Structured Outputs guarantees every required field (e.g. category) is
+ // present; plain JSON mode does not, and a dropped category silently sends the post
+ // to the default WordPress category. Fall back to JSON mode if the model rejects it.
+ try {
+ $data = $this->chat(
+ $model,
+ $message,
+ [
+ 'type' => 'json_schema',
+ 'json_schema' => [ 'name' => 'article', 'strict' => true, 'schema' => $this->strict_schema( $schema ) ],
+ ]
+ );
+ } catch ( \RuntimeException $e ) {
+ $data = $this->chat( $model, $message, [ 'type' => 'json_object' ] );
+ }
+
+ $text = (string) ( $data['choices'][0]['message']['content'] ?? '' );
+ $in = (int) ( $data['usage']['prompt_tokens'] ?? 0 );
+ $out = (int) ( $data['usage']['completion_tokens'] ?? 0 );
+
+ return [
+ 'result' => $this->first_json( $text ),
+ 'tokens' => $in + $out,
+ 'cost_usd' => $this->cost( self::PRICING, $model, self::DEFAULT_MODEL, $in, $out ),
+ ];
+ }
+ /** @param array $response_format */
+ private function chat( string $model, string $message, array $response_format ): array {
$response = wp_remote_post(
self::CHAT_ENDPOINT,
[
@@ -41,24 +71,40 @@ public function structured( string $prompt, array $schema, array $opts = [] ): a
'body' => wp_json_encode(
[
'model' => $model,
- 'messages' => [ [ 'role' => 'user', 'content' => $prompt . $this->schema_hint( $schema ) ] ],
- 'response_format' => [ 'type' => 'json_object' ],
+ 'messages' => [ [ 'role' => 'user', 'content' => $message ] ],
+ 'response_format' => $response_format,
]
),
]
);
- $data = $this->decode( $response );
+ return $this->decode( $response );
+ }
- $text = (string) ( $data['choices'][0]['message']['content'] ?? '' );
- $in = (int) ( $data['usage']['prompt_tokens'] ?? 0 );
- $out = (int) ( $data['usage']['completion_tokens'] ?? 0 );
+ /**
+ * Rewrite our schema into the subset OpenAI's strict mode accepts: every object must list
+ * all properties as required and forbid extras, and string/number constraints it rejects
+ * (maxLength and friends) are dropped — length is still guided by the prompt.
+ *
+ * @param array $schema
+ * @return array
+ */
+ private function strict_schema( array $schema ): array {
+ unset( $schema['minLength'], $schema['maxLength'], $schema['pattern'], $schema['format'], $schema['minimum'], $schema['maximum'] );
+
+ if ( ( $schema['type'] ?? '' ) === 'object' && isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) {
+ foreach ( $schema['properties'] as $key => $prop ) {
+ $schema['properties'][ $key ] = $this->strict_schema( (array) $prop );
+ }
+ $schema['required'] = array_keys( $schema['properties'] );
+ $schema['additionalProperties'] = false;
+ }
- return [
- 'result' => $this->first_json( $text ),
- 'tokens' => $in + $out,
- 'cost_usd' => $this->cost( self::PRICING, $model, self::DEFAULT_MODEL, $in, $out ),
- ];
+ if ( ( $schema['type'] ?? '' ) === 'array' && isset( $schema['items'] ) && is_array( $schema['items'] ) ) {
+ $schema['items'] = $this->strict_schema( (array) $schema['items'] );
+ }
+
+ return $schema;
}
public function embed( string $text ): array {
diff --git a/src/Publish/ImageImporter.php b/src/Publish/ImageImporter.php
index 8831d50..c3d316a 100644
--- a/src/Publish/ImageImporter.php
+++ b/src/Publish/ImageImporter.php
@@ -30,23 +30,31 @@ public function maybe_import( int $post_id, string $image_url, string $alt, bool
if ( ! $replace && $this->settings->image_mode() === 'off' ) {
return;
}
- if ( $image_url === '' ) {
- EventLog::warning( sprintf( 'Post #%d: no image found for this article.', $post_id ) );
- return;
- }
if ( has_post_thumbnail( $post_id ) && ! $replace ) {
return;
}
- $this->load_media_deps();
+ if ( $image_url !== '' ) {
+ $this->load_media_deps();
+ $attachment_id = $this->sideload( esc_url_raw( $image_url ), $post_id, $alt );
+ if ( $attachment_id !== 0 ) {
+ set_post_thumbnail( $post_id, $attachment_id );
+ update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $alt ) );
+ return;
+ }
+ }
- $attachment_id = $this->sideload( esc_url_raw( $image_url ), $post_id, $alt );
- if ( $attachment_id === 0 ) {
+ // No usable source image (missing, or download/import failed): fall back to the
+ // configured default so no post is left image-less.
+ $default = $this->settings->default_image_id();
+ if ( $default > 0 && wp_attachment_is_image( $default ) ) {
+ set_post_thumbnail( $post_id, $default );
return;
}
- set_post_thumbnail( $post_id, $attachment_id );
- update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $alt ) );
+ if ( $image_url === '' ) {
+ EventLog::warning( sprintf( 'Post #%d: no image found for this article.', $post_id ) );
+ }
}
/**
diff --git a/src/Publish/PostFactory.php b/src/Publish/PostFactory.php
index aba8a21..e7ecaea 100644
--- a/src/Publish/PostFactory.php
+++ b/src/Publish/PostFactory.php
@@ -6,6 +6,7 @@
use AggregateIt\Seo\SlugGenerator;
use AggregateIt\Settings;
use AggregateIt\Source\SourceRepository;
+use AggregateIt\Support\EventLog;
defined( 'ABSPATH' ) || exit;
@@ -80,6 +81,14 @@ private function assign_terms( int $post_id, string $post_type, int $source_id,
}
if ( $ids ) {
wp_set_post_terms( $post_id, $ids, 'category', false );
+ } elseif ( $this->settings->ai_categorize() ) {
+ EventLog::warning(
+ sprintf(
+ 'Post #%d kept the site default category: the AI returned %s and the source has no category set.',
+ $post_id,
+ $ai_category === '' ? 'no category' : sprintf( '"%s", which could not be created', $ai_category )
+ )
+ );
}
}
diff --git a/src/Settings.php b/src/Settings.php
index 18aee0f..a94e7f4 100644
--- a/src/Settings.php
+++ b/src/Settings.php
@@ -159,6 +159,10 @@ public function image_source(): string {
return in_array( $src, [ 'share', 'feed' ], true ) ? $src : 'share';
}
+ public function default_image_id(): int {
+ return max( 0, (int) $this->get( 'default_image_id', 0 ) );
+ }
+
public function indexnow_enabled(): bool {
return (bool) $this->get( 'indexnow_enabled', true );
}
diff --git a/src/Source/ContentExtractor.php b/src/Source/ContentExtractor.php
index 93847ad..67576be 100644
--- a/src/Source/ContentExtractor.php
+++ b/src/Source/ContentExtractor.php
@@ -85,18 +85,37 @@ private function link_image( string $html ): string {
}
private function first_content_image( string $html ): string {
- if ( ! preg_match_all( '/
]+src=["\']([^"\']+)["\']/i', $html, $m ) ) {
+ if ( ! preg_match_all( '/
]*>/i', $html, $m ) ) {
return '';
}
- foreach ( $m[1] as $src ) {
- $src = trim( html_entity_decode( $src ) );
- if ( preg_match( '#^https?://#i', $src ) && ! $this->is_junk_image( $src ) ) {
+ foreach ( $m[0] as $tag ) {
+ $src = $this->img_src( $tag );
+ if ( $src !== '' && preg_match( '#^https?://#i', $src ) && ! $this->is_junk_image( $src ) ) {
return $src;
}
}
return '';
}
+ /** Publishers lazy-load heroes: the real URL sits in data-src/srcset while src holds a placeholder. */
+ private function img_src( string $tag ): string {
+ foreach ( [ 'data-src', 'data-lazy-src', 'data-original', 'data-lazy', 'src' ] as $attr ) {
+ if ( preg_match( '/\b' . preg_quote( $attr, '/' ) . '=(["\'])(.*?)\1/i', $tag, $m ) ) {
+ $url = trim( html_entity_decode( $m[2] ) );
+ if ( $url !== '' && strpos( $url, 'data:' ) !== 0 ) {
+ return $url;
+ }
+ }
+ }
+ if ( preg_match( '/\bsrcset=(["\'])(.*?)\1/i', $tag, $m ) ) {
+ $first = trim( (string) strtok( trim( html_entity_decode( $m[2] ) ), ' ' ) );
+ if ( $first !== '' && strpos( $first, 'data:' ) !== 0 ) {
+ return $first;
+ }
+ }
+ return '';
+ }
+
private function meta_content( string $html, string $key ): string {
$e = preg_quote( $key, '/' );
// Capture the opening quote and match the same one, so a URL containing an apostrophe
diff --git a/tests/ContentExtractorTest.php b/tests/ContentExtractorTest.php
index 1f9d845..5a284a6 100644
--- a/tests/ContentExtractorTest.php
+++ b/tests/ContentExtractorTest.php
@@ -73,6 +73,21 @@ public function test_share_image_rethrows_transient_only_when_requested(): void
$ext->share_image( 'https://example.com/x', true );
}
+ public function test_reads_lazy_loaded_hero_from_data_src(): void {
+ $html = '
';
+ $this->assertSame( 'https://cdn/hero.jpg', $this->best->invoke( $this->extractor, $html ) );
+ }
+
+ public function test_reads_lazy_loaded_hero_from_srcset(): void {
+ $html = '
';
+ $this->assertSame( 'https://cdn/small.jpg', $this->best->invoke( $this->extractor, $html ) );
+ }
+
+ public function test_skips_placeholder_only_image(): void {
+ $html = '
';
+ $this->assertSame( '', $this->best->invoke( $this->extractor, $html ) );
+ }
+
public function test_readability_keeps_article_drops_chrome(): void {
$html = ''
. 'Big News
First paragraph with real content.
'
diff --git a/tests/OpenAiProviderTest.php b/tests/OpenAiProviderTest.php
new file mode 100644
index 0000000..1d47499
--- /dev/null
+++ b/tests/OpenAiProviderTest.php
@@ -0,0 +1,69 @@
+setAccessible( true );
+ return $ref->invoke( new OpenAiProvider( new Settings() ), $schema );
+ }
+
+ public function test_makes_every_property_required_and_forbids_extras(): void {
+ $out = $this->strict(
+ [
+ 'type' => 'object',
+ 'required' => [ 'category' ],
+ 'properties' => [
+ 'category' => [ 'type' => 'string' ],
+ 'entities' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ],
+ ],
+ ]
+ );
+
+ $this->assertSame( [ 'category', 'entities' ], $out['required'] );
+ $this->assertFalse( $out['additionalProperties'] );
+ }
+
+ public function test_strips_unsupported_string_constraints(): void {
+ $out = $this->strict(
+ [
+ 'type' => 'object',
+ 'properties' => [
+ 'seo_title' => [ 'type' => 'string', 'maxLength' => 70 ],
+ ],
+ ]
+ );
+
+ $this->assertArrayNotHasKey( 'maxLength', $out['properties']['seo_title'] );
+ }
+
+ public function test_recurses_into_nested_object_items(): void {
+ $out = $this->strict(
+ [
+ 'type' => 'object',
+ 'properties' => [
+ 'entities' => [
+ 'type' => 'array',
+ 'items' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'name' => [ 'type' => 'string' ],
+ 'type' => [ 'type' => 'string' ],
+ ],
+ ],
+ ],
+ ],
+ ]
+ );
+
+ $item = $out['properties']['entities']['items'];
+ $this->assertSame( [ 'name', 'type' ], $item['required'] );
+ $this->assertFalse( $item['additionalProperties'] );
+ }
+}