/** * Guardiano delle giacenze — versione interna a WordPress. * * Sostituisce tools/funbox_guard.py, che gira sul pi5 di casa di vjt (linea * residenziale, non affidabile per un sito di produzione — ordine di vjt, * #hercules 2026-07-29 13:05). * * Il problema resta lo stesso: syncrocommerce scrive le giacenze dritto nel * database senza far girare WordPress, quindi nessun hook PHP se ne accorge e * WP Rocket continua a servire schede con disponibilita' vecchie. Qui il * confronto lo facciamo noi, a orario, leggendo il database in diretta. * * Differenze rispetto alla versione python, tutte a favore: * - la fotografia e' UNA query su wc_product_meta_lookup invece di ~310 * chiamate REST: dai 228s misurati sul pi5 si scende a frazioni di secondo; * - il purge usa le funzioni di WP Rocket in-process, niente endpoint HTTP * con token da proteggere; * - niente credenziali WooCommerce in giro per il mondo. * * INSTALLAZIONE — come snippet PHP (nessun accesso ai file richiesto): * incollare tutto il contenuto SENZA il tag [ id, permalink, stock => [ vid => "stato:qta" ] ] ]. * * La chiave e' lo SKU e non l'id perche' la sincro col gestionale non aggiorna * i prodotti: li CANCELLA e li RICREA con un id nuovo (misurato il 2026-07-28 * sulla VN000D1J9DH1). Indicizzando per id ogni sincro sembrerebbe una * sparizione piu' una comparsa, e ci costringerebbe a svuotare tutta la cache * invece di buttare la singola scheda. */ function funbox_guard_snapshot() { global $wpdb; $lookup = $wpdb->prefix . 'wc_product_meta_lookup'; // Prodotti pubblicati + le loro varianti, in un colpo solo. post_parent = 0 // sui prodotti, = id del padre sulle varianti. $rows = $wpdb->get_results( "SELECT p.ID, p.post_type, p.post_parent, l.sku, l.stock_status, l.stock_quantity FROM {$wpdb->posts} p LEFT JOIN {$lookup} l ON l.product_id = p.ID WHERE ( p.post_type = 'product' AND p.post_status = 'publish' ) OR ( p.post_type = 'product_variation' AND p.post_parent IN ( SELECT ID FROM {$wpdb->posts} WHERE post_type = 'product' AND post_status = 'publish' ) )", ARRAY_A ); $parents = array(); $children = array(); foreach ( $rows as $row ) { if ( 'product' === $row['post_type'] ) { $parents[ (int) $row['ID'] ] = $row; } else { $children[ (int) $row['post_parent'] ][] = $row; } } $shot = array(); foreach ( $parents as $id => $row ) { $stock = array(); if ( ! empty( $children[ $id ] ) ) { foreach ( $children[ $id ] as $variation ) { $stock[ (string) $variation['ID'] ] = funbox_guard_stock_string( $variation ); } } else { // Prodotto semplice: conta lo stato suo. $stock['self'] = funbox_guard_stock_string( $row ); } // Ordine fisso: il confronto con la fotografia vecchia e' un !== fra // array, che in PHP guarda anche l'ordine delle chiavi. Senza questo, // una query che torna le varianti in ordine diverso farebbe sembrare // cambiato tutto il catalogo a ogni giro. ksort( $stock ); // Senza SKU non c'e' identita' stabile: si ripiega sull'id. $key = ( '' !== (string) $row['sku'] && null !== $row['sku'] ) ? (string) $row['sku'] : 'id:' . $id; $shot[ $key ] = array( 'id' => $id, 'permalink' => get_permalink( $id ), 'stock' => $stock, ); } return $shot; } /** "instock:12" — la quantita' e' null sui prodotti che non la gestiscono. */ function funbox_guard_stock_string( $row ) { $qty = ( null === $row['stock_quantity'] || '' === $row['stock_quantity'] ) ? 'None' : (string) (int) $row['stock_quantity']; return $row['stock_status'] . ':' . $qty; } /** Cambiati (giacenza diversa o ricreati dalla sincro), spariti, comparsi. */ function funbox_guard_diff( $old, $new ) { $changed = array(); $recreated = array(); $gone = array(); $added = array(); foreach ( $new as $sku => $entry ) { if ( ! isset( $old[ $sku ] ) ) { $added[] = $sku; continue; } $was = isset( $old[ $sku ]['id'] ) ? (int) $old[ $sku ]['id'] : null; if ( null !== $was && $was !== (int) $entry['id'] ) { $recreated[] = $sku; $changed[] = $sku; } elseif ( $old[ $sku ]['stock'] !== $entry['stock'] ) { $changed[] = $sku; } } foreach ( $old as $sku => $entry ) { if ( ! isset( $new[ $sku ] ) ) { $gone[] = $sku; } } return array( $changed, $gone, $added, $recreated ); } /** Butta dalla cache le schede indicate. Ritorna quante ne ha buttate. */ function funbox_guard_purge_posts( $ids ) { if ( ! function_exists( 'rocket_clean_post' ) ) { return 0; } foreach ( $ids as $id ) { rocket_clean_post( (int) $id ); } return count( $ids ); } /** Svuota tutto. Serve quando il catalogo cambia di numero: shop, categorie, sitemap. */ function funbox_guard_purge_all() { if ( function_exists( 'rocket_clean_domain' ) ) { rocket_clean_domain(); return true; } return false; } /** * Rigenera le pagine buttate. * * Non blocking: le richieste partono e non aspettiamo la risposta, altrimenti * l'attivita' pianificata resterebbe appesa minuti. Chi risponde per primo e' * comunque il visitatore successivo, e a quel punto la pagina e' gia' calda. */ function funbox_guard_warm( $urls ) { $urls = array_slice( array_values( array_unique( $urls ) ), 0, FUNBOX_GUARD_WARM_LIMIT ); foreach ( $urls as $url ) { wp_remote_get( $url, array( 'blocking' => false, 'timeout' => 0.01, 'user-agent' => 'funbox-guard/2.0', ) ); } return count( $urls ); } /** * Riempie la coda di riscaldamento con TUTTO il catalogo. * * Serve solo dopo uno svuotamento totale. Fino al 2026-07-29 qui non c'era * niente: si riscaldavano le sole schede in $changed, e siccome un prodotto * tolto o aggiunto finisce in $gone/$added e non in $changed, il caso piu' * frequente buttava tutta la cache e riscaldava zero pagine. Il commento * diceva "a ripopolare ci pensa il preload di WP Rocket": misurato, non lo fa * (precaricamento acceso, schede ancora a 3,7-4,6s quaranta minuti dopo). * Prima del trasloco a riscaldare era lo script sul pi5; tolto quello * (ordine di vjt, 18:06), non restava nessuno. * * In coda ci vanno anche home, negozio e archivi di categoria: sono le pagine * che cambiano quando un articolo compare o sparisce, cioe' proprio il motivo * per cui si e' svuotato tutto. */ function funbox_guard_warm_queue_fill( $shot ) { $urls = array( home_url( '/' ) ); $shop = function_exists( 'wc_get_page_id' ) ? wc_get_page_id( 'shop' ) : 0; if ( $shop > 0 ) { $urls[] = get_permalink( $shop ); } $terms = get_terms( array( 'taxonomy' => 'product_cat', 'hide_empty' => true, ) ); if ( ! is_wp_error( $terms ) ) { foreach ( $terms as $term ) { $link = get_term_link( $term ); if ( ! is_wp_error( $link ) ) { $urls[] = $link; } } } foreach ( $shot as $entry ) { if ( ! empty( $entry['permalink'] ) ) { $urls[] = $entry['permalink']; } } $urls = array_values( array_unique( array_filter( $urls ) ) ); update_option( FUNBOX_GUARD_QUEUE_OPTION, $urls, false ); return count( $urls ); } /** * Svuota la coda un pezzo per volta e si riarma da solo finche' ne resta. * * Il riarmo e' un evento singolo a breve scadenza invece di un'attivita' a * orario: il cron di WordPress parte con le visite, e le visite qui ce le * facciamo da soli, perche' ogni pagina fredda che chiediamo passa per PHP e * fa ripartire il giro. La coda quindi si tira avanti da sola anche su un sito * senza traffico. */ function funbox_guard_warm_drain() { $queue = get_option( FUNBOX_GUARD_QUEUE_OPTION ); if ( ! is_array( $queue ) || empty( $queue ) ) { delete_option( FUNBOX_GUARD_QUEUE_OPTION ); return 0; } $batch = array_splice( $queue, 0, FUNBOX_GUARD_WARM_BATCH ); // Richieste VERE, aspettando la risposta. Con 'blocking' => false si chiude // la connessione appena partita: la pagina magari si costruisce lo stesso, // ma nessuno garantisce che WP Rocket faccia in tempo a scrivere il file di // cache, ed e' esattamente il pezzo che ci interessa. Costa 4-6 secondi a // pagina, per questo il mestiere si fa dopo aver congedato il visitatore // (vedi funbox_guard_warm_after_response). foreach ( $batch as $url ) { wp_remote_get( $url, array( 'blocking' => true, 'timeout' => 15, 'user-agent' => 'funbox-guard/2.2', ) ); } if ( empty( $queue ) ) { delete_option( FUNBOX_GUARD_QUEUE_OPTION ); } else { update_option( FUNBOX_GUARD_QUEUE_OPTION, $queue, false ); if ( ! wp_next_scheduled( 'funbox_guard_warm_cron' ) ) { wp_schedule_single_event( time() + FUNBOX_GUARD_WARM_INTERVAL, 'funbox_guard_warm_cron' ); } } return count( $batch ); } add_action( 'funbox_guard_warm_cron', 'funbox_guard_warm_drain' ); /** * Il vero motore della coda: OGNI richiesta che arriva fino a PHP ne smaltisce * un pezzo, al massimo uno ogni FUNBOX_GUARD_WARM_INTERVAL secondi. * * Perche' non basta l'attivita' pianificata: misurato il 2026-07-29, la coda e' * rimasta a 360 pagine su 360 per venti minuti buoni. Il cron di WordPress qui * non parte MAI da solo — l'unico giro automatico di tutta la giornata e' quello * provocato bussando a mano a wp-cron.php. Sintomo classico di DISABLE_WP_CRON * acceso con l'attivita' di sistema dell'hosting che poi non esiste, o non gira. * * Legarsi alle visite invece che al cron regge da solo: le pagine fredde che * chiediamo noi sono a loro volta richieste che passano da PHP, quindi la coda * si tira avanti con le proprie gambe anche di notte, a negozio deserto. */ add_action( 'init', 'funbox_guard_warm_tick', 99 ); function funbox_guard_warm_tick() { if ( funbox_guard_warm_pending() < 1 ) { return; } // Un solo lotto per volta in tutto il sito: il lucchetto scade da solo, // cosi' una richiesta morta a meta' non blocca la coda per sempre. if ( get_transient( 'funbox_guard_warm_lock' ) ) { return; } set_transient( 'funbox_guard_warm_lock', 1, FUNBOX_GUARD_WARM_INTERVAL ); add_action( 'shutdown', 'funbox_guard_warm_after_response', 99 ); } /** * Il lavoro sporco, dopo aver salutato chi stava aspettando la pagina. * * fastcgi_finish_request() chiude la risposta e lascia PHP a lavorare: il * visitatore non paga i nostri quindici secondi. Dove non c'e' (server diversi) * si ripiega su ignore_user_abort, che almeno impedisce al server di * ammazzarci a meta' lavoro. */ function funbox_guard_warm_after_response() { ignore_user_abort( true ); if ( function_exists( 'fastcgi_finish_request' ) ) { fastcgi_finish_request(); } funbox_guard_warm_drain(); } /** Quante pagine restano da riscaldare. Solo per il resoconto. */ function funbox_guard_warm_pending() { $queue = get_option( FUNBOX_GUARD_QUEUE_OPTION ); return is_array( $queue ) ? count( $queue ) : 0; } /** * Il giro vero. Stampa un resoconto in testo semplice e torna il numero di * schede toccate. Non termina la richiesta: a farlo e' chi lo chiama. */ function funbox_guard_run( $dry = false, $seed = false ) { $started = microtime( true ); $new = funbox_guard_snapshot(); $elapsed = microtime( true ) - $started; $old = get_option( FUNBOX_GUARD_OPTION ); if ( $seed || ! is_array( $old ) ) { update_option( FUNBOX_GUARD_OPTION, $new, false ); printf( "fotografia iniziale: %d prodotti in %.1fs\n", count( $new ), $elapsed ); return 0; } list( $changed, $gone, $added, $recreated ) = funbox_guard_diff( $old, $new ); printf( "%d prodotti in %.1fs — cambiati %d (di cui ricreati %d), spariti %d, nuovi %d\n", count( $new ), $elapsed, count( $changed ), count( $recreated ), count( $gone ), count( $added ) ); foreach ( $changed as $sku ) { printf( " %s %s\n", in_array( $sku, $recreated, true ) ? 'ricreato' : 'cambiato', $sku ); } foreach ( $gone as $sku ) { printf( " sparito %s\n", $sku ); } foreach ( $added as $sku ) { printf( " nuovo %s\n", $sku ); } // Un prodotto sparito o comparso cambia anche shop, categorie e sitemap: // in quel caso non basta la singola scheda. $needs_full = ( ! empty( $gone ) || ! empty( $added ) || count( $changed ) >= FUNBOX_GUARD_FULL_THRESHOLD ); if ( $dry ) { echo "dry-run: niente purge, niente warming\n"; return 0; } if ( empty( $changed ) && ! $needs_full ) { update_option( FUNBOX_GUARD_OPTION, $new, false ); return 0; } if ( $needs_full ) { echo funbox_guard_purge_all() ? "catalogo cambiato: svuotata tutta la cache\n" : "catalogo cambiato ma WP Rocket non risponde: cache NON svuotata\n"; // Buttato tutto, tocca a noi rimettere su il catalogo: la coda parte // adesso e si smaltisce da sola, un pezzo ogni mezzo minuto. printf( "in coda per il riscaldamento: %d pagine\n", funbox_guard_warm_queue_fill( $new ) ); printf( "riscaldate subito %d pagine\n", funbox_guard_warm_drain() ); update_option( FUNBOX_GUARD_OPTION, $new, false ); return count( $changed ); } $ids = array(); foreach ( $changed as $sku ) { $ids[] = $new[ $sku ]['id']; } printf( "purgate %d schede\n", funbox_guard_purge_posts( $ids ) ); // Qui abbiamo buttato poche schede e sappiamo quali: si riscaldano subito, // senza coda, perche' sono una manciata. $urls = array(); foreach ( $changed as $sku ) { $urls[] = $new[ $sku ]['permalink']; } printf( "riscaldate %d pagine\n", funbox_guard_warm( $urls ) ); update_option( FUNBOX_GUARD_OPTION, $new, false ); return count( $changed ); } /** * Trigger a orario. Il cron di WordPress parte con le visite, quindi su un * negozio con traffico l'ora e' rispettata a spanne — ci basta: il gestionale * scrive quando vuole, noi rincorriamo. */ add_action( 'funbox_guard_cron', 'funbox_guard_cron_run' ); function funbox_guard_cron_run() { ob_start(); funbox_guard_run( false, false ); // Rete di sicurezza: se l'evento a mezzo minuto si e' perso per strada (il // cron di WordPress vive di visite, e di notte qui non passa nessuno) la // coda riparte comunque al giro d'ora. $pending = funbox_guard_warm_pending(); if ( $pending > 0 ) { printf( "coda arretrata: %d pagine, riprendo (%d ora)\n", $pending, funbox_guard_warm_drain() ); } $report = trim( ob_get_clean() ); // Il resoconto resta leggibile da Strumenti -> Salute del sito -> Info, // e soprattutto non finisce in una pagina servita a un cliente. if ( '' !== $report ) { update_option( 'funbox_guard_last_report', gmdate( 'c' ) . "\n" . $report, false ); } } add_action( 'init', 'funbox_guard_schedule' ); function funbox_guard_schedule() { if ( ! wp_next_scheduled( 'funbox_guard_cron' ) ) { wp_schedule_event( time() + 300, 'hourly', 'funbox_guard_cron' ); } } /** * Rotta manuale, per le prove: ?funbox_guard=1 [&dry=1] [&seed=1]. * Nessun token da custodire: risponde solo a un amministratore gia' loggato. */ add_action( 'init', 'funbox_guard_route' ); function funbox_guard_route() { if ( ! isset( $_GET['funbox_guard'] ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { status_header( 403 ); exit( "no\n" ); } nocache_headers(); header( 'Content-Type: text/plain; charset=utf-8' ); // ?warm=1 — mette in coda tutto il catalogo senza buttare niente. Serve una // volta sola, per rimediare agli svuotamenti gia' fatti quando il // riscaldamento non c'era ancora. if ( ! empty( $_GET['warm'] ) ) { printf( "in coda: %d pagine\n", funbox_guard_warm_queue_fill( funbox_guard_snapshot() ) ); printf( "riscaldate subito: %d\n", funbox_guard_warm_drain() ); } else { funbox_guard_run( ! empty( $_GET['dry'] ), ! empty( $_GET['seed'] ) ); } $pending = funbox_guard_warm_pending(); if ( $pending > 0 ) { printf( "coda di riscaldamento: restano %d pagine\n", $pending ); } // Diagnostica, perche' su questo sito ci abbiamo perso una serata. printf( "cron di WordPress: %s — prossimo giro orario: %s\n", ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) ? 'DISABILITATO' : 'attivo', wp_next_scheduled( 'funbox_guard_cron' ) ? gmdate( 'c', wp_next_scheduled( 'funbox_guard_cron' ) ) : 'non programmato' ); $last = get_option( 'funbox_guard_last_report' ); if ( $last ) { echo "\n--- ultimo giro automatico ---\n" . $last . "\n"; } exit; }