Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Admin/Admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down Expand Up @@ -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'] ),
Expand Down
45 changes: 45 additions & 0 deletions src/Admin/views/settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,22 @@
</select>
</td>
</tr>
<tr class="ai-default-image-row">
<th><label><?php esc_html_e( 'Fallback image', 'aggregate-it' ); ?></label></th>
<td>
<?php
$default_image_id = $settings->default_image_id();
$default_image_src = $default_image_id ? wp_get_attachment_image_url( $default_image_id, 'medium' ) : '';
?>
<input type="hidden" name="default_image_id" id="default_image_id" value="<?php echo esc_attr( (string) $default_image_id ); ?>">
<img id="default_image_preview" src="<?php echo esc_url( (string) $default_image_src ); ?>" alt="" style="max-width:160px;height:auto;border-radius:6px;margin-bottom:6px;display:<?php echo $default_image_src ? 'block' : 'none'; ?>;">
<p>
<button type="button" class="button" id="default_image_choose"><?php esc_html_e( 'Choose image', 'aggregate-it' ); ?></button>
<button type="button" class="button-link" id="default_image_clear" style="display:<?php echo $default_image_id ? 'inline' : 'none'; ?>;"><?php esc_html_e( 'Remove', 'aggregate-it' ); ?></button>
</p>
<p class="description"><?php esc_html_e( 'Featured image used only when an article has none of its own. Leave empty to allow image-less posts.', 'aggregate-it' ); ?></p>
</td>
</tr>
<tr>
<th><?php esc_html_e( 'Tell search engines instantly', 'aggregate-it' ); ?></th>
<td><label><input name="indexnow_enabled" type="checkbox" <?php checked( $settings->indexnow_enabled() ); ?>> <?php esc_html_e( 'Let search engines know right away when an article is published or updated', 'aggregate-it' ); ?></label></td>
Expand Down Expand Up @@ -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 ) {
Expand Down
70 changes: 58 additions & 12 deletions src/Ai/OpenAiProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,mixed> $response_format */
private function chat( string $model, string $message, array $response_format ): array {
$response = wp_remote_post(
self::CHAT_ENDPOINT,
[
Expand All @@ -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<string,mixed> $schema
* @return array<string,mixed>
*/
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 {
Expand Down
26 changes: 17 additions & 9 deletions src/Publish/ImageImporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) );
}
}

/**
Expand Down
9 changes: 9 additions & 0 deletions src/Publish/PostFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use AggregateIt\Seo\SlugGenerator;
use AggregateIt\Settings;
use AggregateIt\Source\SourceRepository;
use AggregateIt\Support\EventLog;

defined( 'ABSPATH' ) || exit;

Expand Down Expand Up @@ -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 )
)
);
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}
Expand Down
27 changes: 23 additions & 4 deletions src/Source/ContentExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,18 +85,37 @@ private function link_image( string $html ): string {
}

private function first_content_image( string $html ): string {
if ( ! preg_match_all( '/<img[^>]+src=["\']([^"\']+)["\']/i', $html, $m ) ) {
if ( ! preg_match_all( '/<img\b[^>]*>/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
Expand Down
15 changes: 15 additions & 0 deletions tests/ContentExtractorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<article><img src="data:image/svg+xml;base64,PLACEHOLDER" data-src="https://cdn/hero.jpg"></article>';
$this->assertSame( 'https://cdn/hero.jpg', $this->best->invoke( $this->extractor, $html ) );
}

public function test_reads_lazy_loaded_hero_from_srcset(): void {
$html = '<article><img src="data:image/gif;base64,R0lGOD" srcset="https://cdn/small.jpg 480w, https://cdn/large.jpg 1200w"></article>';
$this->assertSame( 'https://cdn/small.jpg', $this->best->invoke( $this->extractor, $html ) );
}

public function test_skips_placeholder_only_image(): void {
$html = '<article><img src="data:image/svg+xml;base64,PLACEHOLDER"></article>';
$this->assertSame( '', $this->best->invoke( $this->extractor, $html ) );
}

public function test_readability_keeps_article_drops_chrome(): void {
$html = '<html><body><nav>Home About</nav><header>Site</header>'
. '<article><h2>Big News</h2><p>First paragraph with real content.</p>'
Expand Down
69 changes: 69 additions & 0 deletions tests/OpenAiProviderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

namespace AggregateIt\Tests;

use AggregateIt\Ai\OpenAiProvider;
use AggregateIt\Settings;
use PHPUnit\Framework\TestCase;

final class OpenAiProviderTest extends TestCase {

private function strict( array $schema ): array {
$ref = new \ReflectionMethod( OpenAiProvider::class, 'strict_schema' );
$ref->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'] );
}
}
Loading