Changeset 3712303
- Timestamp:
- 09/25/2026 03:45:41 AM (4 weeks ago)
- Location:
- blogpilot/trunk
- Files:
-
- 11 edited
-
admin/class-bpv2-admin.php (modified) (10 diffs)
-
admin/views/settings.php (modified) (2 diffs)
-
admin/views/topic-map.php (modified) (3 diffs)
-
blogpilot.php (modified) (5 diffs)
-
includes/class-bpv2-cron.php (modified) (6 diffs)
-
includes/class-bpv2-db.php (modified) (6 diffs)
-
includes/class-bpv2-generator.php (modified) (1 diff)
-
includes/class-bpv2-gsc.php (modified) (1 diff)
-
includes/class-bpv2-notifier.php (modified) (2 diffs)
-
includes/class-bpv2-social-derivatives.php (modified) (1 diff)
-
readme.txt (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
blogpilot/trunk/admin/class-bpv2-admin.php
r3704425 r3712303 43 43 add_action( 'wp_ajax_bpv2_run_gap', array( $this, 'ajax_run_gap' ) ); 44 44 add_action( 'wp_ajax_bpv2_run_gsc', array( $this, 'ajax_run_gsc' ) ); 45 add_action( 'wp_ajax_bpv2_lifecycle_confirm_noindex', array( $this, 'ajax_lifecycle_confirm_noindex' ) ); 46 add_action( 'wp_ajax_bpv2_lifecycle_dismiss_retarget', array( $this, 'ajax_lifecycle_dismiss_retarget' ) ); 47 add_action( 'wp_ajax_bpv2_lifecycle_requeue_rewrite', array( $this, 'ajax_lifecycle_requeue_rewrite' ) ); 48 add_action( 'wp_ajax_bpv2_lifecycle_toggle_pillar', array( $this, 'ajax_lifecycle_toggle_pillar' ) ); 49 add_action( 'wp_ajax_bpv2_lifecycle_run_now', array( $this, 'ajax_lifecycle_run_now' ) ); 45 50 add_action( 'wp_ajax_bpv2_gsc_insights', array( $this, 'ajax_gsc_insights' ) ); 46 51 add_action( 'wp_ajax_bpv2_submit_sitemap', array( $this, 'ajax_submit_sitemap' ) ); … … 149 154 add_submenu_page( 'bpv2-dashboard', 'GSC Insights', '📈 GSC Insights', 'manage_options', 'bpv2-gsc', array($this,'page_gsc') ); 150 155 add_submenu_page( 'bpv2-dashboard', 'Post Queue', '📋 Post Queue', 'manage_options', 'bpv2-queue', array($this,'page_queue') ); 156 add_submenu_page( 'bpv2-dashboard', 'Lifecycle', '♻️ Lifecycle', 'manage_options', 'bpv2-lifecycle', array($this,'page_lifecycle') ); 151 157 add_submenu_page( 'bpv2-dashboard', 'Diagnostics', '🔍 Diagnostics', 'manage_options', 'bpv2-diagnostics', array($this,'page_diagnostics') ); 152 158 add_submenu_page( 'bpv2-dashboard', 'Accessibility', '♿ Accessibility', 'manage_options', 'bpv2-accessibility',array($this,'page_accessibility') ); … … 394 400 * wp_localize_script(), which JSON-encodes and escapes them. 395 401 */ 402 /** 403 * FIX BP#MODEL-ALIAS-01 (22 Sep 2026): tier alias -> display label, the 404 * single place the plugin defines what the dropdown and the "Model in 405 * use" status check show. The alias itself ('sonnet'/'haiku'/'opus') is 406 * what gets stored and sent to the server; the server resolves it to a 407 * live model ID (lib/anthropic.ts::resolveModel()). Keep this label text 408 * in sync with MODEL_TIERS there when a version bumps. 409 */ 410 public static function model_tiers() { 411 return array( 412 'haiku' => 'Haiku 4.5', 413 'sonnet' => 'Sonnet 5', 414 'opus' => 'Opus 5.5', 415 ); 416 } 417 396 418 public static function enqueue_view_js( $slug, $data = array() ) { 397 419 $slug = sanitize_key( $slug ); … … 443 465 public function page_gsc() { include BPV2_DIR.'admin/views/gsc-insights.php'; } 444 466 public function page_queue() { include BPV2_DIR.'admin/views/queue.php'; } 467 public function page_lifecycle() { include BPV2_DIR.'admin/views/lifecycle.php'; } 445 468 public function page_subscribers() { BPV2_Subscribers::render_admin(); } 446 469 public function page_settings() { include BPV2_DIR.'admin/views/settings.php'; } … … 2096 2119 } 2097 2120 wp_send_json_success( array( 2098 'model_in_use' => get_option( 'bpv2_model', 'claude-sonnet-4-6' ),2121 'model_in_use' => self::model_tiers()[ get_option( 'bpv2_model', 'sonnet' ) ] ?? self::model_tiers()['sonnet'], 2099 2122 ) ); 2100 2123 } … … 2395 2418 'cta_style' => $style, 2396 2419 'cta_color' => $cta_color, 2397 'model' => get_option( 'bpv2_model', ' claude-sonnet-4-6' ),2420 'model' => get_option( 'bpv2_model', 'sonnet' ), 2398 2421 ), 90 ); 2399 2422 if ( is_wp_error( $resp ) ) wp_send_json_error( $resp->get_error_message() ); … … 2420 2443 'kind' => 'voice', 2421 2444 'voice_samples' => $samples, 2422 'model' => get_option( 'bpv2_model', ' claude-sonnet-4-6' ),2445 'model' => get_option( 'bpv2_model', 'sonnet' ), 2423 2446 ), 90 ); 2424 2447 if ( is_wp_error( $resp ) ) wp_send_json_error( $resp->get_error_message() ); … … 4468 4491 'self_written' => $is_self ? 1 : 0, 4469 4492 'word_count' => $word_count, 4470 'model' => get_option( 'bpv2_model', ' claude-sonnet-4-6' ),4493 'model' => get_option( 'bpv2_model', 'sonnet' ), 4471 4494 ), 180 ); 4472 4495 if ( empty( $r['ok'] ) || empty( $r['content'] ) ) { … … 4935 4958 'messages' => $messages, 4936 4959 'site_data' => $sys, 4937 'model' => get_option( 'bpv2_model', ' claude-sonnet-4-6' ),4960 'model' => get_option( 'bpv2_model', 'sonnet' ), 4938 4961 ), 120 ); 4939 4962 if ( empty( $r['ok'] ) || empty( $r['response'] ) ) { … … 5158 5181 5159 5182 // ── Niche pairs AJAX ──────────────────────────────────────────────────── 5183 5184 // -- SL#LIFECYCLE-01 --------------------------------------------------- 5185 5186 public function ajax_lifecycle_confirm_noindex() { 5187 check_ajax_referer( 'bpv2_ajax', 'nonce' ); 5188 if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden' ); 5189 $post_id = absint( $_POST['post_id'] ?? 0 ); 5190 if ( ! $post_id ) wp_send_json_error( 'Invalid post ID.' ); 5191 $r = BPV2_Lifecycle::confirm_noindex( $post_id ); 5192 if ( is_wp_error( $r ) ) wp_send_json_error( $r->get_error_message() ); 5193 wp_send_json_success( array( 'message' => 'Noindexed and moved to draft.' ) ); 5194 } 5195 5196 /** Dismiss a retarget-queue row -- the source page stays on track for noindex at day 60. */ 5197 public function ajax_lifecycle_dismiss_retarget() { 5198 check_ajax_referer( 'bpv2_ajax', 'nonce' ); 5199 if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden' ); 5200 $queue_id = absint( $_POST['queue_id'] ?? 0 ); 5201 if ( ! $queue_id ) wp_send_json_error( 'Invalid queue ID.' ); 5202 BPV2_DB::update_queue_status( $queue_id, 'rejected' ); 5203 wp_send_json_success(); 5204 } 5205 5206 /** 5207 * "Rewrite in place": re-queues the SOURCE post's own generation-queue row 5208 * for regeneration (same pattern as run_refresh_check()'s auto_regen 5209 * branch) and resets its lifecycle clock so it isn't immediately 5210 * re-flagged the next time the pass runs. 5211 */ 5212 public function ajax_lifecycle_requeue_rewrite() { 5213 check_ajax_referer( 'bpv2_ajax', 'nonce' ); 5214 if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden' ); 5215 $queue_id = absint( $_POST['queue_id'] ?? 0 ); 5216 $row = BPV2_DB::get_queue_post( $queue_id ); 5217 if ( ! $row || empty( $row->source_post_id ) ) wp_send_json_error( 'Invalid retarget row.' ); 5218 5219 global $wpdb; 5220 $t = BPV2_DB::queue_table(); 5221 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom bpv2_* table, no core API; values and table name bound through prepare(). 5222 $qrow = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE wp_post_id=%d AND type != 'retarget_candidate' LIMIT 1", $t, (int) $row->source_post_id ) ); 5223 if ( $qrow ) { $wpdb->update( $t, array( 'status' => 'pending' ), array( 'id' => $qrow->id ) ); } 5224 5225 update_post_meta( $row->source_post_id, '_bpv2_lifecycle_state', 'active' ); 5226 update_post_meta( $row->source_post_id, '_bpv2_lifecycle_changed_at', current_time( 'mysql' ) ); 5227 update_post_meta( $row->source_post_id, '_bpv2_last_nonzero_impression_date', gmdate( 'Y-m-d' ) ); 5228 delete_post_meta( $row->source_post_id, '_bpv2_lifecycle_awaiting_confirm' ); 5229 BPV2_DB::insert_lifecycle_log( $row->source_post_id, 'retarget_queue', 'active', 'rewrite-in-place queued by Rd' ); 5230 BPV2_DB::update_queue_status( $queue_id, 'rejected' ); // clears it off the retarget review list 5231 5232 wp_send_json_success( array( 'message' => 'Queued for regeneration; lifecycle clock reset.' ) ); 5233 } 5234 5235 public function ajax_lifecycle_toggle_pillar() { 5236 check_ajax_referer( 'bpv2_ajax', 'nonce' ); 5237 if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden' ); 5238 $post_id = absint( $_POST['post_id'] ?? 0 ); 5239 $on = ! empty( $_POST['on'] ); 5240 if ( ! $post_id ) wp_send_json_error( 'Invalid post ID.' ); 5241 if ( $on ) { update_post_meta( $post_id, '_bpv2_pillar', 1 ); } 5242 else { delete_post_meta( $post_id, '_bpv2_pillar' ); delete_post_meta( $post_id, '_bpv2_lifecycle_pillar_logged' ); } 5243 wp_send_json_success(); 5244 } 5245 5246 /** Manual "run now" button -- same code path as the daily cron. */ 5247 public function ajax_lifecycle_run_now() { 5248 check_ajax_referer( 'bpv2_ajax', 'nonce' ); 5249 if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden' ); 5250 $pull = BPV2_Lifecycle::run_gsc_daily_pull(); 5251 $counts = BPV2_Lifecycle::run_lifecycle_pass(); 5252 wp_send_json_success( array( 'pull' => $pull, 'counts' => $counts ) ); 5253 } 5160 5254 5161 5255 public function ajax_save_niche_pair() { -
blogpilot/trunk/admin/views/settings.php
r3704425 r3712303 5 5 $tabs = array( 'general'=>'General', 'brand'=>'Brand & Author', 'audience'=>'Audience', 'content-rules'=>'Content Rules', 'automation'=>'Automation', 'data-sources'=>'Data Sources', 'telegram'=>'Telegram', 'license'=>'License', 'legal'=>'Legal' ); 6 6 $genres = BPV2_DB::genre_config(); 7 $models = array( 8 'claude-haiku-4-5-20251001' => 'Claude Haiku 4.5 (fastest, cheapest)', 9 'claude-sonnet-4-6' => 'Claude Sonnet 4.6 (recommended)', 10 'claude-opus-4-6' => 'Claude Opus 4.6 (best quality)', 11 ); 7 // FIX BP#MODEL-ALIAS-01 (22 Sep 2026): labels now come from the plugin's one 8 // shared helper instead of a literal, version-pinned array here. 9 $models = BPV2_Admin::model_tiers(); 12 10 $banned_phrases = (array) get_option( 'bpv2_banned_phrases', array() ); 13 11 $banned_topics = (array) get_option( 'bpv2_banned_topics', array() ); … … 96 94 <td><select name="bpv2_model"> 97 95 <?php foreach ($models as $val => $label): ?> 98 <option value="<?php echo esc_attr($val); ?>" <?php selected(get_option('bpv2_model',' claude-sonnet-4-6'),$val); ?>><?php echo esc_html($label); ?></option>96 <option value="<?php echo esc_attr($val); ?>" <?php selected(get_option('bpv2_model','sonnet'),$val); ?>><?php echo esc_html($label); ?></option> 99 97 <?php endforeach; ?> 100 98 </select></td></tr> -
blogpilot/trunk/admin/views/topic-map.php
r3704425 r3712303 430 430 </span> 431 431 <?php endif; ?> 432 <?php echo $kd_vol_badges( $pillar_kw ); ?> 432 <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $kd_vol_badges() escapes every value internally (esc_attr/esc_html/wp_kses_post) before returning; wrapping the call again would double-encode or strip the style/data attributes the badge markup needs. 433 echo $kd_vol_badges( $pillar_kw ); ?> 433 434 <?php if ( $pillar_kw->status === 'pending' ) : ?> 434 435 <button type="button" class="bpv2-kw-generate-btn button button-small" data-id="<?php echo esc_attr( $pillar_kw->id ); ?>" style="padding:0 7px;font-size:11px" title="Add this keyword to the generation queue">▶ Queue</button> … … 457 458 <?php echo esc_html( $skw_disp ); ?> 458 459 </span> 459 <?php echo $kd_vol_badges( $skw ); ?> 460 <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $kd_vol_badges() escapes every value internally (esc_attr/esc_html/wp_kses_post) before returning; wrapping the call again would double-encode or strip the style/data attributes the badge markup needs. 461 echo $kd_vol_badges( $skw ); ?> 460 462 <?php if ( $skw->status === 'pending' ) : ?> 461 463 <button type="button" class="bpv2-kw-generate-btn button button-small" data-id="<?php echo esc_attr( $skw->id ); ?>" style="padding:0 7px;font-size:11px" title="Add this keyword to the generation queue">▶ Queue</button> … … 503 505 <?php echo esc_html( $unc_disp ); ?> 504 506 </span> 505 <?php echo $kd_vol_badges( $unc ); ?> 507 <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $kd_vol_badges() escapes every value internally (esc_attr/esc_html/wp_kses_post) before returning; wrapping the call again would double-encode or strip the style/data attributes the badge markup needs. 508 echo $kd_vol_badges( $unc ); ?> 506 509 <?php if ( in_array( $unc->status, array( 'pending', 'skipped' ), true ) ) : ?> 507 510 <button type="button" class="bpv2-kw-generate-btn button button-small" -
blogpilot/trunk/blogpilot.php
r3704425 r3712303 4 4 * Plugin URI: https://superappsdomain.com/apps/blogpilot-wordpress-plugin/ 5 5 * Description: Multi-niche AI autoblogging -- discovers keywords, builds topical clusters, writes 15 content genres and publishes automatically. 6 * Version: 2.0.23 36 * Version: 2.0.234 7 7 * Requires at least: 6.2 8 8 * Requires PHP: 7.4 … … 37 37 } 38 38 39 define( 'BPV2_VERSION', '2.0.23 3' );39 define( 'BPV2_VERSION', '2.0.234' ); 40 40 define( 'BPV2_FILE', __FILE__ ); 41 41 define( 'BPV2_DIR', plugin_dir_path( __FILE__ ) ); … … 71 71 'includes/class-bpv2-translator.php', 72 72 'includes/class-bpv2-scaled-risk.php', 73 'includes/class-bpv2-lifecycle.php', 73 74 'includes/class-bpv2-subscribers.php', 74 75 'admin/class-bpv2-admin.php', … … 435 436 add_action( 'wp_enqueue_scripts', array( 'BPV2_Accessibility', 'enqueue_contrast_fixes' ) ); 436 437 438 // SL#LIFECYCLE-01: belt-and-suspenders sitemap exclusion alongside the 439 // draft post_status change confirm_noindex() already makes. 440 if ( class_exists( 'BPV2_Lifecycle' ) ) { 441 add_filter( 'rank_math/sitemap/entry', array( 'BPV2_Lifecycle', 'filter_sitemap_entry' ) ); 442 } 443 437 444 // Cron heartbeat: record every wp-cron tick so the admin UI can detect a 438 445 // dead WP-Cron (e.g. DISABLE_WP_CRON=true carried over in a server migration … … 459 466 if ( get_option( 'bpv2_operation_mode', 'manual' ) === 'auto' 460 467 && ! wp_next_scheduled( BPV2_Cron::HOOK_GENERATE ) ) { 468 BPV2_Cron::schedule_all(); 469 } 470 // SL#LIFECYCLE-01: these two run regardless of Manual/Auto (see their 471 // own can_run() reasoning), so self-heal them independently of the 472 // auto-mode check above -- otherwise an existing Auto-mode site whose 473 // HOOK_GENERATE is already scheduled would never pick up the new jobs. 474 if ( ! wp_next_scheduled( BPV2_Cron::HOOK_LIFECYCLE ) || ! wp_next_scheduled( BPV2_Cron::HOOK_GSC_DAILY ) ) { 461 475 BPV2_Cron::schedule_all(); 462 476 } -
blogpilot/trunk/includes/class-bpv2-cron.php
r3672131 r3712303 29 29 const HOOK_CEREBRO_SCAN = 'bpv2_daily_cerebro_scan'; 30 30 const HOOK_SCALED_RISK = 'bpv2_daily_scaled_risk'; 31 const HOOK_GSC_DAILY = 'bpv2_cron_gsc_daily'; 32 const HOOK_LIFECYCLE = 'bpv2_cron_lifecycle_pass'; 31 33 32 34 public function __construct() { … … 43 45 add_action( self::HOOK_CEREBRO_SCAN, array( $this, 'run_cerebro_scan' ) ); 44 46 add_action( self::HOOK_SCALED_RISK, array( $this, 'run_scaled_risk' ) ); 47 add_action( self::HOOK_GSC_DAILY, array( $this, 'run_gsc_daily' ) ); 48 add_action( self::HOOK_LIFECYCLE, array( $this, 'run_lifecycle' ) ); 45 49 add_filter( 'cron_schedules', array( $this, 'add_schedules' ) ); 46 50 } … … 59 63 array( self::HOOK_CEREBRO_SCAN, 'bpv2_daily', '06:00' ), 60 64 array( self::HOOK_SCALED_RISK, 'bpv2_daily', '05:00' ), 65 // SL#LIFECYCLE-01: daily ingestion runs first, the state-machine pass 66 // right after so the same day's data is available to it. 67 array( self::HOOK_GSC_DAILY, 'bpv2_daily', '04:30' ), 68 array( self::HOOK_LIFECYCLE, 'bpv2_daily', '05:15' ), 61 69 ); 62 70 foreach ( $jobs as $job ) { … … 91 99 self::HOOK_GSC, self::HOOK_DIGEST, self::HOOK_RETRY, 92 100 self::HOOK_REVIEW, self::HOOK_CEREBRO_SCAN, 93 self::HOOK_SCALED_RISK ) as $hook ) { 101 self::HOOK_SCALED_RISK, self::HOOK_GSC_DAILY, 102 self::HOOK_LIFECYCLE ) as $hook ) { 94 103 wp_clear_scheduled_hook( $hook ); 95 104 } … … 513 522 } 514 523 524 /** 525 * SL#LIFECYCLE-01 daily GSC ingestion. Read-only against GSC, same 526 * risk profile as run_gsc() -- runs regardless of Manual/Auto mode. 527 */ 528 public function run_gsc_daily() { 529 $lock = 'bpv2_running_gsc_daily'; 530 if ( get_transient( $lock ) ) { error_log( 'BlogPilot lifecycle: GSC daily pull skipped -- already running.' ); return; } 531 set_transient( $lock, 1, 300 ); 532 $r = BPV2_Lifecycle::run_gsc_daily_pull(); 533 error_log( sprintf( 'BlogPilot lifecycle: GSC daily pull for %s -- %d rows. Error: %s', $r['date'] ?? '?', $r['pulled'], $r['error'] ?? 'none' ) ); 534 delete_transient( $lock ); 535 } 536 537 /** 538 * SL#LIFECYCLE-01 state-machine pass. Read/flag-only in effect -- the 539 * only destructive step (noindex/draft) requires an explicit admin 540 * confirm and never runs from this cron. Runs regardless of 541 * Manual/Auto mode, same reasoning as run_scaled_risk(): it publishes 542 * nothing, and a site parked in Manual still wants its dead-page 543 * picture kept current. 544 */ 545 public function run_lifecycle() { 546 if ( ! class_exists( 'BPV2_Lifecycle' ) ) return; 547 $lock = 'bpv2_running_lifecycle'; 548 if ( get_transient( $lock ) ) { error_log( 'BlogPilot lifecycle: pass skipped -- already running.' ); return; } 549 set_transient( $lock, 1, 600 ); 550 try { 551 $counts = BPV2_Lifecycle::run_lifecycle_pass(); 552 error_log( 'BlogPilot lifecycle: ' . wp_json_encode( $counts ) ); 553 } catch ( Exception $e ) { 554 error_log( 'BlogPilot lifecycle pass failed: ' . $e->getMessage() ); 555 } 556 delete_transient( $lock ); 557 } 558 515 559 public function run_review_window() { 516 560 $hours = (int) get_option( 'bpv2_review_window_hours', 48 ); … … 1017 1061 } 1018 1062 global $wpdb; 1063 // SL#LIFECYCLE-01: a post this feature has noindexed/drafted stops 1064 // counting toward the niche's monthly ceiling -- otherwise pruning 1065 // dead weight would cost the niche room to publish its replacement. 1066 // In practice this rarely changes anything (a post needs >=28 days 1067 // of silence before it can even enter retarget_queue, so it is 1068 // almost always outside the current-month window already), but it 1069 // is checked explicitly rather than left to that timing coincidence. 1019 1070 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom bpv2_* table, no core API; values and table name bound through prepare(). 1020 1071 $used = (int) $wpdb->get_var( $wpdb->prepare( 1021 "SELECT COUNT(*) FROM %i WHERE niche_id=%d AND status='published' AND created_at >= %s", 1072 "SELECT COUNT(*) FROM %i q 1073 WHERE q.niche_id=%d AND q.status='published' AND q.created_at >= %s 1074 AND NOT EXISTS ( 1075 SELECT 1 FROM {$wpdb->postmeta} pm 1076 WHERE pm.post_id = q.wp_post_id 1077 AND pm.meta_key = '_bpv2_lifecycle_state' 1078 AND pm.meta_value = 'noindexed' 1079 )", 1022 1080 BPV2_DB::queue_table(), (int) $niche->id, gmdate( 'Y-m-01 00:00:00' ) 1023 1081 ) ); -
blogpilot/trunk/includes/class-bpv2-db.php
r3692399 r3712303 52 52 public static function translations_table() { global $wpdb; return $wpdb->prefix . 'bpv2_translations'; } 53 53 public static function fingerprints_table() { global $wpdb; return $wpdb->prefix . 'bpv2_fingerprints'; } 54 public static function gsc_daily_table() { global $wpdb; return $wpdb->prefix . 'bpv2_gsc_daily'; } 55 public static function lifecycle_log_table() { global $wpdb; return $wpdb->prefix . 'bpv2_lifecycle_log'; } 54 56 55 57 // -- Genre config --------------------------------------------------------- … … 215 217 KEY idx_signal (signal_type), 216 218 KEY idx_actioned (actioned) 219 ) $charset;" ); 220 221 // SL#LIFECYCLE-01: daily per-post GSC impressions/clicks/position, one 222 // row per post per day. The lifecycle state machine never reads this 223 // table directly -- it reads the _bpv2_last_nonzero_impression_date 224 // postmeta rollup -- this table exists for the site-health gate's 225 // trailing-window sums and as the raw audit trail behind that meta. 226 dbDelta( "CREATE TABLE IF NOT EXISTS " . self::gsc_daily_table() . " ( 227 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, 228 post_id BIGINT UNSIGNED NOT NULL, 229 date DATE NOT NULL, 230 impressions INT UNSIGNED NOT NULL DEFAULT 0, 231 clicks INT UNSIGNED NOT NULL DEFAULT 0, 232 position DECIMAL(5,2) DEFAULT NULL, 233 PRIMARY KEY (id), 234 UNIQUE KEY uniq_post_date (post_id, date), 235 KEY idx_date (date) 236 ) $charset;" ); 237 238 // SL#LIFECYCLE-01: audit trail for every active/retarget_queue/noindexed 239 // transition -- source for the morning-report block and for checking 240 // the safety gate after the fact. 241 dbDelta( "CREATE TABLE IF NOT EXISTS " . self::lifecycle_log_table() . " ( 242 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, 243 post_id BIGINT UNSIGNED NOT NULL, 244 from_state VARCHAR(20) NOT NULL, 245 to_state VARCHAR(20) NOT NULL, 246 reason VARCHAR(255) DEFAULT NULL, 247 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 248 PRIMARY KEY (id), 249 KEY idx_post (post_id), 250 KEY idx_created (created_at) 217 251 ) $charset;" ); 218 252 … … 430 464 array( 'niches_table', 'image_mode', "ADD COLUMN image_mode VARCHAR(12) NOT NULL DEFAULT 'stock'" ), 431 465 array( 'niches_table', 'image_ai_prompt', 'ADD COLUMN image_ai_prompt TEXT DEFAULT NULL' ), 466 // SL#LIFECYCLE-01 467 array( 'queue_table', 'type', "ADD COLUMN type VARCHAR(30) NOT NULL DEFAULT 'generation' AFTER post_type" ), 468 array( 'queue_table', 'source_post_id', 'ADD COLUMN source_post_id BIGINT UNSIGNED DEFAULT NULL AFTER keyword_id' ), 432 469 ); 433 470 … … 512 549 $wpdb->query( $wpdb->prepare( "ALTER TABLE %i MODIFY COLUMN status 513 550 ENUM('pending','draft','approved','rejected','published','trashed') 551 NOT NULL DEFAULT 'pending'", $qt ) ); 552 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching 553 } 554 555 // SL#LIFECYCLE-01: widen queue.status again for the retarget-queue values. 556 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom bpv2_* tables have no core API. Every value and every table name is bound through $wpdb->prepare(). 557 $col_type2 = $wpdb->get_var( $wpdb->prepare( 558 "SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS 559 WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s AND COLUMN_NAME='status'", 560 $qt 561 ) ); 562 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching 563 if ( $col_type2 && strpos( $col_type2, "'pending_review'" ) === false ) { 564 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom bpv2_* tables have no core API. Every value and every table name is bound through $wpdb->prepare(). 565 $wpdb->query( $wpdb->prepare( "ALTER TABLE %i MODIFY COLUMN status 566 ENUM('pending','draft','approved','rejected','published','trashed','pending_review','no_viable_retarget') 514 567 NOT NULL DEFAULT 'pending'", $qt ) ); 515 568 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching … … 966 1019 } 967 1020 1021 // -- SL#LIFECYCLE-01 -------------------------------------------------- 1022 1023 /** Upsert one post/day row of GSC data. */ 1024 public static function upsert_gsc_daily( $post_id, $date, $impressions, $clicks, $position ) { 1025 global $wpdb; $t = self::gsc_daily_table(); 1026 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom bpv2_* table, no core API; every value and the table name are bound through prepare(). 1027 $existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE post_id=%d AND date=%s LIMIT 1", $t, (int) $post_id, $date ) ); 1028 $row = array( 1029 'post_id' => (int) $post_id, 1030 'date' => $date, 1031 'impressions' => (int) $impressions, 1032 'clicks' => (int) $clicks, 1033 'position' => $position, 1034 ); 1035 $existing ? $wpdb->update( $t, $row, array( 'id' => $existing ) ) : $wpdb->insert( $t, $row ); 1036 } 1037 1038 /** Append one lifecycle transition to the audit log. */ 1039 public static function insert_lifecycle_log( $post_id, $from_state, $to_state, $reason = '' ) { 1040 global $wpdb; 1041 $wpdb->insert( self::lifecycle_log_table(), array( 1042 'post_id' => (int) $post_id, 1043 'from_state' => sanitize_text_field( $from_state ), 1044 'to_state' => sanitize_text_field( $to_state ), 1045 'reason' => sanitize_text_field( $reason ), 1046 'created_at' => current_time( 'mysql' ), 1047 ) ); 1048 } 1049 1050 /** Today's lifecycle_log rows (used by the morning-report block). */ 1051 public static function get_lifecycle_log_today() { 1052 global $wpdb; $t = self::lifecycle_log_table(); 1053 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom bpv2_* table, no core API; every value and the table name are bound through prepare(). 1054 return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE created_at >= %s ORDER BY created_at DESC", $t, gmdate( 'Y-m-d 00:00:00' ) ) ); 1055 } 1056 1057 /** 1058 * Queue rows that are this plugin's own published, live content -- 1059 * i.e. the population the lifecycle pass evaluates. Joined implicitly 1060 * through niche_id, which the caller uses for authority_basis lookups 1061 * without a second query per post. 1062 */ 1063 public static function get_published_pipeline_rows( $limit = 500 ) { 1064 global $wpdb; $t = self::queue_table(); 1065 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom bpv2_* table, no core API; every value and the table name are bound through prepare(). 1066 return $wpdb->get_results( $wpdb->prepare( 1067 "SELECT id, wp_post_id, niche_id, keyword_id, genre FROM %i 1068 WHERE status='published' AND wp_post_id IS NOT NULL AND type != 'retarget_candidate' 1069 ORDER BY id ASC LIMIT %d", $t, (int) $limit 1070 ) ); 1071 } 1072 1073 /** Pending, unused keywords in a niche's bank -- candidate pool for retarget matching. */ 1074 public static function get_keywords_for_niche( $niche_id, $limit = 200 ) { 1075 global $wpdb; $t = self::keywords_table(); 1076 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom bpv2_* table, no core API; every value and the table name are bound through prepare(). 1077 return $wpdb->get_results( $wpdb->prepare( 1078 "SELECT * FROM %i WHERE niche_id=%d AND status='pending' ORDER BY id DESC LIMIT %d", 1079 $t, (int) $niche_id, (int) $limit 1080 ) ); 1081 } 1082 1083 /** pending_review / no_viable_retarget queue rows for the Lifecycle admin screen. */ 1084 public static function get_retarget_queue_rows( $status = 'pending_review', $limit = 100 ) { 1085 global $wpdb; $t = self::queue_table(); 1086 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom bpv2_* table, no core API; every value and the table name are bound through prepare(). 1087 return $wpdb->get_results( $wpdb->prepare( 1088 "SELECT * FROM %i WHERE type='retarget_candidate' AND status=%s ORDER BY created_at DESC LIMIT %d", 1089 $t, $status, (int) $limit 1090 ) ); 1091 } 1092 968 1093 // -- Queue CRUD ----------------------------------------------------------- 969 1094 … … 996 1121 'translation_of' => isset($data['translation_of']) ? absint($data['translation_of']) : null, 997 1122 'translation_lang' => isset($data['translation_lang']) ? sanitize_text_field($data['translation_lang']) : null, 1123 // SL#LIFECYCLE-01 1124 'type' => sanitize_text_field( $data['type'] ?? 'generation' ), 1125 'source_post_id' => isset($data['source_post_id']) ? absint($data['source_post_id']) : null, 998 1126 ) ); 999 1127 return $wpdb->insert_id; -
blogpilot/trunk/includes/class-bpv2-generator.php
r3672131 r3712303 24 24 public function __construct() { 25 25 $this->api_key = get_option( 'bpv2_anthropic_key', '' ); 26 $this->model = get_option( 'bpv2_model', ' claude-sonnet-4-6' );26 $this->model = get_option( 'bpv2_model', 'sonnet' ); // FIX BP#MODEL-ALIAS-01 27 27 } 28 28 -
blogpilot/trunk/includes/class-bpv2-gsc.php
r3672131 r3712303 198 198 } 199 199 200 /** 201 * SL#LIFECYCLE-01: single-day, page-level impressions/clicks/position. 202 * Separate from fetch_page_coverage() (which is a rolling N-day TOTAL, 203 * no date granularity) -- the lifecycle daily cron needs one calendar 204 * day at a time so wp_bpv2_gsc_daily can hold a real per-day series. 205 * 206 * @param string $date Y-m-d. 207 * @return array<string,array{impressions:int,clicks:int,position:float}>|null 208 */ 209 public function fetch_daily_page_impressions( $date ) { 210 if ( ! get_option( 'bpv2_gsc_connected', 0 ) ) return null; 211 $token = $this->get_valid_token(); 212 if ( empty( $token ) ) return null; 213 214 $site_url = get_option( 'bpv2_gsc_site_url', get_site_url() ); 215 $response = wp_remote_post( 216 self::SC_API_BASE . '/sites/' . rawurlencode( $site_url ) . '/searchAnalytics/query', 217 array( 218 'timeout' => 30, 219 'headers' => array( 220 'Authorization' => 'Bearer ' . $token, 221 'Content-Type' => 'application/json', 222 ), 223 'body' => wp_json_encode( array( 224 'startDate' => $date, 225 'endDate' => $date, 226 'dimensions' => array( 'page' ), 227 'rowLimit' => 5000, 228 ) ), 229 ) 230 ); 231 if ( is_wp_error( $response ) ) return null; 232 if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) return null; 233 234 $body = json_decode( wp_remote_retrieve_body( $response ), true ); 235 $out = array(); 236 foreach ( (array) ( $body['rows'] ?? array() ) as $row ) { 237 $url = $row['keys'][0] ?? ''; 238 if ( '' === $url ) continue; 239 $out[ untrailingslashit( $url ) ] = array( 240 'impressions' => (int) ( $row['impressions'] ?? 0 ), 241 'clicks' => (int) ( $row['clicks'] ?? 0 ), 242 'position' => (float) ( $row['position'] ?? 0 ), 243 ); 244 } 245 return $out; 246 } 247 200 248 public function fetch_signals() { 201 249 if ( ! get_option( 'bpv2_gsc_connected', 0 ) ) { -
blogpilot/trunk/includes/class-bpv2-notifier.php
r3648134 r3712303 88 88 $dash_url = admin_url( 'admin.php?page=bpv2-dashboard' ); 89 89 90 // SL#LIFECYCLE-01 reporting block -- gate status, today's counts, and 91 // the retarget-review backlog, exactly what the admin Lifecycle screen 92 // shows live. Nothing here is silent: an all-zero week is printed as 93 // zeros, not omitted. 94 $lc_gate = get_option( 'bpv2_lifecycle_gate_status', 'dry_run' ); 95 $lc_t7 = (int) get_option( 'bpv2_lifecycle_trailing7', 0 ); 96 $lc_floor = (int) get_option( 'bpv2_lifecycle_floor', 200 ); 97 $lc_counts = wp_parse_args( get_option( 'bpv2_lifecycle_counts_today', array() ), 98 array( 'entered_retarget'=>0,'ready_noindex'=>0,'reactivated'=>0,'pillar_skips'=>0,'no_viable'=>0 ) ); 99 $lc_pending = count( BPV2_DB::get_retarget_queue_rows( 'pending_review', 500 ) ); 100 $lc_url = admin_url( 'admin.php?page=bpv2-lifecycle' ); 101 102 $lc_block = "\nLifecycle pruning (SL#LIFECYCLE-01):\n" 103 . " Gate: " . ( 'live' === $lc_gate ? 'LIVE' : 'dry-run' ) . " (trailing 7d impressions: {$lc_t7}, floor: {$lc_floor}/day)\n" 104 . " Entered retarget queue: {$lc_counts['entered_retarget']}\n" 105 . " Ready to noindex today: {$lc_counts['ready_noindex']}\n" 106 . " Reactivated: {$lc_counts['reactivated']}\n" 107 . " Pillar-exempt skips: {$lc_counts['pillar_skips']}\n" 108 . " Retarget backlog: {$lc_pending} pending review\n" 109 . " Review queue: {$lc_url}\n"; 110 90 111 $message = "Here's your BlogPilot weekly summary:\n\n" 91 112 . "[OK] Posts published: {$stats['queue_published']}\n" … … 93 114 . " Keywords in pipeline: {$stats['keywords_total']}\n" 94 115 . " Topic clusters: {$stats['clusters_total']} ({$stats['clusters_complete']} complete)\n" 95 . " GSC signals unactioned: {$stats['gsc_signals']}\n\n" 116 . " GSC signals unactioned: {$stats['gsc_signals']}\n" 117 . $lc_block . "\n" 96 118 . "View your dashboard:\n{$dash_url}\n\n" 97 119 . "-- BlogPilot"; -
blogpilot/trunk/includes/class-bpv2-social-derivatives.php
r3653823 r3712303 259 259 'global_voice' => get_option( 'bpv2_brand_voice', '' ), 260 260 'global_persona' => get_option( 'bpv2_extra_persona', '' ), 261 'model' => get_option( 'bpv2_model', ' claude-sonnet-4-6' ),261 'model' => get_option( 'bpv2_model', 'sonnet' ), // FIX BP#MODEL-ALIAS-01 262 262 ), 120 ); 263 263 if ( ! is_wp_error( $resp ) && ! empty( $resp['ok'] ) && isset( $resp['variants'] ) && is_array( $resp['variants'] ) ) { -
blogpilot/trunk/readme.txt
r3704425 r3712303 5 5 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 2.0.23 37 Stable tag: 2.0.234 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 159 159 160 160 == Changelog == 161 162 = 2.0.234 = 163 * Added: Lifecycle Pruning -- daily GSC-driven detection of posts whose search impressions have gone quiet, moving them through active -> retarget queue -> noindexed. Never fully automatic: a post only reaches "awaiting confirm" on its own, and actually noindexing it always requires an explicit click from an admin. Posts marked as a pillar are permanently exempt. 164 * Changed: Settings' AI model picker now shows plain tier names (Sonnet 5, Haiku 4.5, Opus 5.5) instead of raw version strings, resolved to the current model server-side -- future model updates no longer require a plugin update to take effect. 165 * Fixed: WordPress.org Plugin Check flagged unescaped output on the Topic Map keyword-difficulty/search-volume badges; confirmed the badge markup was already fully escaped internally and annotated the call sites accordingly. 166 * Fixed: readme.txt's Stable tag had fallen out of sync with the plugin's Version header. 161 167 162 168 = 2.0.233 =
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)