Come Costruire la Base di un Sito di News Automatizzato

Un sito che pubblica notizie locali senza una redazione dietro richiede un impianto preciso: un formato dati condiviso, un modello che cerca sul web e riscrive senza inventare, un controllo dei doppioni, e due modi per far partire la generazione — a comando o su orario. Vediamo i pezzi fondamentali, con esempi di codice reali.

1. La Struttura del Progetto

Prima regola: separare nettamente ciò che è pubblico da ciò che è privato/amministrativo. Il pubblico legge soltanto un file JSON già pronto; il privato è l'unico posto che parla con il modello e scrive quel JSON. Una struttura minima che regge bene è questa:

/
├── index.php              // Homepage pubblica: griglia notizie, letta da data/news.json
├── article.php             // Pagina di dettaglio di una singola notizia (?id=...)
├── admin/
│   ├── generate.php         // Pannello privato: bottone "Cerca notizie" + feedback
│   └── search-news.php      // Endpoint che chiama davvero il modello e scrive il JSON
├── includes/
│   └── openai_search.php    // Funzioni di libreria per la chiamata all'API
├── config/
│   ├── config.php            // Costanti condivise: percorsi, modello, limiti
│   ├── sources.php           // Elenco testate/fonti da preferire
│   └── apikey.local.php      // Chiave API (mai su repository pubblici)
└── data/
    ├── news.json              // Il "database" delle notizie: un array JSON
    └── search_log.json        // Log di ogni esecuzione (per debug)

Il punto chiave è che index.php e article.php non sanno nulla del modello o dell'API: leggono solo data/news.json. Questo separa in modo netto "generare contenuto" da "mostrare contenuto", e permette di cambiare il motore di ricerca in futuro senza toccare una riga del frontend pubblico.

Perché conta: centralizzare costanti come il modello usato, il numero massimo di notizie per esecuzione o i percorsi dei file in un unico config.php evita che gli stessi valori vengano ridefiniti (e magari disallineati) in più punti del codice.

2. Il Contratto Dati: uno Schema Fisso per Ogni Notizia

Tutto il sito gira attorno a un unico file JSON che funge da database. Per evitare che pagine diverse si aspettino campi diversi, conviene fissare da subito uno schema rigido e non uscirne mai:

[
  {
    "id": 45,
    "titolo": "Titolo riscritto della notizia",
    "testo": "Testo riscritto, almeno 150 parole, tono giornalistico neutro...",
    "fonte_url": "https://testata-originale.it/articolo-vero",
    "data_pubblicazione_originale": "2026-07-24",
    "timestamp": 1753456789
  }
]

Due dettagli non ovvi ma importanti: data_pubblicazione_originale è la data reale della notizia (per mostrare correttamente quanto è "vecchia"), mentre timestamp è il momento in cui il sito l'ha salvata — sono due cose diverse e vanno tenute separate, altrimenti una notizia di tre giorni fa generata oggi sembrerà pubblicata oggi. Le nuove notizie vanno sempre anteposte all'array (le più recenti prima), così la homepage non deve riordinare nulla.

3. Chiamare il Modello con Ricerca Web Nativa

Il cuore del sistema è la chiamata all'API di OpenAI. La scelta più affidabile oggi è la Responses API con il tool nativo web_search, invece di un modello "chat" a cui si chiede genericamente di cercare: costringendo il tool a essere usato (tool_choice: "required"), il modello è obbligato a fare ricerche reali prima di rispondere, invece di poter rispondere "a memoria".

Per ricerche che richiedono più query in sequenza (più città, più categorie), la chiamata può metterci più di qualche secondo: invece di tenere aperta una singola connessione HTTP fino alla fine (rischiando un timeout), conviene usare la modalità background — si sottomette la richiesta, si ottiene subito un id, e si fa polling a intervalli brevi finché lo stato non diventa definitivo:

function callOpenAiWebSearch(string $apiKey, string $systemMessage, string $userPrompt, string $model): array
{
    $payload = [
        'model' => $model,
        'background' => true,               // evita di tenere aperta la connessione a lungo
        'reasoning' => ['effort' => 'medium'],
        'tools' => [[
            'type' => 'web_search',
            'search_context_size' => 'high',
            'user_location' => [
                'type' => 'approximate',
                'country' => 'IT', 'region' => 'Sardegna', 'city' => 'Sassari',
            ],
        ]],
        'tool_choice' => 'required',          // la ricerca web NON è opzionale
        'input' => [
            ['role' => 'system', 'content' => $systemMessage],
            ['role' => 'user', 'content' => $userPrompt],
        ],
    ];

    // 1) Sottomissione: risposta quasi immediata con id + stato "queued"/"in_progress"
    $submission = httpPost('https://api.openai.com/v1/responses', $apiKey, $payload);
    $responseId = $submission['id'];
    $status = $submission['status'];

    // 2) Polling breve finché lo stato non è definitivo (completed/failed/...)
    while (in_array($status, ['queued', 'in_progress'], true)) {
        sleep(3);
        $poll = httpGet('https://api.openai.com/v1/responses/' . $responseId, $apiKey);
        $status = $poll['status'];
    }

    // 3) Il testo finale è nell'array "output", tra le voci di tipo "message"
    //    (ci sono anche voci di tipo "web_search_call" con le query realmente eseguite)
    // ... estrazione del testo e delle citazioni, vedi punto successivo ...
}
⚠️ Sicurezza: la chiave API non deve mai comparire in JavaScript lato browser. Tutta la chiamata avviene lato server (PHP), richiamata via AJAX dal pannello privato — il browser dell'utente non vede mai la chiave.

4. System Prompt e User Prompt

Con la ricerca web nativa risolta, il resto del lavoro è nella qualità delle istruzioni. Serve un system prompt che fissi il formato di output, e uno user prompt che descriva cosa cercare:

$systemMessage = "Sei un assistente di redazione che cerca notizie reali sul web e restituisce " .
    "esclusivamente un array JSON valido, senza markdown, senza testo introduttivo o conclusivo. " .
    "Esegui sempre almeno una ricerca web reale prima di rispondere: se una singola ricerca non " .
    "copre tutto lo scope richiesto, esegui più ricerche separate prima di comporre la risposta.";

$userPrompt = "Data di oggi: {$oggi}.\n" .
    "Cerca sul web notizie di attualità pubblicate negli ultimi {$giorniMax} giorni riguardanti " .
    "{$zonaGeografica}. Dai priorità, quando possibile, a queste fonti:\n- {$listaFonti}\n\n" .
    "Tra le notizie trovate, scegli le {$numeroMax} più rilevanti.\n" .
    "Per ciascuna notizia scelta:\n" .
    "- Riscrivi titolo e testo con parole tue (parafrasa), SENZA inventare, aggiungere o alterare " .
    "  fatti, nomi, luoghi, date o numeri.\n" .
    "- Il testo riscritto deve essere di almeno 150 parole, in tono giornalistico neutro.\n\n" .
    "Restituisci ESCLUSIVAMENTE un array JSON con questa struttura esatta (nessun altro testo):\n" .
    '[{"titolo": "...", "testo": "...", "fonte_url": "...", "data_pubblicazione_originale": "YYYY-MM-DD"}]';
Un dettaglio che costa caro se sbagliato: qualunque vincolo nel prompt (es. "non ripetere queste notizie già pubblicate") va scritto come preferenza, non come divieto assoluto. Un divieto rigido, su uno scope stretto (poche notizie reali al giorno per una zona piccola), spinge il modello a preferire "zero risultati" piuttosto che violare la regola — la cosa peggiore che possa succedere a un run automatico.

5. Controllo Duplicati (in Breve)

Il prompt riduce le ripetizioni, ma la deduplica vera va fatta lato codice, non fidandosi del modello. L'approccio più semplice ed efficace è normalizzare i titoli (minuscolo, senza punteggiatura/accenti) e confrontarli con quelli già salvati:

function normalizeTitleForDedup(string $title): string {
    $t = mb_strtolower($title, 'UTF-8');
    $t = preg_replace('/[^\p{L}\p{N}\s]/u', '', $t);   // via punteggiatura/simboli
    return trim(preg_replace('/\s+/', ' ', $t));
}

// Per ogni notizia proposta dal modello:
$normalizzato = normalizeTitleForDedup($notizia['titolo']);
if (in_array($normalizzato, $titoliGiaEsistentiNormalizzati, true)) {
    // scarta: è un duplicato esatto di una notizia già salvata
    continue;
}

Questo intercetta i doppioni con titolo identico o quasi identico, ma non un titolo completamente riformulato sullo stesso fatto — un limite noto di questo approccio, accettabile per un sito che pubblica poche notizie al giorno.

6. Generazione Manuale (generate.php) o su Cronjob

Lo stesso endpoint che chiama il modello e scrive news.json (admin/search-news.php) può essere invocato in due modi, senza duplicare la logica:

  • Manuale: un pannello privato (admin/generate.php) con un bottone "Cerca notizie" che chiama l'endpoint via AJAX e mostra un feedback (quante notizie aggiunte, quante scartate come doppioni). Utile per test, per rigenerare a comando, o come rete di sicurezza se il cronjob dovesse saltare.
  • Automatico via cronjob: lo stesso endpoint richiamato a intervalli regolari (es. ogni 2-3 ore) con una semplice chiamata HTTP da riga di comando, così il sito si aggiorna da solo senza intervento umano:
# crontab -e
# Richiama l'endpoint di ricerca ogni 3 ore, scartando l'output
0 */3 * * * curl -s "https://www.tuosito.it/admin/search-news.php" > /dev/null 2>&1
⚠️ Se il cronjob è pubblicamente raggiungibile: aggiungi una protezione minima (una chiave segreta in query string controllata dallo script, o una restrizione IP) — altrimenti chiunque conosca l'URL può far scattare esecuzioni a piacimento, consumando la tua quota API.

7. Il Punto Fermo: l'IA Non Inventa Nulla

L'intero sistema regge su un vincolo non negoziabile: il modello non genera notizie dal nulla. Il tool web_search recupera pagine reali da testate reali; il modello riceve quei risultati e ha il solo compito di parafrasare — cambiare le parole di titolo e testo mantenendo fatti, nomi, luoghi, date e numeri identici all'originale. Il prompt lo dice esplicitamente ("senza inventare, aggiungere o alterare fatti"), ma la garanzia reale sta nell'architettura stessa: il modello non può rispondere senza aver prima cercato (tool_choice: "required"), quindi non c'è margine per una risposta "a memoria" o inventata.

Perché è la parte più importante: un sito che pubblica automaticamente ha una sola vera responsabilità editoriale: non diffondere fatti falsi. Tutto il resto (design, velocità, SEO) è secondario rispetto a questo vincolo strutturale.