feat: v2.6.0 3-step AI pipeline, VPS worker, REST worker routes
- 3-step OpenRouter pipeline: Gatekeeper scoring/classification, PubMed+Google URL extraction, Markdown summary generation - REST worker routes /wp-json/peptide-news/v1/worker/* with X-Peptide-News-Token auth - Standalone Coolify worker (tools/vps-worker) with Dockerfile + docker-compose - CLI commands, content extractor, pubmed extractor, google url resolver - 89 PHPUnit tests passing
This commit is contained in:
233
includes/class-peptide-news-rest-worker.php
Normal file
233
includes/class-peptide-news-rest-worker.php
Normal file
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
/**
|
||||
* REST API Endpoints for VPS Worker Synchronization.
|
||||
*
|
||||
* Exposes authenticated endpoints allowing an external VPS worker
|
||||
* (e.g., running on Hostinger KVM8) to fetch pending articles,
|
||||
* run AI generation and URL resolution offboard, and sync completed
|
||||
* data or ingest new articles back into the shared WordPress server.
|
||||
*
|
||||
* @package PeptideNews
|
||||
* @since 2.6.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'WPINC' ) ) {
|
||||
die;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle REST API routes for VPS Worker integration (/worker/*).
|
||||
*/
|
||||
class Peptide_News_Rest_Worker {
|
||||
|
||||
/**
|
||||
* Check whether the incoming request is authenticated as an admin
|
||||
* OR bears a valid VPS worker secret token in X-Peptide-News-Token header.
|
||||
*
|
||||
* @param WP_REST_Request $request
|
||||
* @return bool|WP_Error
|
||||
*/
|
||||
public function check_worker_permissions( $request ) {
|
||||
if ( current_user_can( 'manage_options' ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$token = get_option( 'peptide_news_vps_token', '' );
|
||||
if ( ! empty( $token ) && class_exists( 'Peptide_News_Encryption' ) ) {
|
||||
$decrypted = Peptide_News_Encryption::decrypt( (string) $token );
|
||||
if ( ! empty( $decrypted ) ) {
|
||||
$token = $decrypted;
|
||||
}
|
||||
}
|
||||
$header_auth = $request->get_header( 'x_peptide_news_token' );
|
||||
if ( empty( $header_auth ) ) {
|
||||
$header_auth = $request->get_param( 'token' );
|
||||
}
|
||||
|
||||
if ( ! empty( $token ) && ! empty( $header_auth ) && hash_equals( (string) $token, (string) $header_auth ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return new WP_Error(
|
||||
'rest_forbidden',
|
||||
__( 'Invalid or missing VPS worker authentication token.', 'peptide-news' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /peptide-news/v1/worker/pending
|
||||
*
|
||||
* Returns articles that have an empty ai_summary and are marked active.
|
||||
*
|
||||
* @param WP_REST_Request $request REST Request.
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function get_pending_articles( WP_REST_Request $request ) {
|
||||
global $wpdb;
|
||||
$limit = absint( $request->get_param( 'limit' ) ?: 20 );
|
||||
$limit = min( max( $limit, 1 ), 100 );
|
||||
$table = $wpdb->prefix . 'peptide_news_articles';
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$results = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT id, title, content, source_url, source_name, pub_date
|
||||
FROM {$table}
|
||||
WHERE (ai_summary IS NULL OR ai_summary = '') AND is_active = 1
|
||||
ORDER BY pub_date DESC
|
||||
LIMIT %d",
|
||||
$limit
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return rest_ensure_response( array(
|
||||
'success' => true,
|
||||
'count' => count( $results ),
|
||||
'data' => $results,
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /peptide-news/v1/worker/update
|
||||
*
|
||||
* Updates an article with AI summaries, tags, rigor score, study type, and metadata.
|
||||
*
|
||||
* @param WP_REST_Request $request REST Request.
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function update_article( WP_REST_Request $request ) {
|
||||
global $wpdb;
|
||||
$id = absint( $request->get_param( 'id' ) );
|
||||
$table = $wpdb->prefix . 'peptide_news_articles';
|
||||
|
||||
if ( empty( $id ) ) {
|
||||
return new WP_Error( 'invalid_id', __( 'Missing article ID.', 'peptide-news' ), array( 'status' => 400 ) );
|
||||
}
|
||||
|
||||
$update = array();
|
||||
$format = array();
|
||||
|
||||
if ( null !== $request->get_param( 'tags' ) ) {
|
||||
$update['tags'] = sanitize_text_field( $request->get_param( 'tags' ) );
|
||||
$format[] = '%s';
|
||||
}
|
||||
if ( null !== $request->get_param( 'ai_summary' ) ) {
|
||||
$update['ai_summary'] = wp_kses_post( $request->get_param( 'ai_summary' ) );
|
||||
$format[] = '%s';
|
||||
}
|
||||
if ( null !== $request->get_param( 'rigor_score' ) ) {
|
||||
$update['rigor_score'] = absint( $request->get_param( 'rigor_score' ) );
|
||||
$format[] = '%d';
|
||||
}
|
||||
if ( null !== $request->get_param( 'study_type' ) ) {
|
||||
$update['study_type'] = sanitize_text_field( $request->get_param( 'study_type' ) );
|
||||
$format[] = '%s';
|
||||
}
|
||||
if ( null !== $request->get_param( 'is_active' ) ) {
|
||||
$update['is_active'] = absint( $request->get_param( 'is_active' ) );
|
||||
$format[] = '%d';
|
||||
}
|
||||
if ( null !== $request->get_param( 'source_url' ) && esc_url_raw( $request->get_param( 'source_url' ) ) ) {
|
||||
$update['source_url'] = esc_url_raw( $request->get_param( 'source_url' ) );
|
||||
$format[] = '%s';
|
||||
}
|
||||
if ( null !== $request->get_param( 'ai_metadata' ) ) {
|
||||
$meta = $request->get_param( 'ai_metadata' );
|
||||
$update['ai_metadata'] = is_array( $meta ) ? wp_json_encode( $meta ) : (string) $meta;
|
||||
$format[] = '%s';
|
||||
}
|
||||
|
||||
if ( empty( $update ) ) {
|
||||
return new WP_Error( 'rest_empty_payload', 'No fields to update.', array( 'status' => 400 ) );
|
||||
}
|
||||
|
||||
$updated = $wpdb->update( $table, $update, array( 'id' => $id ), $format, array( '%d' ) );
|
||||
|
||||
if ( false === $updated ) {
|
||||
return new WP_Error( 'rest_db_error', 'Database update failed.', array( 'status' => 500 ) );
|
||||
}
|
||||
|
||||
// Clear transient cache so front-end displays updated article.
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
|
||||
$wpdb->esc_like( '_transient_peptide_news_articles_' ) . '%'
|
||||
)
|
||||
);
|
||||
|
||||
return rest_ensure_response( array(
|
||||
'success' => true,
|
||||
'id' => $id,
|
||||
'updated' => true,
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /peptide-news/v1/worker/ingest
|
||||
*
|
||||
* Bulk-ingest new articles scraped or processed on an external VPS worker.
|
||||
*
|
||||
* @param WP_REST_Request $request
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function ingest_articles( $request ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'peptide_news_articles';
|
||||
|
||||
$articles = $request->get_param( 'articles' );
|
||||
if ( ! is_array( $articles ) ) {
|
||||
return new WP_Error( 'rest_invalid_payload', 'Expected array of articles.', array( 'status' => 400 ) );
|
||||
}
|
||||
|
||||
$ingested = 0;
|
||||
foreach ( $articles as $art ) {
|
||||
if ( empty( $art['title'] ) || empty( $art['source_url'] ) ) {
|
||||
continue;
|
||||
}
|
||||
$source_url = esc_url_raw( $art['source_url'] );
|
||||
$exists = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE source_url = %s", $source_url ) );
|
||||
if ( $exists ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$inserted = $wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'title' => sanitize_text_field( $art['title'] ),
|
||||
'source_url' => $source_url,
|
||||
'excerpt' => sanitize_textarea_field( $art['excerpt'] ?? '' ),
|
||||
'content' => wp_kses_post( $art['content'] ?? '' ),
|
||||
'published_at' => ! empty( $art['published_at'] ) ? $art['published_at'] : current_time( 'mysql' ),
|
||||
'fetched_at' => current_time( 'mysql' ),
|
||||
'categories' => sanitize_text_field( $art['categories'] ?? 'general' ),
|
||||
'tags' => sanitize_text_field( $art['tags'] ?? '' ),
|
||||
'ai_summary' => wp_kses_post( $art['ai_summary'] ?? '' ),
|
||||
'rigor_score' => absint( $art['rigor_score'] ?? 0 ),
|
||||
'study_type' => sanitize_text_field( $art['study_type'] ?? '' ),
|
||||
'ai_metadata' => is_array( $art['ai_metadata'] ?? null ) ? wp_json_encode( $art['ai_metadata'] ) : (string) ( $art['ai_metadata'] ?? '' ),
|
||||
'is_active' => 1,
|
||||
)
|
||||
);
|
||||
if ( $inserted ) {
|
||||
++$ingested;
|
||||
}
|
||||
}
|
||||
|
||||
if ( $ingested > 0 ) {
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
|
||||
$wpdb->esc_like( '_transient_peptide_news_articles_' ) . '%'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return rest_ensure_response( array(
|
||||
'success' => true,
|
||||
'ingested' => $ingested,
|
||||
) );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user