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.
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.
config.php evita che gli stessi valori vengano ridefiniti (e magari disallineati) in più punti del codice.
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.
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 ...
}
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"}]';
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.
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:
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.# 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
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.