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 = '
Site
' . '

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'] ); + } +}