Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/ballales1984-wq_newsflow.json
T
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
130 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "ballales1984-wq/newsflow", "files": {"backend/INTEGRAZIONE_AI.md": {"a6b4ff74a7e7c5ddd3ee768e0f4e42b36822f89a": {"t": 1785746615.5879948, "n": 3210, "h": "d87d449eb15bedba", "c": "# 🤖 Integrazione AI per Approfondimenti\n\n## ✅ Cosa è stato implementato\n\n### 1. Servizio AI (`backend/app/ai_explainer.py`)\n- **ChatGPT (OpenAI)**: Supporto completo per spiegazioni AI\n- **DeepSeek**: Alternativa economica con buona qualità\n- **Fallback automatico**: Se le API non sono disponibili, usa spiegazioni statiche migliorate\n\n### 2. Endpoint API (`/api/v1/articles/explain`)\n- **POST** `/api/v1/articles/explain`\n- Genera spiegazioni AI per articoli\n- Supporta 3 tipi: `quick`, `standard`, `deep`\n\n### 3. Frontend aggiornato\n- Chiamate API per spiegazioni AI\n- Indicatori di caricamento\n- Badge \"Powered by AI\"\n- Cache delle spiegazioni (non ricarica se già generate)\n\n---\n\n## 🔧 Configurazione API Keys\n\n### Opzione 1: Variabili d'ambiente (consigliato)\n\nNel file `backend/AVVIA_TUTTO_AUTO.bat` o script di avvio, aggiungi:\n\n```batch\nset OPENAI_API_KEY=sk-tua-chiave-openai\nset DEEPSEEK_API_KEY=sk-tua-chiave-deepseek\n```\n\n### Opzione 2: File `.env` (se supportato)\n\nCrea `backend/.env`:\n```\nOPENAI_API_KEY=sk-tua-chiave-openai\nDEEPSEEK_API_KEY=sk-tua-chiave-deepseek\n```\n\n### Opzione 3: PowerShell (temporaneo)\n\n```powershell\n$env:OPENAI_API_KEY = \"sk-tua-chiave-openai\"\n$env:DEEPSEEK_API_KEY = \"sk-tua-chiave-deepseek\"\n```\n\n---\n\n## 📝 Come ottenere le API Keys\n\n### OpenAI (ChatGPT)\n1. Vai su: https://platform.openai.com/api-keys\n2. Crea un account o accedi\n3. Clicca \"Create new secret key\"\n4. Copia la chiave (inizia con `sk-`)\n\n**Costi**: ~$0.15 per 1M token (modello `gpt-4o-mini`)\n\n### DeepSeek\n1. Vai su: https://platform.deepseek.com/api_keys\n2. Crea un account o accedi\n3. Clicca \"Create API Key\"\n4. Copia la chiave (inizia con `sk-`)\n\n**Costi**: ~$0.14 per 1M token (modello `deepseek-chat`)\n\n---\n\n## 🎯 Come funziona\n\n1. **Utente clicca \"Spiegami questa notizia\"**\n2. **Frontend chiama API** `/api/v1/articles/explain`\n3. **Backend prova ChatGPT** (se API key disponibile)\n4. **Se fallisce, prova DeepSeek** (se API key disponibile)\n5. **Se entrambi falliscono, usa spiegazione statica migliorata**\n\n### Tipi di spiegazione:\n- **Quick (30s)**: Breve, max 150 parole\n- **Standard (3min)**: Dettagliata, max 400 parole\n- **Deep (approfondito)**: Analisi completa, max 800 parole\n\n---\n\n## 💡 Vantaggi\n\n✅ **Spiegazioni uniche**: Ogni articolo ha spiegazioni diverse generate dall'AI\n✅ **Più approfondite**: Le spiegazioni AI sono più dettagliate e contestualizzate\n✅ **Fallback sicuro**: Se le API non sono disponibili, funziona comunque\n✅ **Cache intelligente**: Le spiegazioni vengono cachate per evitare chiamate duplicate\n\n---\n\n## 🚀 Test\n\n1. Avvia il backend\n2. Apri il sito\n3. Clicca \"Spiegami questa notizia\" su un articolo\n4. Vedi l'indicatore di caricamento\n5. La spiegazione AI appare con badge \"Powered by ChatGPT\" o \"Powered by DeepSeek\"\n\n---\n\n## ⚠️ Note importanti\n\n- **Costi**: Ogni spiegazione costa pochi centesimi. Monitora l'uso!\n- **Rate limits**: Le API hanno limiti di chiamate al minuto\n- **Privacy**: Le API keys devono essere mantenute segrete\n- **Fallback**: Se non configuri le API keys, funziona comunque con spiegazioni statiche\n\n---\n\n## 🔒 Sicurezza\n\n**NON COMMITTARE LE API KEYS NEL CODICE!**\n\nUsa sempre variabili d'ambiente o file `.env` (non tracciato da git).\n\n"}, "b0fec24cda4c33d4e9a4b0b9725a0e11e9130990": {"t": 1785766298.8628345, "n": 3210, "h": "d87d449eb15bedba", "c": "# 🤖 Integrazione AI per Approfondimenti\n\n## ✅ Cosa è stato implementato\n\n### 1. Servizio AI (`backend/app/ai_explainer.py`)\n- **ChatGPT (OpenAI)**: Supporto completo per spiegazioni AI\n- **DeepSeek**: Alternativa economica con buona qualità\n- **Fallback automatico**: Se le API non sono disponibili, usa spiegazioni statiche migliorate\n\n### 2. Endpoint API (`/api/v1/articles/explain`)\n- **POST** `/api/v1/articles/explain`\n- Genera spiegazioni AI per articoli\n- Supporta 3 tipi: `quick`, `standard`, `deep`\n\n### 3. Frontend aggiornato\n- Chiamate API per spiegazioni AI\n- Indicatori di caricamento\n- Badge \"Powered by AI\"\n- Cache delle spiegazioni (non ricarica se già generate)\n\n---\n\n## 🔧 Configurazione API Keys\n\n### Opzione 1: Variabili d'ambiente (consigliato)\n\nNel file `backend/AVVIA_TUTTO_AUTO.bat` o script di avvio, aggiungi:\n\n```batch\nset OPENAI_API_KEY=sk-tua-chiave-openai\nset DEEPSEEK_API_KEY=sk-tua-chiave-deepseek\n```\n\n### Opzione 2: File `.env` (se supportato)\n\nCrea `backend/.env`:\n```\nOPENAI_API_KEY=sk-tua-chiave-openai\nDEEPSEEK_API_KEY=sk-tua-chiave-deepseek\n```\n\n### Opzione 3: PowerShell (temporaneo)\n\n```powershell\n$env:OPENAI_API_KEY = \"sk-tua-chiave-openai\"\n$env:DEEPSEEK_API_KEY = \"sk-tua-chiave-deepseek\"\n```\n\n---\n\n## 📝 Come ottenere le API Keys\n\n### OpenAI (ChatGPT)\n1. Vai su: https://platform.openai.com/api-keys\n2. Crea un account o accedi\n3. Clicca \"Create new secret key\"\n4. Copia la chiave (inizia con `sk-`)\n\n**Costi**: ~$0.15 per 1M token (modello `gpt-4o-mini`)\n\n### DeepSeek\n1. Vai su: https://platform.deepseek.com/api_keys\n2. Crea un account o accedi\n3. Clicca \"Create API Key\"\n4. Copia la chiave (inizia con `sk-`)\n\n**Costi**: ~$0.14 per 1M token (modello `deepseek-chat`)\n\n---\n\n## 🎯 Come funziona\n\n1. **Utente clicca \"Spiegami questa notizia\"**\n2. **Frontend chiama API** `/api/v1/articles/explain`\n3. **Backend prova ChatGPT** (se API key disponibile)\n4. **Se fallisce, prova DeepSeek** (se API key disponibile)\n5. **Se entrambi falliscono, usa spiegazione statica migliorata**\n\n### Tipi di spiegazione:\n- **Quick (30s)**: Breve, max 150 parole\n- **Standard (3min)**: Dettagliata, max 400 parole\n- **Deep (approfondito)**: Analisi completa, max 800 parole\n\n---\n\n## 💡 Vantaggi\n\n✅ **Spiegazioni uniche**: Ogni articolo ha spiegazioni diverse generate dall'AI\n✅ **Più approfondite**: Le spiegazioni AI sono più dettagliate e contestualizzate\n✅ **Fallback sicuro**: Se le API non sono disponibili, funziona comunque\n✅ **Cache intelligente**: Le spiegazioni vengono cachate per evitare chiamate duplicate\n\n---\n\n## 🚀 Test\n\n1. Avvia il backend\n2. Apri il sito\n3. Clicca \"Spiegami questa notizia\" su un articolo\n4. Vedi l'indicatore di caricamento\n5. La spiegazione AI appare con badge \"Powered by ChatGPT\" o \"Powered by DeepSeek\"\n\n---\n\n## ⚠️ Note importanti\n\n- **Costi**: Ogni spiegazione costa pochi centesimi. Monitora l'uso!\n- **Rate limits**: Le API hanno limiti di chiamate al minuto\n- **Privacy**: Le API keys devono essere mantenute segrete\n- **Fallback**: Se non configuri le API keys, funziona comunque con spiegazioni statiche\n\n---\n\n## 🔒 Sicurezza\n\n**NON COMMITTARE LE API KEYS NEL CODICE!**\n\nUsa sempre variabili d'ambiente o file `.env` (non tracciato da git).\n\n"}}, ".history/backend/app/main_simple_20260210074746.py": {"f554c99f0be1935a5a28d29773ca2ad2711d4776": {"t": 1786068246.180545, "n": 120504, "h": "1823b353208040a4", "c": "\"\"\"Versione semplificata di main.py per deploy veloce senza dipendenze complesse\"\"\"\nfrom fastapi import FastAPI, Request\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom pydantic import BaseModel\nfrom typing import Optional\nfrom datetime import datetime\nimport os\nimport json\n\n# Import opzionale di FingerprintAuth per evitare crash su Vercel\ntry:\n from app.core.auth_fingerprint import FingerprintAuth\nexcept ImportError:\n FingerprintAuth = None\n print(\"WARNING: FingerprintAuth non disponibile\")\n\n# Create FastAPI app\napp = FastAPI(\n title=\"NewsFlow\",\n version=\"1.0.0\",\n description=\"Intelligent News Curation Platform\"\n)\n\n# Configure CORS\n# Permetti richieste da localhost, Vercel e ngrok\ndefault_origins = [\n \"http://localhost:4200\",\n \"http://localhost:3000\",\n \"https://newsflow-orcin.vercel.app\", # Dominio Vercel\n \"https://*.vercel.app\", # Tutti i domini Vercel\n \"https://*.ngrok-free.app\", # Domini ngrok\n \"https://*.ngrok.io\",\n]\nCORS_ORIGINS = os.getenv(\"CORS_ORIGINS\", \",\".join(default_origins)).split(\",\")\n\n# Per ngrok e Vercel, usa allow_origin_regex invece di allow_origins per supportare wildcard\napp.add_middleware(\n CORSMiddleware,\n allow_origins=[\"*\"], # Permetti tutti per ngrok e Vercel\n allow_credentials=True,\n allow_methods=[\"*\"],\n allow_headers=[\"*\"],\n)\n\n\n# DISABILITATO startup event su Vercel - causa problemi con Mangum\n# Il caricamento avverrà alla prima richiesta (lazy loading)\nimport os\nis_vercel = os.getenv(\"VERCEL\") == \"1\" or os.getenv(\"VERCEL_ENV\")\n\n# IMPORTANTE: Carica le notizie vecchie ALL'AVVIO per evitare pagina vuota\n# Le nuove notizie le sostituiranno quando saranno disponibili\nif not is_vercel:\n @app.on_event(\"startup\")\n async def startup_event():\n \"\"\"Precarica la cache degli articoli all'avvio con NOTIZIE VECCHIE (se disponibili)\"\"\"\n print(\"🚀 Startup: Caricamento notizie vecchie all'avvio...\")\n print(\" Le nuove notizie sostituiranno le vecchie quando disponibili\")\n\n # Carica le notizie vecchie PRIMA che arrivino le nuove\n try:\n articles = _load_articles(force_reload=False)\n if articles:\n print(f\" ✅ Caricate {len(articles)} notizie vecchie all'avvio\")\n else:\n print(\" ⚠️ Nessuna notizia vecchia trovata - verrà cercato alla prima richiesta\")\n except Exception as e:\n print(f\" ⚠️ Errore caricamento notizie vecchie: {e}\")\n print(\" Le notizie verranno caricate alla prima richiesta\")\nelse:\n print(\"🚀 Vercel rilevato - startup event disabilitato (lazy loading)\")\n # Su Vercel, carica comunque le notizie vecchie alla prima richiesta\n\n\n@app.get(\"/\")\ndef root():\n \"\"\"Root endpoint\"\"\"\n return {\n \"name\": \"NewsFlow\",\n \"version\": \"1.0.0\",\n \"status\": \"running\",\n \"message\": \"Backend is alive! 🚀\"\n }\n\n\n@app.get(\"/api/health\")\ndef health_check():\n \"\"\"Health check endpoint\"\"\"\n return {\"status\": \"healthy\"}\n\n\n@app.get(\"/api/debug/files\")\ndef debug_files():\n \"\"\"Debug endpoint per verificare se i file JSON sono accessibili\"\"\"\n import os\n import json\n\n debug_info = {\n \"current_working_directory\": os.getcwd(),\n \"file_location\": __file__,\n \"possible_paths\": [],\n \"files_found\": [],\n \"directories\": []\n }\n\n # Lista directory per debug\n try:\n cwd = os.getcwd()\n debug_info[\"directories\"].append({\n \"path\": cwd,\n \"exists\": os.path.exists(cwd),\n \"files\": os.listdir(cwd)[:20] if os.path.exists(cwd) else []\n })\n\n # Lista api/\n api_dir = os.path.join(cwd, 'api')\n if os.path.exists(api_dir):\n debug_info[\"directories\"].append({\n \"path\": api_dir,\n \"exists\": True,\n \"files\": os.listdir(api_dir)[:20]\n })\n\n # Lista backend/\n backend_dir = os.path.join(cwd, 'backend')\n if os.path.exists(backend_dir):\n debug_info[\"directories\"].append({\n \"path\": backend_dir,\n \"exists\": True,\n \"files\": os.listdir(backend_dir)[:20]\n })\n except Exception as e:\n debug_info[\"error_listing_dirs\"] = str(e)\n\n # Prova diversi path\n possible_paths = [\n os.path.join(os.getcwd(), 'api', 'final_news_italian.json'),\n os.path.join(os.getcwd(), 'backend', 'final_news_italian.json'),\n os.path.join(os.getcwd(), 'final_news_italian.json'),\n os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'final_news_italian.json'),\n os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'backend', 'final_news_italian.json'),\n os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'api', 'final_news_italian.json'),\n ]\n\n for path in possible_paths:\n exists = os.path.exists(path)\n size = None\n if exists:\n try:\n size = os.path.getsize(path)\n except:\n pass\n debug_info[\"possible_paths\"].append({\n \"path\": path,\n \"exists\": exists,\n \"size\": size\n })\n if exists:\n debug_info[\"files_found\"].append(path)\n\n # Prova a caricare gli articoli\n try:\n articles = _load_articles()\n debug_info[\"articles_loaded\"] = len(articles) if articles else 0\n debug_info[\"articles_sample\"] = articles[:3] if articles else []\n except Exception as e:\n debug_info[\"error_loading_articles\"] = str(e)\n import traceback\n debug_info[\"traceback\"] = traceback.format_exc()\n\n return debug_info\n\n\n# Cache globale per gli articoli (evita ricaricamento continuo)\n_articles_cache = None\n_cache_timestamp = None\n_cache_file_path = None\n\ndef _load_articles(force_reload=False):\n \"\"\"Helper to load articles - 94 NEWS ALL IN ITALIAN\n IMPORTANTE: Carica PRIMA le notizie vecchie (se esistono), poi le sostituisce con le nuove\n Usa cache in memoria per performance migliori\"\"\"\n import json\n import os\n import re\n global _articles_cache, _cache_timestamp, _cache_file_path\n\n # Se abbiamo cache valida e non è forzato il reload, verifica se il file è cambiato\n if not force_reload and _articles_cache is not None and _cache_file_path:\n try:\n current_mtime = os.path.getmtime(_cache_file_path)\n if current_mtime == _cache_timestamp:\n # File non modificato, usa cache (NOTIZIE VECCHIE)\n print(f\"✅ Usando cache (notizie vecchie): {len(_articles_cache)} articoli\")\n return _articles_cache\n else:\n # File modificato, ricarica (SOSTITUISCE con nuove)\n print(f\"🔄 File modificato, ricarico cache (sostituisco vecchie con nuove)...\")\n except:\n # File non esiste più o errore, ricarica\n pass\n\n # Se non c'è cache, prova a caricare le notizie vecchie PRIMA\n if _articles_cache is None:\n print(\"📰 Caricamento iniziale: cerco notizie vecchie da caricare...\")\n\n def clean_html(text):\n \"\"\"Rimuove tutti i tag HTML dal testo\"\"\"\n if not text:\n return \"\"\n import html\n # Rimuove tutti i tag HTML\n text = re.sub(r'<[^>]+>', '', text)\n # Decodifica entità HTML (inclusi quelli numerici come &#8217;)\n try:\n text = html.unescape(text)\n except:\n # Fallback manuale se html.unescape non disponibile\n text = text.replace('&nbsp;', ' ')\n text = text.replace('&amp;', '&')\n text = text.replace('&lt;', '<')\n text = text.replace('&gt;', '>')\n text = text.replace('&quot;', '\"')\n text = text.replace('&#39;', \"'\")\n text = text.replace('&apos;', \"'\")\n # Decodifica entità numeriche comuni\n text = text.replace('&#8217;', \"'\") # apostrofo\n text = text.replace('&#8216;', \"'\") # apostrofo sinistro\n text = text.replace('&#8220;', '\"') # virgolette sinistre\n text = text.replace('&#8221;', '\"') # virgolette destre\n text = text.replace('&#8230;', '...') # tre puntini\n text = text.replace('&mdash;', '—') # dash\n text = text.replace('&ndash;', '–') # en dash\n # Rimuove spazi multipli\n text = re.sub(r'\\s+', ' ', text)\n return text.strip()\n\n # Determina il path base (directory corrente o backend/)\n # Su Vercel, i file sono nella root del progetto\n # Prova MOLTI più path per trovare i file JSON\n current_file_dir = os.path.dirname(os.path.abspath(__file__))\n project_root_from_file = os.path.dirname(current_file_dir) # backend/app -> backend\n project_root_from_file2 = os.path.dirname(project_root_from_file) # backend -> root\n\n possible_paths = [\n # Path 0: api/final_news_italian.json (PRIORITÀ - sempre incluso su Vercel)\n os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'api', 'final_news_italian.json'),\n os.path.join(os.getcwd(), 'api', 'final_news_italian.json'),\n '/vercel/path0/api/final_news_italian.json',\n # Path 1: backend/final_news_italian.json dalla root (Vercel standard)\n os.path.join(os.getcwd(), 'backend', 'final_news_italian.json'),\n # Path 2: backend/final_news_italian.json relativo al file corrente\n os.path.join(project_root_from_file, 'final_news_italian.json'),\n # Path 3: final_news_italian.json nella root\n os.path.join(os.getcwd(), 'final_news_italian.json'),\n # Path 4: backend/final_news_italian.json dalla directory del file (2 livelli su)\n os.path.join(project_root_from_file2, 'backend', 'final_news_italian.json'),\n # Path 5: final_news_italian.json nella root (2 livelli su)\n os.path.join(project_root_from_file2, 'final_news_italian.json'),\n # Path 6: Prova anche con /vercel/path0 (Vercel build path)\n '/vercel/path0/backend/final_news_italian.json',\n '/vercel/path0/final_news_italian.json',\n # Path 7: Prova anche nella directory corrente se siamo già in backend/\n os.path.join(current_file_dir, 'final_news_italian.json'),\n os.path.join(current_file_dir, '..', 'final_news_italian.json'),\n ]\n\n file_path = None\n for path in possible_paths:\n if os.path.exists(path):\n file_path = path\n print(f\"✅ Trovato final_news_italian.json: {path}\")\n break\n\n if not file_path:\n print(f\"❌ Nessun file final_news_italian.json trovato. Path provati:\")\n for path in possible_paths:\n exists = os.path.exists(path)\n print(f\" - {path} (exists: {exists})\")\n if exists:\n try:\n size = os.path.getsize(path)\n print(f\" Size: {size} bytes\")\n except:\n pass\n print(f\" Current working directory: {os.getcwd()}\")\n print(f\" File location: {__file__}\")\n print(f\" List files in backend/: {os.listdir(os.path.join(os.getcwd(), 'backend')) if os.path.exists(os.path.join(os.getcwd(), 'backend')) else 'backend/ does not exist'}\")\n\n # IMPORTANTE: Se non trova il file nuovo, prova a caricare le notizie vecchie dalla cache\n if _articles_cache is not None:\n print(f\"✅ File nuovo non trovato, uso notizie vecchie dalla cache: {len(_articles_cache)} articoli\")\n return _articles_cache\n\n print(\"❌ Nessun file trovato e nessuna cache disponibile - restituisco array vuoto\")\n return [] # Restituisci array vuoto invece di None\n if file_path and os.path.exists(file_path):\n try:\n # VERCEL FREE: Caricamento ottimizzato con timeout implicito\n # Usa lettura binaria per performance migliori\n import time\n start_load = time.time()\n\n with open(file_path, 'r', encoding='utf-8') as f:\n data = json.load(f)\n articles = data.get('items', [])\n\n load_time = time.time() - start_load\n print(f\"✅ Caricati {len(articles)} articoli da final_news_italian.json in {load_time:.2f}s\")\n\n # VERCEL FREE: Se il caricamento è troppo lento, avvisa\n if load_time > 5:\n print(f\"⚠️ Caricamento lento ({load_time:.2f}s) - considera ottimizzazioni\")\n\n # Mappa keywords → category_id per articoli esistenti senza category_id\n KEYWORD_TO_CATEGORY_ID = {\n 'technology': 1, 'tech': 1, 'tecnologia': 1,\n 'science': 2, 'scienz': 2,\n 'philosophy': 3, 'filosofia': 3,\n 'cybersecurity': 4, 'security': 4, 'sicurezza': 4,\n 'ai': 5, 'artificial intelligence': 5, 'intelligenza artificiale': 5,\n 'innovation': 6, 'innovazione': 6,\n 'culture': 7, 'cultura': 7,\n 'ethics': 8, 'etica': 8,\n 'sport': 9, 'calcio': 9, 'football': 9,\n 'nature': 10, 'ambiente': 10, 'environment': 10,\n 'business': 11, 'economia': 11, 'finance': 11,\n 'health': 12, 'salute': 12, 'medical': 12,\n 'politics': 13, 'politica': 13,\n 'entertainment': 14, 'intrattenimento': 14\n }\n\n # Pulisce HTML e aggiunge category_id se mancante\n for article in articles:\n if 'summary' in article:\n article['summary'] = clean_html(article['summary'])\n if 'title' in article:\n article['title'] = clean_html(article['title'])\n\n # Aggiungi category_id se mancante\n if 'category_id' not in article or article.get('category_id') is None:\n # Cerca nei keywords\n keywords = article.get('keywords', [])\n category_id = None\n for kw in keywords:\n kw_lower = str(kw).lower()\n for key, cat_id in KEYWORD_TO_CATEGORY_ID.items():\n if key in kw_lower:\n category_id = cat_id\n break\n if category_id:\n break\n\n # Se non trovato nei keywords, default a Technology\n if not category_id:\n category_id = 1\n\n article['category_id'] = category_id\n\n # Salva in cache\n _articles_cache = articles\n _cache_timestamp = os.path.getmtime(file_path)\n _cache_file_path = file_path\n print(f\"✅ Cache aggiornata: {len(articles)} articoli\")\n return articles\n except Exception as e:\n print(f\"❌ Errore caricamento final_news_italian.json: {e}\")\n import traceback\n traceback.print_exc()\n # Restituisci array vuoto invece di None per evitare errori downstream\n return []\n\n # Fallback su tutte le fonti (stessi path migliorati)\n possible_paths_all = [\n # Path 0: api/all_sources_news.json (PRIORITÀ - sempre incluso su Vercel)\n os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'api', 'all_sources_news.json'),\n os.path.join(os.getcwd(), 'api', 'all_sources_news.json'),\n '/vercel/path0/api/all_sources_news.json',\n os.path.join(os.getcwd(), 'backend', 'all_sources_news.json'),\n os.path.join(project_root_from_file, 'all_sources_news.json'),\n os.path.join(os.getcwd(), 'all_sources_news.json'),\n os.path.join(project_root_from_file2, 'backend', 'all_sources_news.json'),\n os.path.join(project_root_from_file2, 'all_sources_news.json'),\n '/vercel/path0/backend/all_sources_news.json',\n '/vercel/path0/all_sources_news.json',\n os.path.join(current_file_dir, 'all_sources_news.json'),\n os.path.join(current_file_dir, '..', 'all_sources_news.json'),\n ]\n\n file_path = None\n for path in possible_paths_all:\n if os.path.exists(path):\n file_path = path\n print(f\"✅ Trovato all_sources_news.json: {path}\")\n break\n\n if file_path and os.path.exists(file_path):\n try:\n with open(file_path, 'r', encoding='utf-8') as f:\n data = json.load(f)\n articles = data.get('items', [])\n # Pulisce HTML da tutte le notizie esistenti\n for article in articles:\n if 'summary' in article:\n article['summary'] = clean_html(article['summary'])\n if 'title' in article:\n article['title'] = clean_html(article['title'])\n # Salva in cache\n _articles_cache = articles\n _cache_timestamp = os.path.getmtime(file_path)\n _cache_file_path = file_path\n print(f\"✅ Cache aggiornata (all_sources): {len(articles)} articoli\")\n return articles\n except Exception as e:\n print(f\"Errore caricamento all_sources_news.json: {e}\")\n pass\n\n # Fallback: notizie embedded (le 12 migliori)\n return [\n {\n \"id\": 1,\n \"title\": \"Can OpenAI keep pace with industry's soaring costs?\",\n \"slug\": \"can-openai-keep-pace-with-industrys-soaring-costs\",\n \"url\": \"https://www.theguardian.com/technology/2025/nov/10/sam-altman-can-openai-profits-keep-pace\",\n \"summary\": \"As investor jitters grow, the loss-making ChatGPT firm's vast spending commitments test the limits of Silicon Valley optimism. It is the $1.4tn (£1.1tn) question. How can a loss-making startup such as OpenAI afford such a staggering spending commitment?\",\n \"author\": \"Dan Milmo\",\n \"published_at\": \"2025-11-11T00:00:00\",\n \"collected_at\": \"2025-11-11T00:00:00\",\n \"source_id\": 1,\n \"is_featured\": True,\n \"is_verified\": True,\n \"is_archived\": False,\n \"quality_score\": 0.75,\n \"reading_time_minutes\": 3,\n \"keywords\": [\"OpenAI\", \"costs\", \"AI\", \"business\"],\n \"language\": \"en\"\n },\n {\n \"id\": 2,\n \"title\": \"What we lose when we surrender care to algorithms | Eric Reinhart\",\n \"slug\": \"what-we-lose-when-we-surrender-care-to-algorithms\",\n \"url\": \"https://www.theguardian.com/us-news/ng-interactive/2025/nov/09/healthcare-artificial-intelligence-ai\",\n \"summary\": \"A dangerous faith in AI is sweeping American healthcare – with consequences for the basis of society itself. The computer interrupted while Pamela was still speaking. I had accompanied her to a recent doctor's appointment.\",\n \"author\": \"Eric Reinhart\",\n \"published_at\": \"2025-11-11T00:00:00\",\n \"collected_at\": \"2025-11-11T00:00:00\",\n \"source_id\": 1,\n \"is_featured\": False,\n \"is_verified\": True,\n \"is_archived\": False,\n \"quality_score\": 0.8,\n \"reading_time_minutes\": 4,\n \"keywords\": [\"healthcare\", \"AI\", \"algorithms\", \"society\"],\n \"language\": \"en\"\n },\n {\n \"id\": 3,\n \"title\": \"Social media misinformation driving men to seek unneeded NHS testosterone therapy\",\n \"slug\": \"social-media-misinformation-testosterone-therapy\",\n \"url\": \"https://www.theguardian.com/society/2025/nov/08/social-media-misinformation-driving-men-to-nhs-clinics\",\n \"summary\": \"Endocrinologists warn taking testosterone unnecessarily can suppress natural hormone production. Social media misinformation is driving men to NHS clinics in search of testosterone therapy they don't need.\",\n \"author\": \"Sarah Marsh\",\n \"published_at\": \"2025-11-11T00:00:00\",\n \"collected_at\": \"2025-11-11T00:00:00\",\n \"source_id\": 1,\n \"is_featured\": False,\n \"is_verified\": True,\n \"is_archived\": False,\n \"quality_score\": 0.85,\n \"reading_time_minutes\": 5,\n \"keywords\": [\"health\", \"misinformation\", \"testosterone\", \"NHS\"],\n \"language\": \"en\"\n },\n {\n \"id\": 4,\n \"title\": \"10mila Labubu contraffatti sequestrati a Palermo dalla Guardia di finanza\",\n \"slug\": \"labubu-contraffatti-sequestrati-palermo\",\n \"url\": \"https://www.wired.it/article/10mila-labubu-contraffatti-sequestrati-a-palermo-dalla-guardia-di-finanza/\",\n \"summary\": \"Da Labubu a lafufu, i peluche falsi erano venduti in sette negozi, uno dei quali in un centro commerciale. I commercianti denunciati rischiano accuse per vendita di prodotti con marchi contraffatti.\",\n \"author\": \"Riccardo Piccolo\",\n \"published_at\": \"2025-11-11T00:00:00\",\n \"collected_at\": \"2025-11-11T00:00:00\",\n \"source_id\": 1,\n \"is_featured\": True,\n \"is_verified\": True,\n \"is_archived\": False,\n \"quality_score\": 0.75,\n \"reading_time_minutes\": 3,\n \"keywords\": [\"Italia\", \"contraffazione\", \"Palermo\"],\n \"language\": \"it\"\n },\n {\n \"id\": 5,\n \"title\": \"Audi Q3 e-hybrid, 119 chilometri in solo elettrico con ricarica rapida\",\n \"slug\": \"audi-q3-e-hybrid-autonomia-elettrica\",\n \"url\": \"https://www.wired.it/article/nuova-audi-q3-e-hybrid-test-su-strada/\",\n \"summary\": \"Con un'autonomia elettrica record fino a 119 km, una ricarica rapida in corrente continua da 50 kW e una potenza complessiva di 272 Cv, definisce nuovi standard nel segmento dei Suv compatti premium.\",\n \"author\": \"Gabriele Nava\",\n \"published_at\": \"2025-11-11T00:00:00\",\n \"collected_at\": \"2025-11-11T00:00:00\",\n \"source_id\": 1,\n \"is_featured\": False,\n \"is_verified\": True,\n \"is_archived\": False,\n \"quality_score\": 0.8,\n \"reading_time_minutes\": 4,\n \"keywords\": [\"auto\", \"elettrico\", \"Audi\", \"tecnologia\"],\n \"language\": \"it\"\n },\n {\n \"id\": 6,\n \"title\": \"Apple rimuove app LGBTQ+ in Cina su richiesta del governo\",\n \"slug\": \"apple-rimuove-app-lgbtq-cina\",\n \"url\": \"https://www.wired.it/article/blued-finka-app-dating-lgbt-apple-rimozione-app-store-cina/\",\n \"summary\": \"Blued e Finka erano tra le piattaforme LGBTQ+ più popolari nel paese, dove la comunità è sempre di più nel mirino del regime di Pechino.\",\n \"author\": \"Zeyi Yang, Louise Matsakis\",\n \"published_at\": \"2025-11-11T00:00:00\",\n \"collected_at\": \"2025-11-11T00:00:00\",\n \"source_id\": 1,\n \"is_featured\": False,\n \"is_verified\": True,\n \"is_archived\": False,\n \"quality_score\": 0.85,\n \"reading_time_minutes\": 5,\n \"keywords\": [\"Apple\", \"Cina\", \"LGBTQ\", \"censura\"],\n \"language\": \"it\"\n },\n {\n \"id\": 7,\n \"title\": \"Hackers Exploiting Triofox Flaw to Install Remote Access Tools\",\n \"slug\": \"hackers-exploiting-triofox-flaw\",\n \"url\": \"https://thehackernews.com/2025/11/hackers-exploiting-triofox-flaw-to.html\",\n \"summary\": \"Google's Mandiant Threat Defense discovered n-day exploitation of a now-patched security flaw in Gladinet's Triofox file-sharing platform. The critical vulnerability (CVE-2025-12480, CVSS 9.1) allows attackers to bypass authentication.\",\n \"author\": \"The Hacker News\",\n \"published_at\": \"2025-11-11T00:00:00\",\n \"collected_at\": \"2025-11-11T00:00:00\",\n \"source_id\": 1,\n \"is_featured\": True,\n \"is_verified\": True,\n \"is_archived\": False,\n \"quality_score\": 0.75,\n \"reading_time_minutes\": 3,\n \"keywords\": [\"cybersecurity\", \"exploit\", \"Triofox\", \"hacking\"],\n \"language\": \"en\"\n },\n {\n \"id\": 8,\n \"title\": \"Konni Hackers Turn Google's Find Hub into Remote Data-Wiping Weapon\",\n \"slug\": \"konni-hackers-google-find-hub\",\n \"url\": \"https://thehackernews.com/2025/11/konni-hackers-turn-googles-find-hub.html\",\n \"summary\": \"The North Korea-affiliated threat actor Konni has been attributed to new attacks targeting Android and Windows devices for data theft and remote control. Attackers impersonated psychological counselors and North Korean human rights activists.\",\n \"author\": \"The Hacker News\",\n \"published_at\": \"2025-11-11T00:00:00\",\n \"collected_at\": \"2025-11-11T00:00:00\",\n \"source_id\": 1,\n \"is_featured\": False,\n \"is_verified\": True,\n \"is_archived\": False,\n \"quality_score\": 0.8,\n \"reading_time_minutes\": 4,\n \"keywords\": [\"North Korea\", \"hacking\", \"Google\", \"cybersecurity\"],\n \"language\": \"en\"\n },\n {\n \"id\": 9,\n \"title\": \"Weekly Recap: Hyper-V Malware, Malicious AI Bots, RDP Exploits\",\n \"slug\": \"weekly-recap-hyper-v-malware-ai-bots\",\n \"url\": \"https://thehackernews.com/2025/11/weekly-recap-hyper-v-malware-malicious.html\",\n \"summary\": \"Cyber threats didn't slow down last week—and attackers are getting smarter. We're seeing malware hidden in virtual machines, side-channel leaks exposing AI chats, and spyware quietly targeting Android devices in the wild.\",\n \"author\": \"The Hacker News\",\n \"published_at\": \"2025-11-11T00:00:00\",\n \"collected_at\": \"2025-11-11T00:00:00\",\n \"source_id\": 1,\n \"is_featured\": False,\n \"is_verified\": True,\n \"is_archived\": False,\n \"quality_score\": 0.85,\n \"reading_time_minutes\": 5,\n \"keywords\": [\"malware\", \"AI\", \"security\", \"recap\"],\n \"language\": \"en\"\n },\n {\n \"id\": 10,\n \"title\": \"Fire TV Stick: blocco delle app pirata anche in Italia\",\n \"slug\": \"fire-tv-stick-blocco-app-pirata-italia\",\n \"url\": \"https://www.punto-informatico.it/fire-tv-stick-blocco-app-pirata-italia/\",\n \"summary\": \"Amazon inizia a mostrare gli avvisi agli utenti delle app pirata su Fire TV Stick anche in Italia, poi darà il via ai blocchi definitivi.\",\n \"author\": \"Cristiano Ghidotti\",\n \"published_at\": \"2025-11-11T00:00:00\",\n \"collected_at\": \"2025-11-11T00:00:00\",\n \"source_id\": 1,\n \"is_featured\": True,\n \"is_verified\": True,\n \"is_archived\": False,\n \"quality_score\": 0.75,\n \"reading_time_minutes\": 3,\n \"keywords\": [\"Amazon\", \"Fire TV\", \"pirateria\", \"Italia\"],\n \"language\": \"it\"\n },\n {\n \"id\": 11,\n \"title\": \"ExpressVPN in offerta: streaming, viaggi e privacy totale\",\n \"slug\": \"expressvpn-offerta-streaming-privacy\",\n \"url\": \"https://www.punto-informatico.it/streaming-viaggi-e-privacy-totale-expressvpn-in-offerta/\",\n \"summary\": \"Naviga in modo sicuro e veloce con ExpressVPN: ora il piano biennale è scontato del 67% e include 4 mesi extra gratis per una protezione completa.\",\n \"author\": \"Eleonora Busi\",\n \"published_at\": \"2025-11-11T00:00:00\",\n \"collected_at\": \"2025-11-11T00:00:00\",\n \"source_id\": 1,\n \"is_featured\": False,\n \"is_verified\": True,\n \"is_archived\": False,\n \"quality_score\": 0.7,\n \"reading_time_minutes\": 4,\n \"keywords\": [\"VPN\", \"privacy\", \"sicurezza\", \"offerta\"],\n \"language\": \"it\"\n },\n {\n \"id\": 12,\n \"title\": \"Hostinger Black Friday: sito online a meno di 2€ al mese\",\n \"slug\": \"hostinger-black-friday-hosting-economico\",\n \"url\": \"https://www.punto-informatico.it/hostinger-sconti-black-friday/\",\n \"summary\": \"Hosting super scontato per il Black Friday: sconti fino all'85% sui piani Hostinger, con dominio incluso e 3 mesi gratis. Ideale per siti web, blog e e-commerce.\",\n \"author\": \"Eleonora Busi\",\n \"published_at\": \"2025-11-11T00:00:00\",\n \"collected_at\": \"2025-11-11T00:00:00\",\n \"source_id\": 1,\n \"is_featured\": False,\n \"is_verified\": True,\n \"is_archived\": False,\n \"quality_score\": 0.7,\n \"reading_time_minutes\": 5,\n \"keywords\": [\"hosting\", \"Hostinger\", \"Black Friday\", \"sconti\"],\n \"language\": \"it\"\n }\n ]\n\n\n@app.get(\"/api/v1/articles\")\ndef get_articles(category_id: int = None, skip: int = 0, limit: int = 50):\n \"\"\"Get articles - REAL NEWS from RSS feeds - WITH CATEGORY FILTER AND PAGINATION\n Limit aumentato a 50 per permettere più articoli per pagina\n \"\"\"\n try:\n # Limita il limite massimo a 200 per evitare problemi di performance\n limit = min(limit, 200)\n\n # Debug: verifica file prima di caricare\n import os\n debug_paths = [\n os.path.join(os.getcwd(), 'api', 'final_news_italian.json'),\n os.path.join(os.getcwd(), 'backend', 'final_news_italian.json'),\n os.path.join(os.getcwd(), 'final_news_italian.json'),\n ]\n print(f\"DEBUG get_articles: cwd={os.getcwd()}\")\n for path in debug_paths:\n exists = os.path.exists(path)\n print(f\"DEBUG get_articles: {path} exists={exists}\")\n if exists:\n try:\n size = os.path.getsize(path)\n print(f\"DEBUG get_articles: {path} size={size}\")\n except:\n pass\n\n articles = _load_articles()\n\n # Se non ci sono articoli, restituisci array vuoto invece di causare errore\n if not articles:\n print(\"⚠️ Nessun articolo caricato, restituisco array vuoto\")\n return {\n \"items\": [],\n \"total\": 0,\n \"page\": 1,\n \"size\": 0,\n \"pages\": 1\n }\n\n # Mappa categorie → keywords da cercare\n CATEGORY_KEYWORDS = {\n 1: [\"technology\", \"tech\", \"tecnologia\", \"computer\", \"software\", \"hardware\", \"digital\"], # Technology\n 2: [\"science\", \"scienz\", \"research\", \"ricerca\", \"studio\", \"arxiv\"], # Science\n 3: [\"philosophy\", \"filosofia\", \"pensiero\", \"critica\"], # Philosophy\n 4: [\"cybersecurity\", \"security\", \"sicurezza\", \"hacking\", \"exploit\", \"malware\", \"cyber\"], # Cybersecurity\n 5: [\"ai\", \"artificial intelligence\", \"intelligenza artificiale\", \"machine learning\", \"gpt\", \"openai\", \"llm\"], # AI\n 6: [\"innovation\", \"innovazione\", \"futuro\", \"new\"], # Innovation\n 7: [\"culture\", \"cultura\", \"arte\", \"society\", \"società\"], # Culture\n 8: [\"ethics\", \"etica\", \"morale\", \"diritti\"], # Ethics\n 9: [\"sport\", \"calcio\", \"football\", \"soccer\", \"tennis\", \"basketball\", \"sports\"], # Sport\n 10: [\"nature\", \"ambiente\", \"environment\", \"climate\", \"clima\", \"green\", \"ecologia\"], # Nature\n 11: [\"business\", \"economia\", \"finance\", \"finanza\", \"market\", \"mercato\", \"company\", \"azienda\"], # Business\n 12: [\"health\", \"salute\", \"medical\", \"medico\", \"hospital\", \"ospedale\", \"medicine\"], # Health\n 13: [\"politics\", \"politica\", \"government\", \"governo\", \"election\", \"elezioni\", \"parliament\"], # Politics\n 14: [\"entertainment\", \"intrattenimento\", \"movie\", \"film\", \"cinema\", \"music\", \"musica\", \"tv\", \"show\"] # Entertainment\n }\n\n if articles:\n # Filtra per categoria se richiesto\n if category_id and category_id in CATEGORY_KEYWORDS:\n cat_keywords = CATEGORY_KEYWORDS[category_id]\n filtered = []\n\n for article in articles:\n # Cerca match nei keywords dell'articolo\n article_keywords = [k.lower() for k in article.get('keywords', [])]\n article_title = article.get('title', '').lower()\n article_summary = article.get('summary', '').lower()\n\n # Match se almeno una keyword della categoria è presente\n for cat_kw in cat_keywords:\n if any(cat_kw in akw for akw in article_keywords) or \\\n cat_kw in article_title or \\\n cat_kw in article_summary:\n filtered.append(article)\n break\n\n articles = filtered\n\n # Ordina: prima articoli con immagini, poi senza\n # Usa una chiave di ordinamento: 0 se ha immagine, 1 se non ha\n articles.sort(key=lambda x: (0 if x.get('image_url') else 1, -x.get('quality_score', 0)))\n\n # Applica paginazione\n total = len(articles)\n paginated_articles = articles[skip:skip + limit]\n\n return {\n \"items\": paginated_articles,\n \"total\": total,\n \"page\": (skip // limit) + 1,\n \"size\": len(paginated_articles),\n \"pages\": (total + limit - 1) // limit if limit > 0 else 1\n }\n\n # Fallback se non ci sono articoli dopo il filtro\n return {\n \"items\": [],\n \"total\": 0,\n \"page\": 1,\n \"size\": 0,\n \"pages\": 1\n }\n except Exception as e:\n print(f\"❌ Errore in get_articles: {e}\")\n import traceback\n traceback.print_exc()\n # Restituisci risposta vuota invece di causare 500\n return {\n \"items\": [],\n \"total\": 0,\n \"page\": 1,\n \"size\": 0,\n \"pages\": 1,\n \"error\": str(e)\n }\n\n\n@app.get(\"/api/v1/categories\")\ndef get_categories():\n \"\"\"Get categories - demo data\"\"\"\n return [\n {\"id\": 1, \"name\": \"Technology\", \"slug\": \"technology\", \"icon\": \"computer\", \"color\": \"#2196F3\"},\n {\"id\": 2, \"name\": \"Science\", \"slug\": \"science\", \"icon\": \"science\", \"color\": \"#4CAF50\"},\n {\"id\": 3, \"name\": \"Philosophy\", \"slug\": \"philosophy\", \"icon\": \"psychology\", \"color\": \"#9C27B0\"},\n {\"id\": 4, \"name\": \"Cybersecurity\", \"slug\": \"cybersecurity\", \"icon\": \"security\", \"color\": \"#F44336\"},\n {\"id\": 5, \"name\": \"AI\", \"slug\": \"ai\", \"icon\": \"smart_toy\", \"color\": \"#FF9800\"},\n {\"id\": 6, \"name\": \"Innovation\", \"slug\": \"innovation\", \"icon\": \"lightbulb\", \"color\": \"#FFEB3B\"},\n {\"id\": 7, \"name\": \"Culture\", \"slug\": \"culture\", \"icon\": \"palette\", \"color\": \"#E91E63\"},\n {\"id\": 8, \"name\": \"Ethics\", \"slug\": \"ethics\", \"icon\": \"balance\", \"color\": \"#607D8B\"},\n {\"id\": 9, \"name\": \"Sport\", \"slug\": \"sport\", \"icon\": \"sports_soccer\", \"color\": \"#FF5722\"},\n {\"id\": 10, \"name\": \"Nature\", \"slug\": \"nature\", \"icon\": \"park\", \"color\": \"#4CAF50\"},\n {\"id\": 11, \"name\": \"Business\", \"slug\": \"business\", \"icon\": \"business_center\", \"color\": \"#1976D2\"},\n {\"id\": 12, \"name\": \"Health\", \"slug\": \"health\", \"icon\": \"health_and_safety\", \"color\": \"#E91E63\"},\n {\"id\": 13, \"name\": \"Politics\", \"slug\": \"politics\", \"icon\": \"gavel\", \"color\": \"#9C27B0\"},\n {\"id\": 14, \"name\": \"Entertainment\", \"slug\": \"entertainment\", \"icon\": \"movie\", \"color\": \"#FF9800\"}\n ]\n\n\n@app.get(\"/api/v1/articles/{article_id}\")\ndef get_article(article_id: int):\n \"\"\"Get single article by ID\"\"\"\n articles = _load_articles()\n\n for article in articles:\n if article.get('id') == article_id:\n return article\n\n return {\"error\": \"Article not found\"}\n\n\n@app.get(\"/api/v1/articles/slug/{slug}\")\ndef get_article_by_slug(slug: str):\n \"\"\"Get single article by slug\"\"\"\n articles = _load_articles()\n\n for article in articles:\n if article.get('slug') == slug:\n return article\n\n return {\"error\": \"Article not found\"}\n\n\ndef _get_ai_service_used() -> str:\n \"\"\"Determina quale servizio AI è disponibile\"\"\"\n import os\n import requests\n\n # Controlla Ollama (locale) - PRIMA SCELTA\n try:\n response = requests.get(f\"{os.getenv('OLLAMA_URL', 'http://localhost:11434')}/api/tags\", timeout=2)\n if response.status_code == 200:\n models = response.json().get('models', [])\n model_names = [m.get('name', '') for m in models]\n return f\"Ollama (Gratuito) - Modelli: {', '.join(model_names[:3])}\"\n except:\n pass\n\n # Controlla AI Locale Integrata\n try:\n from app.local_ai_explainer import TRANSFORMERS_AVAILABLE, TORCH_AVAILABLE\n if TRANSFORMERS_AVAILABLE and TORCH_AVAILABLE:\n return \"AI Locale Integrata (T5/GPT-2, Offline)\"\n except:\n pass\n\n # Controlla Hugging Face\n if os.getenv(\"HUGGINGFACE_API_KEY\"):\n return \"Hugging Face (Gratuito)\"\n\n # Controlla DeepSeek\n if os.getenv(\"DEEPSEEK_API_KEY\"):\n return \"DeepSeek (Gratuito)\"\n\n # Controlla ChatGPT\n if os.getenv(\"OPENAI_API_KEY\"):\n return \"ChatGPT\"\n\n return \"Static (Nessuna AI configurata)\"\n\n\nclass ExplanationRequest(BaseModel):\n article_id: Optional[int] = None\n slug: Optional[str] = None\n explanation_type: str = \"quick\" # \"quick\", \"standard\", \"deep\"\n\n\n# Cache semplice per spiegazioni (evita rigenerazioni)\n_explanation_cache = {}\n\n@app.post(\"/api/v1/articles/explain\")\ndef explain_article(request: ExplanationRequest):\n \"\"\"\n Restituisce spiegazione AI dell'articolo (già generata durante collect-news)\n Se non esiste, la genera al volo (fallback)\n\n Args:\n request: ExplanationRequest con article_id o slug e explanation_type\n\n Returns:\n Spiegazione già generata o generata al volo\n \"\"\"\n import time\n start_time = time.time()\n\n articles = _load_articles()\n\n # Trova l'articolo\n article = None\n if request.article_id:\n for a in articles:\n if a.get('id') == request.article_id:\n article = a\n break\n elif request.slug:\n for a in articles:\n if a.get('slug') == request.slug:\n article = a\n break\n\n if not article:\n return {\"error\": \"Article not found\"}\n\n # Mappa explanation_type ai campi nel JSON\n explanation_field_map = {\n \"quick\": \"explanation_quick\",\n \"standard\": \"explanation_standard\",\n \"deep\": \"explanation_deep\"\n }\n\n explanation_field = explanation_field_map.get(request.explanation_type, \"explanation_standard\")\n\n # Controlla se la spiegazione esiste già nel JSON (già generata durante collect-news)\n if explanation_field in article and article[explanation_field]:\n print(f\"✅ Spiegazione già presente nel JSON per articolo {article.get('id')} (tipo: {request.explanation_type})\")\n return {\n \"success\": True,\n \"article_id\": article.get('id'),\n \"article_title\": article.get('title'),\n \"explanation_type\": request.explanation_type,\n \"explanation\": article[explanation_field],\n \"ai_used\": \"Pre-generated (during collect-news)\",\n \"cached\": True,\n \"generation_time\": 0,\n \"pre_generated\": True\n }\n\n # VERCEL FREE VERSION: Non genera al volo, solo lettura da JSON\n # Le spiegazioni devono essere generate sul backend PC/Render\n print(f\"⚠️ Spiegazione non trovata nel JSON per articolo {article.get('id')}\")\n print(f\"💡 Genera le spiegazioni sul backend PC/Render usando POST /api/admin/generate-explanations\")\n\n return {\n \"success\": False,\n \"error\": \"Explanation not available\",\n \"message\": f\"Spiegazione '{request.explanation_type}' non disponibile per questo articolo. Genera le spiegazioni sul backend PC/Render usando POST /api/admin/generate-explanations\",\n \"article_id\": article.get('id'),\n \"article_title\": article.get('title'),\n \"explanation_type\": request.explanation_type,\n \"note\": \"Questo è il backend Vercel (read-only). Per generare spiegazioni, usa il backend PC/Render.\"\n }\n\n # NOTA: La generazione AI è stata rimossa per Vercel Free Plan.\n # Le spiegazioni devono essere generate sul backend PC/Render e salvate nel JSON.\n\n\n@app.get(\"/api/v1/articles/featured/list\")\ndef get_featured_articles(limit: int = 10):\n \"\"\"Get featured articles\"\"\"\n articles = _load_articles()\n\n # Filtra solo gli articoli in evidenza\n featured = [a for a in articles if a.get('is_featured', False)]\n\n # Ordina: prima articoli con immagini, poi per quality score (decrescente)\n featured.sort(key=lambda x: (0 if x.get('image_url') else 1, -x.get('quality_score', 0)))\n\n # Limita il numero\n return featured[:limit]\n\n\n@app.get(\"/api/v1/articles/recent/list\")\ndef get_recent_articles(days: int = 7, limit: int = 20):\n \"\"\"Get recent articles\"\"\"\n articles = _load_articles()\n\n # Ordina: prima articoli con immagini, poi per data pubblicazione (più recenti prima)\n articles.sort(key=lambda x: (0 if x.get('image_url') else 1, x.get('published_at', '')), reverse=True)\n\n # Limita il numero\n return articles[:limit]\n\n\n@app.post(\"/api/v1/articles/search\")\ndef search_articles(query: str = \"\", category_id: int = None, language: str = \"\"):\n \"\"\"Search articles by query - FULL TEXT SEARCH\"\"\"\n articles = _load_articles()\n\n if not articles:\n return {\n \"items\": [],\n \"total\": 0,\n \"page\": 1,\n \"size\": 0,\n \"pages\": 1\n }\n\n results = []\n query_lower = query.lower() if query else \"\"\n\n for article in articles:\n # Se c'è una query, cerca nel titolo, sommario e keywords\n if query_lower:\n title = article.get('title', '').lower()\n summary = article.get('summary', '').lower()\n keywords = ' '.join(article.get('keywords', [])).lower()\n\n # Match se la query è nel titolo, sommario o keywords\n if query_lower not in title and \\\n query_lower not in summary and \\\n query_lower not in keywords:\n continue\n\n # Filtra per categoria se richiesto\n if category_id:\n # Usa la stessa logica del filtro categorie\n CATEGORY_KEYWORDS = {\n 1: [\"technology\", \"tech\", \"tecnologia\", \"ai\", \"computer\", \"software\", \"hardware\", \"digital\"],\n 2: [\"science\", \"scienz\", \"research\", \"ricerca\", \"studio\"],\n 3: [\"philosophy\", \"filosofia\", \"pensiero\", \"critica\"],\n 4: [\"cybersecurity\", \"security\", \"sicurezza\", \"hacking\", \"exploit\", \"malware\", \"cyber\"],\n 5: [\"ai\", \"artificial intelligence\", \"intelligenza artificiale\", \"machine learning\", \"gpt\", \"openai\", \"llm\"],\n 6: [\"innovation\", \"innovazione\", \"futuro\", \"new\"],\n 7: [\"culture\", \"cultura\", \"arte\", \"society\", \"società\"],\n 8: [\"ethics\", \"etica\", \"morale\", \"diritti\"]\n }\n\n if category_id in CATEGORY_KEYWORDS:\n cat_keywords = CATEGORY_KEYWORDS[category_id]\n article_keywords = [k.lower() for k in article.get('keywords', [])]\n article_title = article.get('title', '').lower()\n article_summary = article.get('summary', '').lower()\n\n match = False\n for cat_kw in cat_keywords:\n if any(cat_kw in akw for akw in article_keywords) or \\\n cat_kw in article_title or \\\n cat_kw in article_summary:\n match = True\n break\n\n if not match:\n continue\n\n # Filtra per lingua se richiesto\n if language and article.get('language', '') != language:\n continue\n\n results.append(article)\n\n return {\n \"items\": results,\n \"total\": len(results),\n \"page\": 1,\n \"size\": len(results),\n \"pages\": 1\n }\n\n\n@app.get(\"/api/v1/sources\")\ndef get_sources():\n \"\"\"Get sources - demo data\"\"\"\n return [\n {\n \"id\": 1,\n \"name\": \"SINTESI News\",\n \"slug\": \"sintesi\",\n \"url\": \"https://newsflow-orcin.vercel.app\",\n \"source_type\": \"rss\",\n \"language\": \"it\",\n \"is_active\": True,\n \"is_verified\": True\n }\n ]\n\n\ndef _load_digest():\n \"\"\"Helper to load daily digest from JSON file\"\"\"\n import json\n import os\n from datetime import datetime\n\n # Prova diversi path per trovare il file digest.json\n current_file_dir = os.path.dirname(os.path.abspath(__file__))\n project_root_from_file = os.path.dirname(current_file_dir)\n\n possible_paths = [\n # Path 0: api/digest.json (Vercel)\n os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'api', 'digest.json'),\n os.path.join(os.getcwd(), 'api', 'digest.json'),\n '/vercel/path0/api/digest.json',\n # Path 1: backend/digest.json\n os.path.join(os.getcwd(), 'backend', 'digest.json'),\n os.path.join(project_root_from_file, 'digest.json'),\n # Path 2: digest.json nella root\n os.path.join(os.getcwd(), 'digest.json'),\n # Path 3: backend/app/digest.json\n os.path.join(current_file_dir, 'digest.json'),\n ]\n\n for path in possible_paths:\n if os.path.exists(path):\n try:\n with open(path, 'r', encoding='utf-8') as f:\n digest_data = json.load(f)\n print(f\"✅ Digest caricato da: {path}\")\n return digest_data\n except Exception as e:\n print(f\"⚠️ Errore nel caricamento digest da {path}: {e}\")\n continue\n\n # Se non trova il file, genera un digest vuoto con la data di oggi\n print(\"⚠️ File digest.json non trovato, genero digest vuoto\")\n today = datetime.now().strftime(\"%Y-%m-%d\")\n return {\n \"date\": today,\n \"digest\": []\n }\n\n\n@app.get(\"/api/v1/digest\")\ndef get_digest():\n \"\"\"Get daily digest\"\"\"\n try:\n digest = _load_digest()\n return digest\n except Exception as e:\n print(f\"❌ Errore nel caricamento digest: {e}\")\n import traceback\n traceback.print_exc()\n # Ritorna digest vuoto in caso di errore\n from datetime import datetime\n return {\n \"date\": datetime.now().strftime(\"%Y-%m-%d\"),\n \"digest\": []\n }\n\n\n@app.get(\"/api/v1/digest/{date}\")\ndef get_digest_by_date(date: str):\n \"\"\"Get digest for a specific date\"\"\"\n try:\n digest = _load_digest()\n # Se il digest caricato corrisponde alla data richiesta, ritornalo\n if digest.get(\"date\") == date:\n return digest\n # Altrimenti ritorna digest vuoto per quella data\n return {\n \"date\": date,\n \"digest\": []\n }\n except Exception as e:\n print(f\"❌ Errore nel caricamento digest per data {date}: {e}\")\n return {\n \"date\": date,\n \"digest\": []\n }\n\n\n@app.get(\"/api/v1/auth/whoami\")\ndef whoami(request: Request):\n \"\"\"\n Autenticazione automatica con fingerprint\n Zero friction - 100% conversion!\n\n Returns user data (esistente o nuovo)\n \"\"\"\n # Genera fingerprint da request\n if FingerprintAuth is None:\n # Fallback se FingerprintAuth non disponibile\n return {\n \"success\": False,\n \"error\": \"Authentication service not available\"\n }\n\n request_data = {\n 'ip': request.client.host if request.client else 'unknown',\n 'user_agent': request.headers.get('user-agent', 'unknown'),\n 'accept_language': request.headers.get('accept-language', 'unknown'),\n 'accept_encoding': request.headers.get('accept-encoding', 'unknown')\n }\n\n fingerprint = FingerprintAuth.generate_fingerprint(request_data)\n\n # Carica o crea file users (localStorage-like server-side)\n users_file = 'users_db.json'\n\n try:\n with open(users_file, 'r', encoding='utf-8') as f:\n users = json.load(f)\n except:\n users = {}\n\n # Cerca utente per fingerprint\n if fingerprint in users:\n # Utente esistente!\n user = users[fingerprint]\n user['is_new'] = False\n\n # Aggiorna last_seen\n from datetime import datetime\n user['last_seen'] = datetime.utcnow().isoformat()\n\n # Salva\n with open(users_file, 'w', encoding='utf-8') as f:\n json.dump(users, f, indent=2, ensure_ascii=False)\n\n return {\n \"success\": True,\n \"authenticated\": True,\n \"user\": user\n }\n\n # Nuovo utente - crea!\n user_data = FingerprintAuth.create_user_data(fingerprint)\n user_data['id'] = len(users) + 1\n user_data['is_new'] = True\n\n # Salva\n users[fingerprint] = user_data\n\n with open(users_file, 'w', encoding='utf-8') as f:\n json.dump(users, f, indent=2, ensure_ascii=False)\n\n return {\n \"success\": True,\n \"authenticated\": True,\n \"user\": user_data,\n \"message\": f\"Benvenuto {user_data['name']}! 🎉\"\n }\n\n\n@app.post(\"/api/admin/generate-explanations\")\ndef trigger_explanations_generation():\n \"\"\"\n Endpoint per generare spiegazioni AI per articoli esistenti.\n Da chiamare DOPO collect-news per completare il processo.\n \"\"\"\n import json\n import os\n from datetime import datetime\n\n try:\n print(\"🤖 Generazione spiegazioni AI per articoli esistenti...\")\n\n # Carica articoli esistenti\n existing_file_path = None\n for path in [\n 'final_news_italian.json',\n os.path.join('backend', 'final_news_italian.json'),\n os.path.join('api', 'final_news_italian.json')\n ]:\n if os.path.exists(path):\n existing_file_path = path\n break\n\n if not existing_file_path:\n return {\n \"success\": False,\n \"error\": \"File final_news_italian.json non trovato. Esegui prima /api/admin/collect-news\"\n }\n\n with open(existing_file_path, 'r', encoding='utf-8') as f:\n data = json.load(f)\n articles = data.get('items', [])\n\n print(f\"📚 Caricati {len(articles)} articoli da processare\")\n\n # Genera spiegazioni solo per articoli senza spiegazioni\n try:\n from app.ai_explainer import generate_explanation\n\n articles_needing_explanations = []\n for article in articles:\n if not article.get('explanation_quick'):\n articles_needing_explanations.append(article)\n\n print(f\"📊 Articoli senza spiegazioni: {len(articles_needing_explanations)}\")\n print(f\"⏭️ Articoli con spiegazioni già presenti: {len(articles) - len(articles_needing_explanations)}\")\n\n explanations_generated = 0\n\n # Genera spiegazioni solo per quelli senza\n # Limita a max 50 articoli per volta per evitare timeout\n max_articles_to_process = min(50, len(articles_needing_explanations))\n articles_to_process = articles_needing_explanations[:max_articles_to_process]\n\n print(f\"📝 Processerò {max_articles_to_process} articoli (su {len(articles_needing_explanations)} totali)\")\n\n for i, article in enumerate(articles_to_process):\n try:\n print(f\" [{i+1}/{max_articles_to_process}] {article.get('title', '')[:60]}...\")\n\n # Genera spiegazioni AI\n article['explanation_quick'] = generate_explanation(article, 'quick')\n article['explanation_standard'] = generate_explanation(article, 'standard')\n article['explanation_deep'] = generate_explanation(article, 'deep')\n\n explanations_generated += 1\n print(f\" ✅ Completato\")\n except Exception as e:\n print(f\" ⚠️ Errore: {e}\")\n continue\n\n # Salva articoli aggiornati\n data['items'] = articles\n data['updated_at'] = datetime.now().isoformat()\n\n with open(existing_file_path, 'w', encoding='utf-8') as f:\n json.dump(data, f, indent=2, ensure_ascii=False)\n\n print(f\"✅ Spiegazioni AI generate: {explanations_generated} articoli\")\n print(f\"✅ File aggiornato: {existing_file_path}\")\n\n return {\n \"success\": True,\n \"message\": f\"Generated explanations for {explanations_generated} articles\",\n \"explanations_generated\": explanations_generated,\n \"total_articles\": len(articles),\n \"articles_with_explanations\": len(articles_needing_explanations),\n \"updated_at\": datetime.now().isoformat()\n }\n\n except ImportError:\n return {\n \"success\": False,\n \"error\": \"AI explainer module not available\",\n \"hint\": \"Installa le dipendenze AI necessarie\"\n }\n except Exception as e:\n return {\n \"success\": False,\n \"error\": str(e)\n }\n\n except Exception as e:\n print(f\"❌ Errore generazione spiegazioni: {e}\")\n import traceback\n traceback.print_exc()\n return {\n \"success\": False,\n \"error\": str(e)\n }\n\n\n@app.post(\"/api/admin/collect-news\")\ndef trigger_news_collection():\n \"\"\"\n Endpoint per raccogliere nuovi articoli AUTOMATICAMENTE.\n Può essere chiamato da un CRON job gratuito ogni 4 ore.\n\n Usa cron-job.org (gratuito) per chiamare questo endpoint:\n URL: https://newsflow-backend-v2.onrender.com/api/admin/collect-news\n Frequenza: Ogni 4 ore\n \"\"\"\n import feedparser\n import json\n import os\n import re\n from datetime import datetime\n\n def clean_html(text):\n \"\"\"Rimuove tutti i tag HTML dal testo\"\"\"\n if not text:\n return \"\"\n import html\n # Rimuove tutti i tag HTML\n text = re.sub(r'<[^>]+>', '', text)\n # Decodifica entità HTML (inclusi quelli numerici come &#8217;)\n try:\n text = html.unescape(text)\n except:\n # Fallback manuale se html.unescape non disponibile\n text = text.replace('&nbsp;', ' ')\n text = text.replace('&amp;', '&')\n text = text.replace('&lt;', '<')\n text = text.replace('&gt;', '>')\n text = text.replace('&quot;', '\"')\n text = text.replace('&#39;', \"'\")\n text = text.replace('&apos;', \"'\")\n # Decodifica entità numeriche comuni\n text = text.replace('&#8217;', \"'\") # apostrofo\n text = text.replace('&#8216;', \"'\") # apostrofo sinistro\n text = text.replace('&#8220;', '\"') # virgolette sinistre\n text = text.replace('&#8221;', '\"') # virgolette destre\n text = text.replace('&#8230;', '...') # tre puntini\n text = text.replace('&mdash;', '—') # dash\n text = text.replace('&ndash;', '–') # en dash\n # Rimuove spazi multipli\n text = re.sub(r'\\s+', ' ', text)\n return text.strip()\n\n try:\n print(\"🔄 Aggiornamento automatico notizie iniziato...\")\n\n # Importa traduttore (opzionale, se disponibile)\n try:\n from deep_translator import GoogleTranslator\n translator = GoogleTranslator(source='en', target='it')\n translation_available = True\n except ImportError:\n print(\"⚠️ deep_translator non disponibile - traduzione disabilitata\")\n translation_available = False\n translator = None\n\n # Fonti RSS - ESPANSE per tutte le categorie\n RSS_SOURCES = {\n # Tecnologia - Italia\n 'Wired IT': 'https://www.wired.it/feed/rss',\n 'Punto Informatico': 'https://www.punto-informatico.it/feed/',\n 'Agenda Digitale': 'https://www.agendadigitale.eu/feed/',\n 'DDay.it': 'https://www.dday.it/rss',\n 'Tom\\'s Hardware Italia': 'https://www.tomshw.it/feed',\n 'StartupItalia': 'https://startupitalia.eu/feed/',\n 'Forbes Italia': 'https://www.forbes.it/feed/',\n\n # Tecnologia - Internazionale\n 'MIT Technology Review': 'https://www.technologyreview.com/feed/',\n 'The Guardian Tech': 'https://www.theguardian.com/technology/rss',\n 'The Hacker News': 'https://feeds.feedburner.com/TheHackersNews',\n 'TechCrunch': 'https://techcrunch.com/feed/',\n 'Ars Technica': 'https://feeds.arstechnica.com/arstechnica/index',\n 'The Verge': 'https://www.theverge.com/rss/index.xml',\n 'CNET': 'https://www.cnet.com/rss/news/',\n 'Engadget': 'https://www.engadget.com/rss.xml',\n 'VentureBeat': 'https://venturebeat.com/feed/',\n 'ZDNet': 'https://www.zdnet.com/news/rss.xml',\n\n # Scienza\n 'ArXiv CS': 'http://export.arxiv.org/rss/cs',\n 'Science Daily': 'https://www.sciencedaily.com/rss/all.xml',\n 'Nature': 'https://www.nature.com/nature.rss',\n 'Scientific American': 'https://rss.sciam.com/ScientificAmerican-News',\n\n # Filosofia\n 'MicroMega': 'https://www.micromega.net/feed/',\n\n # Cybersecurity\n 'ICT Security Magazine': 'https://www.ictsecuritymagazine.com/feed/',\n 'Krebs on Security': 'https://krebsonsecurity.com/feed/',\n 'Bleeping Computer': 'https://www.bleepingcomputer.com/feed/',\n\n # Business - Italia\n 'AI4Business': 'https://www.ai4business.it/feed/',\n 'Il Sole 24 Ore': 'https://www.ilsole24ore.com/rss/home.xml',\n 'Repubblica Economia': 'https://www.repubblica.it/rss/home/rss2.0.xml',\n\n # Business - Internazionale\n 'The Guardian Business': 'https://www.theguardian.com/business/rss',\n 'Bloomberg': 'https://www.bloomberg.com/feed/topics/technology',\n 'Financial Times Tech': 'https://www.ft.com/technology?format=rss',\n\n # Notizie Generali - Italia\n 'ANSA': 'https://www.ansa.it/sito/notizie/mondo/mondo_rss.xml',\n 'Il Post': 'https://www.ilpost.it/feed/',\n 'Internazionale': 'https://www.internazionale.it/rss',\n 'Linkiesta': 'https://www.linkiesta.it/feed/',\n 'Corriere della Sera': 'https://www.corriere.it/rss/homepage.xml',\n\n # Sport\n 'The Guardian Sport': 'https://www.theguardian.com/sport/rss',\n 'Gazzetta dello Sport': 'https://www.gazzetta.it/rss/home.xml',\n 'Sky Sport': 'https://sport.sky.it/rss',\n\n # Salute\n 'The Guardian Health': 'https://www.theguardian.com/society/health/rss',\n 'WebMD': 'https://rssfeeds.webmd.com/rss/rss.aspx?RSSSource=RSS_PUBLIC',\n\n # Politica\n 'The Guardian Politics': 'https://www.theguardian.com/politics/rss',\n 'BBC News': 'https://feeds.bbci.co.uk/news/rss.xml',\n 'Reuters': 'https://www.reutersagency.com/feed/?best-topics=tech&post_type=best',\n\n # Intrattenimento\n 'The Guardian Entertainment': 'https://www.theguardian.com/uk/entertainment/rss',\n\n # Natura/Ambiente\n 'The Guardian Environment': 'https://www.theguardian.com/environment/rss',\n 'National Geographic': 'https://www.nationalgeographic.com/feed/',\n }\n\n all_articles = []\n article_id = 1\n\n # Raccoglie notizie da tutte le fonti\n for source_name, rss_url in RSS_SOURCES.items():\n try:\n feed = feedparser.parse(rss_url)\n count = 0\n for entry in feed.entries[:5]:\n try:\n # Estrae contenuto completo: prova content, poi description, poi summary\n full_content = \"\"\n full_content_html = \"\" # Mantiene HTML per estrarre immagini\n if hasattr(entry, 'content') and entry.content:\n # Alcuni feed hanno content[0].value con HTML completo\n if isinstance(entry.content, list) and len(entry.content) > 0:\n full_content_html = entry.content[0].value\n full_content = full_content_html\n elif hasattr(entry, 'description'):\n full_content_html = entry.description\n full_content = entry.description\n elif hasattr(entry, 'summary'):\n full_content_html = entry.summary\n full_content = entry.summary\n\n # Pulisce HTML dal contenuto completo\n full_content_clean = clean_html(full_content)\n\n # Summary: usa i primi 600 caratteri (aumentato da 400)\n summary = full_content_clean[:600] if len(full_content_clean) > 600 else full_content_clean\n\n # Content completo: tutto il testo pulito (max 5000 caratteri per performance)\n content = full_content_clean[:5000] if len(full_content_clean) > 5000 else full_content_clean\n\n # Determina lingua originale (fonti italiane)\n italian_sources = [\n 'MicroMega', 'AI4Business', 'ICT Security Magazine', 'Punto Informatico',\n 'Agenda Digitale', 'Wired IT', 'Gazzetta dello Sport', 'DDay.it',\n 'Tom\\'s Hardware Italia', 'StartupItalia', 'Forbes Italia', 'Il Sole 24 Ore',\n 'Repubblica Economia', 'ANSA', 'Il Post', 'Internazionale', 'Linkiesta',\n 'Corriere della Sera', 'Sky Sport'\n ]\n original_language = 'it' if source_name in italian_sources else 'en'\n language = original_language\n\n # Traduci in italiano se la notizia è in inglese\n if language == 'en' and translation_available and translator:\n try:\n import time\n # Traduci titolo\n title_en = entry.get('title', '').strip()[:200]\n if title_en:\n title_it = translator.translate(title_en)\n time.sleep(0.2) # Evita rate limiting\n else:\n title_it = title_en\n\n # Traduci summary (primi 500 caratteri per evitare limiti API)\n if summary:\n summary_it = translator.translate(summary[:500])\n time.sleep(0.3)\n else:\n summary_it = summary\n\n # Traduci anche content completo se disponibile (primi 2000 caratteri)\n if content and len(content) > len(summary):\n try:\n content_it = translator.translate(content[:2000])\n time.sleep(0.5) # Più tempo per contenuti lunghi\n content = content_it if content_it else content\n except:\n pass # Se fallisce, usa content originale\n\n # Usa versioni tradotte\n entry_title = title_it if title_it else entry.get('title', '').strip()[:200]\n summary = summary_it if summary_it else summary\n language = 'it' # Ora è in italiano\n\n except Exception as e:\n print(f\"⚠️ Errore traduzione: {e}\")\n # Usa originale se traduzione fallisce\n entry_title = entry.get('title', '').strip()[:200]\n else:\n entry_title = entry.get('title', '').strip()[:200]\n\n # Determina categoria basandosi sulla fonte\n # Mappa nome categoria → ID categoria\n CATEGORY_NAME_TO_ID = {\n 'Technology': 1,\n 'Science': 2,\n 'Philosophy': 3,\n 'Cybersecurity': 4,\n 'AI': 5,\n 'Innovation': 6,\n 'Culture': 7,\n 'Ethics': 8,\n 'Sport': 9,\n 'Nature': 10,\n 'Business': 11,\n 'Health': 12,\n 'Politics': 13,\n 'Entertainment': 14\n }\n\n # Logica di categorizzazione migliorata per tutte le nuove fonti\n source_lower = source_name.lower()\n summary_lower = summary.lower()\n\n if 'Security' in source_name or 'Hacker' in source_name or 'Krebs' in source_name or 'Bleeping' in source_name:\n category = 'Cybersecurity'\n category_id = CATEGORY_NAME_TO_ID.get('Cybersecurity', 4)\n elif 'ArXiv' in source_name or 'Science' in source_name or 'Nature' in source_name or 'Scientific American' in source_name:\n category = 'Science'\n category_id = CATEGORY_NAME_TO_ID.get('Science', 2)\n elif 'MicroMega' in source_name:\n category = 'Philosophy'\n category_id = CATEGORY_NAME_TO_ID.get('Philosophy', 3)\n elif 'Sport' in source_name or 'Gazzetta' in source_name or 'Sky Sport' in source_name:\n category = 'Sport'\n category_id = CATEGORY_NAME_TO_ID.get('Sport', 9)\n elif 'Business' in source_name or 'AI4Business' in source_name or 'Sole 24 Ore' in source_name or 'Bloomberg' in source_name or 'Financial Times' in source_name:\n category = 'Business'\n category_id = CATEGORY_NAME_TO_ID.get('Business', 11)\n elif 'Health' in source_name or 'WebMD' in source_name:\n category = 'Health'\n category_id = CATEGORY_NAME_TO_ID.get('Health', 12)\n elif 'Politics' in source_name or 'ANSA' in source_name or 'Corriere' in source_name or 'Repubblica' in source_name or 'BBC' in source_name or 'Reuters' in source_name:\n category = 'Politics'\n category_id = CATEGORY_NAME_TO_ID.get('Politics', 13)\n elif 'Entertainment' in source_name:\n category = 'Entertainment'\n category_id = CATEGORY_NAME_TO_ID.get('Entertainment', 14)\n elif 'Environment' in source_name or 'National Geographic' in source_name:\n category = 'Nature'\n category_id = CATEGORY_NAME_TO_ID.get('Nature', 10)\n elif 'AI' in source_name or 'artificial intelligence' in summary_lower or 'AI4Business' in source_name:\n category = 'AI'\n category_id = CATEGORY_NAME_TO_ID.get('AI', 5)\n elif 'Startup' in source_name or 'Innovation' in source_lower:\n category = 'Innovation'\n category_id = CATEGORY_NAME_TO_ID.get('Innovation', 6)\n elif 'Il Post' in source_name or 'Internazionale' in source_name or 'Linkiesta' in source_name:\n category = 'Culture'\n category_id = CATEGORY_NAME_TO_ID.get('Culture', 7)\n elif 'Tech' in source_name or 'Technology' in source_name or 'Wired' in source_name or 'Punto Informatico' in source_name or 'Agenda Digitale' in source_name or 'DDay' in source_name or 'Tom\\'s Hardware' in source_name or 'TechCrunch' in source_name or 'Ars Technica' in source_name or 'The Verge' in source_name or 'CNET' in source_name or 'Engadget' in source_name or 'VentureBeat' in source_name or 'ZDNet' in source_name or 'MIT' in source_name:\n category = 'Technology'\n category_id = CATEGORY_NAME_TO_ID.get('Technology', 1)\n else:\n category = 'Technology'\n category_id = CATEGORY_NAME_TO_ID.get('Technology', 1)\n\n # Calcola reading_time basato sulla lunghezza del contenuto\n content_length = len(content) if content else len(summary)\n reading_time = max(1, int(content_length / 200)) # ~200 caratteri per minuto\n\n # Estrae immagine da vari campi del feed RSS\n image_url = None\n\n # 1. Prova media_content (Media RSS standard)\n if hasattr(entry, 'media_content') and entry.media_content:\n if isinstance(entry.media_content, list) and len(entry.media_content) > 0:\n media_item = entry.media_content[0]\n if isinstance(media_item, dict) and 'url' in media_item:\n image_url = media_item['url']\n elif isinstance(media_item, str):\n image_url = media_item\n\n # 2. Prova media_thumbnail\n if not image_url and hasattr(entry, 'media_thumbnail') and entry.media_thumbnail:\n if isinstance(entry.media_thumbnail, list) and len(entry.media_thumbnail) > 0:\n thumb_item = entry.media_thumbnail[0]\n if isinstance(thumb_item, dict) and 'url' in thumb_item:\n image_url = thumb_item['url']\n elif isinstance(thumb_item, str):\n image_url = thumb_item\n\n # 3. Prova enclosures (allegati)\n if not image_url and hasattr(entry, 'enclosures') and entry.enclosures:\n for enc in entry.enclosures:\n if isinstance(enc, dict):\n enc_type = enc.get('type', '').lower()\n if 'image' in enc_type:\n image_url = enc.get('href') or enc.get('url')\n break\n\n # 4. Estrae immagine da HTML nel contenuto\n if not image_url and full_content_html:\n import re\n img_pattern = r'<img[^>]+src=[\"\\']([^\"\\']+)[\"\\']'\n img_matches = re.findall(img_pattern, full_content_html, re.IGNORECASE)\n if img_matches:\n # Prende la prima immagine trovata\n image_url = img_matches[0]\n # Se è un URL relativo, prova a renderlo assoluto\n if image_url.startswith('//'):\n image_url = 'https:' + image_url\n elif image_url.startswith('/'):\n # Prova a costruire URL assoluto dalla fonte\n from urllib.parse import urljoin\n if entry.get('link'):\n image_url = urljoin(entry.get('link'), image_url)\n\n # 5. Valida e pulisce URL immagine\n if image_url:\n # Rimuove parametri di tracking comuni\n image_url = image_url.split('?')[0].split('&')[0]\n # Verifica che sia un URL valido\n if not image_url.startswith(('http://', 'https://')):\n image_url = None\n\n # Estrae data pubblicazione reale dal feed RSS\n published_date = None\n try:\n # Prova vari campi per la data\n if hasattr(entry, 'published_parsed') and entry.published_parsed:\n from time import mktime\n published_date = datetime.fromtimestamp(mktime(entry.published_parsed))\n elif hasattr(entry, 'updated_parsed') and entry.updated_parsed:\n from time import mktime\n published_date = datetime.fromtimestamp(mktime(entry.updated_parsed))\n elif hasattr(entry, 'published'):\n try:\n # Prova a parsare la data dal formato stringa\n from dateutil import parser\n published_date = parser.parse(entry.published)\n except:\n pass\n elif hasattr(entry, 'updated'):\n try:\n from dateutil import parser\n published_date = parser.parse(entry.updated)\n except:\n pass\n except Exception as e:\n print(f\"⚠️ Errore parsing data per {entry_title[:50]}: {e}\")\n\n # Se non trova data, usa quella di oggi\n if not published_date:\n published_date = datetime.now()\n\n # Filtra solo notizie di oggi\n today = datetime.now().date()\n article_date = published_date.date()\n\n # Salta articoli vecchi (non di oggi)\n if article_date < today:\n continue # Salta questo articolo, è vecchio\n\n article = {\n \"id\": article_id,\n \"title\": entry_title,\n \"slug\": entry_title.lower().replace(' ', '-').replace(\"'\", '').replace(',', '')[:50],\n \"url\": entry.get('link', ''),\n \"summary\": summary, # Summary più lungo (600 caratteri)\n \"content\": content, # Contenuto completo (fino a 5000 caratteri)\n \"image_url\": image_url, # Immagine estratta dal feed\n \"author\": entry.get('author', source_name) + (\" (trad. auto)\" if original_language == 'en' and language == 'it' else \"\"),\n \"published_at\": published_date.isoformat(), # Data reale dal feed RSS\n \"collected_at\": datetime.now().isoformat(),\n \"source_id\": 1,\n \"category_id\": category_id, # ID categoria aggiunto\n \"is_featured\": count == 0,\n \"is_verified\": True,\n \"is_archived\": False,\n \"quality_score\": 0.7 + (0.05 * (5 - count)),\n \"reading_time_minutes\": reading_time,\n \"keywords\": [category.lower(), \"news\", language, source_name.lower()],\n \"language\": language,\n \"original_language\": original_language if original_language != language else None\n }\n\n all_articles.append(article)\n article_id += 1\n count += 1\n except:\n continue\n except Exception as e:\n print(f\"Errore fonte {source_name}: {e}\")\n continue\n\n # RACCOLTA DA GOOGLE NEWS\n print(f\"\\n📰 RACCOLTA DA GOOGLE NEWS...\")\n try:\n from app.services.collectors.google_news_collector import GoogleNewsCollector, GOOGLE_NEWS_TOPICS, GOOGLE_NEWS_QUERIES_IT\n\n google_news_collector = GoogleNewsCollector()\n google_articles_count = 0\n\n # Raccoglie da topic principali (ESPANSI)\n print(\" 📋 Raccolta da topic principali...\")\n topics_to_collect = [\n 'TECHNOLOGY', 'SCIENCE', 'WORLD', 'BUSINESS', 'HEALTH', 'SPORTS',\n 'ENTERTAINMENT', 'POLITICS', 'NATION', 'ENVIRONMENT'\n ]\n for topic in topics_to_collect:\n try:\n articles = google_news_collector.collect(\n query=None,\n language='it',\n country='IT',\n max_articles=8, # Aumentato da 5 a 8\n topic=topic\n )\n\n for article_data in articles:\n try:\n # Determina categoria basandosi sul topic\n category_map = {\n 'TECHNOLOGY': ('Technology', 1),\n 'SCIENCE': ('Science', 2),\n 'WORLD': ('Technology', 1),\n 'BUSINESS': ('Business', 11),\n 'HEALTH': ('Health', 12),\n 'SPORTS': ('Sport', 9),\n 'ENTERTAINMENT': ('Entertainment', 14),\n 'POLITICS': ('Politics', 13),\n 'NATION': ('Politics', 13),\n 'ENVIRONMENT': ('Nature', 10),\n }\n category, category_id = category_map.get(topic, ('Technology', 1))\n\n # Estrae contenuto\n summary = article_data.get('summary', '')[:600]\n content = article_data.get('content', '')[:5000]\n\n # Calcola reading_time\n content_length = len(content) if content else len(summary)\n reading_time = max(1, int(content_length / 200))\n\n article = {\n \"id\": article_id,\n \"title\": article_data.get('title', '')[:200],\n \"slug\": article_data.get('title', '').lower().replace(' ', '-').replace(\"'\", '').replace(',', '')[:50],\n \"url\": article_data.get('url', ''),\n \"summary\": summary,\n \"content\": content,\n \"image_url\": article_data.get('image_url'),\n \"author\": article_data.get('author', 'Google News'),\n \"published_at\": article_data.get('published_at').isoformat() if article_data.get('published_at') and isinstance(article_data.get('published_at'), datetime) else datetime.now().isoformat(),\n \"collected_at\": datetime.now().isoformat(),\n \"source_id\": 1,\n \"category_id\": category_id,\n \"is_featured\": google_articles_count == 0,\n \"is_verified\": True,\n \"is_archived\": False,\n \"quality_score\": 0.75,\n \"reading_time_minutes\": reading_time,\n \"keywords\": [category.lower(), \"news\", \"it\", \"google news\"],\n \"language\": \"it\",\n \"original_language\": None\n }\n\n all_articles.append(article)\n article_id += 1\n google_articles_count += 1\n except Exception as e:\n print(f\" ⚠️ Errore parsing articolo Google News: {e}\")\n continue\n\n print(f\" ✅ Topic {topic}: {len(articles)} articoli raccolti\")\n except Exception as e:\n print(f\" ⚠️ Errore topic {topic}: {e}\")\n continue\n\n # Raccoglie da query specifiche italiane (ESPANSE)\n print(\" 🔍 Raccolta da query specifiche...\")\n queries_to_collect = [\n 'intelligenza artificiale', 'cybersecurity', 'innovazione', 'startup',\n 'tecnologia', 'scienza', 'economia', 'politica', 'sport', 'salute',\n 'ambiente', 'cultura', 'etica', 'filosofia'\n ]\n for query in queries_to_collect:\n try:\n articles = google_news_collector.collect(\n query=query,\n language='it',\n country='IT',\n max_articles=5 # Aumentato da 3 a 5\n )\n\n for article_data in articles:\n try:\n # Determina categoria dalla query (MAPPA ESPANSA)\n query_lower = query.lower()\n if 'intelligenza artificiale' in query_lower or 'ai' in query_lower:\n category, category_id = ('AI', 5)\n elif 'cybersecurity' in query_lower or 'security' in query_lower:\n category, category_id = ('Cybersecurity', 4)\n elif 'innovazione' in query_lower or 'startup' in query_lower:\n category, category_id = ('Innovation', 6)\n elif 'tecnologia' in query_lower or 'tech' in query_lower:\n category, category_id = ('Technology', 1)\n elif 'scienza' in query_lower or 'science' in query_lower:\n category, category_id = ('Science', 2)\n elif 'economia' in query_lower or 'business' in query_lower:\n category, category_id = ('Business', 11)\n elif 'politica' in query_lower or 'politics' in query_lower:\n category, category_id = ('Politics', 13)\n elif 'sport' in query_lower:\n category, category_id = ('Sport', 9)\n elif 'salute' in query_lower or 'health' in query_lower:\n category, category_id = ('Health', 12)\n elif 'ambiente' in query_lower or 'environment' in query_lower:\n category, category_id = ('Nature', 10)\n elif 'cultura' in query_lower or 'culture' in query_lower:\n category, category_id = ('Culture', 7)\n elif 'etica' in query_lower or 'ethics' in query_lower:\n category, category_id = ('Ethics', 8)\n elif 'filosofia' in query_lower or 'philosophy' in query_lower:\n category, category_id = ('Philosophy', 3)\n else:\n category, category_id = ('Technology', 1)\n\n summary = article_data.get('summary', '')[:600]\n content = article_data.get('content', '')[:5000]\n content_length = len(content) if content else len(summary)\n reading_time = max(1, int(content_length / 200))\n\n article = {\n \"id\": article_id,\n \"title\": article_data.get('title', '')[:200],\n \"slug\": article_data.get('title', '').lower().replace(' ', '-').replace(\"'\", '').replace(',', '')[:50],\n \"url\": article_data.get('url', ''),\n \"summary\": summary,\n \"content\": content,\n \"image_url\": article_data.get('image_url'),\n \"author\": article_data.get('author', 'Google News'),\n \"published_at\": article_data.get('published_at').isoformat() if article_data.get('published_at') and isinstance(article_data.get('published_at'), datetime) else datetime.now().isoformat(),\n \"collected_at\": datetime.now().isoformat(),\n \"source_id\": 1,\n \"category_id\": category_id,\n \"is_featured\": False,\n \"is_verified\": True,\n \"is_archived\": False,\n \"quality_score\": 0.7,\n \"reading_time_minutes\": reading_time,\n \"keywords\": [category.lower(), \"news\", \"it\", \"google news\", query.lower()],\n \"language\": \"it\",\n \"original_language\": None\n }\n\n all_articles.append(article)\n article_id += 1\n google_articles_count += 1\n except Exception as e:\n print(f\" ⚠️ Errore parsing articolo Google News (query): {e}\")\n continue\n\n print(f\" ✅ Query '{query}': {len(articles)} articoli raccolti\")\n except Exception as e:\n print(f\" ⚠️ Errore query '{query}': {e}\")\n continue\n\n print(f\"✅ Google News: {google_articles_count} articoli totali raccolti\")\n except ImportError as e:\n print(f\"⚠️ Google News Collector non disponibile: {e}\")\n except Exception as e:\n print(f\"⚠️ Errore raccolta Google News: {e}\")\n import traceback\n traceback.print_exc()\n\n # FASE 2: Salta spiegazioni AI - verranno generate in un secondo passaggio\n # Le spiegazioni AI vengono generate chiamando /api/admin/generate-explanations\n SKIP_AI_EXPLANATIONS = True # Sempre True - spiegazioni in passaggio separato\n\n if False: # Disabilitato - spiegazioni in endpoint separato\n print(f\"\\n🤖 FASE 2: Generazione spiegazioni AI per articoli nuovi...\")\n explanations_generated = 0\n explanations_skipped = 0\n else:\n print(f\"\\n⏭️ FASE 2: Salto generazione spiegazioni AI (SKIP_AI_EXPLANATIONS=True)\")\n explanations_generated = 0\n explanations_skipped = len(all_articles)\n\n if not SKIP_AI_EXPLANATIONS:\n # Carica articoli esistenti per confrontare (evita rigenerare spiegazioni vecchie)\n existing_articles = {}\n try:\n existing_file_path = None\n for path in [\n 'final_news_italian.json',\n os.path.join('backend', 'final_news_italian.json'),\n os.path.join('api', 'final_news_italian.json')\n ]:\n if os.path.exists(path):\n existing_file_path = path\n break\n\n if existing_file_path:\n with open(existing_file_path, 'r', encoding='utf-8') as f:\n existing_data = json.load(f)\n for art in existing_data.get('items', []):\n # Usa URL come chiave univoca (più affidabile di ID)\n url = art.get('url', '')\n if url:\n existing_articles[url] = art\n print(f\" 📚 Caricati {len(existing_articles)} articoli esistenti per confronto\")\n except Exception as e:\n print(f\" ⚠️ Errore caricamento articoli esistenti: {e}\")\n\n if not SKIP_AI_EXPLANATIONS:\n # Genera spiegazioni solo per articoli nuovi\n try:\n from app.ai_explainer import generate_explanation\n\n articles_needing_explanations = []\n for article in all_articles:\n url = article.get('url', '')\n existing = existing_articles.get(url) if url else None\n\n # Controlla se è nuovo o se non ha spiegazioni\n is_new = existing is None\n has_explanations = (\n (existing and existing.get('explanation_quick')) or\n article.get('explanation_quick')\n )\n\n if is_new or not has_explanations:\n articles_needing_explanations.append(article)\n else:\n # Copia spiegazioni esistenti dall'articolo vecchio\n if existing:\n article['explanation_quick'] = existing.get('explanation_quick')\n article['explanation_standard'] = existing.get('explanation_standard')\n article['explanation_deep'] = existing.get('explanation_deep')\n explanations_skipped += 1\n\n print(f\" 📊 Articoli da processare: {len(articles_needing_explanations)} nuovi\")\n print(f\" ⏭️ Articoli saltati: {explanations_skipped} (spiegazioni già presenti)\")\n\n # Genera spiegazioni solo per quelli nuovi\n for i, article in enumerate(articles_needing_explanations):\n try:\n print(f\" [{i+1}/{len(articles_needing_explanations)}] Generando spiegazioni per: {article.get('title', '')[:50]}...\")\n\n # Genera spiegazioni AI (usa cache interna)\n article['explanation_quick'] = generate_explanation(article, 'quick')\n article['explanation_standard'] = generate_explanation(article, 'standard')\n article['explanation_deep'] = generate_explanation(article, 'deep')\n\n explanations_generated += 1\n\n # Log ogni 5 articoli per non intasare\n if (i + 1) % 5 == 0:\n print(f\" ✅ {i+1}/{len(articles_needing_explanations)} articoli processati...\")\n except Exception as e:\n print(f\" ⚠️ Errore generazione spiegazioni per articolo {i+1}: {e}\")\n # Continua con gli altri articoli anche se uno fallisce\n continue\n\n print(f\"✅ Spiegazioni AI generate: {explanations_generated} nuovi articoli\")\n print(f\"✅ Spiegazioni mantenute: {explanations_skipped} articoli esistenti\")\n except ImportError:\n print(\"⚠️ Modulo AI non disponibile, salto generazione spiegazioni\")\n except Exception as e:\n print(f\"⚠️ Errore generazione spiegazioni: {e}\")\n print(\" Continuo comunque con il salvataggio degli articoli...\")\n else:\n print(f\"✅ Raccolti {len(all_articles)} articoli senza spiegazioni AI (per velocità)\")\n\n # Aggiorna final_news_italian.json con spiegazioni incluse\n output_data = {\n \"items\": all_articles,\n \"total\": len(all_articles),\n \"page\": 1,\n \"size\": len(all_articles),\n \"pages\": 1,\n \"updated_at\": datetime.now().isoformat()\n }\n\n # Salva in TUTTI i path necessari (backend, api, root)\n file_paths = [\n 'final_news_italian.json', # Root\n os.path.join('backend', 'final_news_italian.json'), # Backend\n os.path.join('api', 'final_news_italian.json') # API per Vercel\n ]\n\n for file_path in file_paths:\n try:\n # Crea directory se non esiste\n os.makedirs(os.path.dirname(file_path), exist_ok=True) if os.path.dirname(file_path) else None\n with open(file_path, 'w', encoding='utf-8') as f:\n json.dump(output_data, f, indent=2, ensure_ascii=False)\n print(f\"✅ File salvato: {file_path}\")\n except Exception as e:\n print(f\"⚠️ Errore salvataggio {file_path}: {e}\")\n\n file_path = file_paths[0] # Usa il primo per il messaggio\n with open(file_path, 'w', encoding='utf-8') as f:\n json.dump(output_data, f, indent=2, ensure_ascii=False)\n\n # Invalida la cache per forzare il ricaricamento\n global _articles_cache, _cache_timestamp, _cache_file_path\n _articles_cache = None\n _cache_timestamp = None\n _cache_file_path = None\n print(f\"🔄 Cache invalidata - gli articoli verranno ricaricati alla prossima richiesta\")\n\n # Forza il reload immediato per avere i nuovi dati subito disponibili\n try:\n _load_articles(force_reload=True)\n print(f\"✅ Cache ricaricata immediatamente con {len(_articles_cache) if _articles_cache else 0} articoli\")\n except Exception as e:\n print(f\"⚠️ Errore durante reload cache: {e}\")\n\n # GENERA DIGEST AUTOMATICAMENTE con le notizie nuove\n print(f\"\\n📰 Generazione digest giornaliero con notizie nuove...\")\n try:\n import subprocess\n import sys\n\n # Path dello script digest\n script_dir = os.path.dirname(os.path.abspath(__file__))\n backend_dir = os.path.dirname(script_dir)\n digest_script = os.path.join(backend_dir, 'genera_digest_giornaliero.py')\n\n if os.path.exists(digest_script):\n # Esegui lo script Python per generare il digest\n result = subprocess.run(\n [sys.executable, digest_script],\n cwd=backend_dir,\n capture_output=True,\n text=True,\n timeout=60\n )\n\n if result.returncode == 0:\n print(f\"✅ Digest generato automaticamente con notizie nuove\")\n # Mostra output se utile\n if result.stdout:\n for line in result.stdout.strip().split('\\n'):\n if '✅' in line or '📊' in line or '✨' in line:\n print(f\" {line}\")\n else:\n print(f\"⚠️ Errore generazione digest: {result.stderr}\")\n else:\n print(f\"⚠️ Script digest non trovato: {digest_script}\")\n except Exception as e:\n print(f\"⚠️ Errore generazione digest automatica: {e}\")\n # Non bloccare il processo se il digest fallisce\n import traceback\n traceback.print_exc()\n\n print(f\"✅ Aggiornate {len(all_articles)} notizie con spiegazioni AI!\")\n\n return {\n \"success\": True,\n \"message\": f\"Collected and updated {len(all_articles)} articles successfully!\",\n \"total_articles\": len(all_articles),\n \"explanations_generated\": explanations_generated,\n \"updated_at\": datetime.now().isoformat(),\n \"next_collection\": \"In 4 hours\"\n }\n except Exception as e:\n print(f\"❌ Errore aggiornamento: {e}\")\n return {\n \"success\": False,\n \"error\": str(e)\n }\n\n\n@app.post(\"/api/admin/reload-cache\")\ndef reload_cache():\n \"\"\"\n Endpoint per forzare il reload della cache degli articoli.\n Utile dopo aver aggiornato manualmente il file JSON.\n \"\"\"\n global _articles_cache, _cache_timestamp, _cache_file_path\n try:\n # Invalida cache\n _articles_cache = None\n _cache_timestamp = None\n _cache_file_path = None\n\n # Forza reload\n articles = _load_articles(force_reload=True)\n\n return {\n \"success\": True,\n \"message\": f\"Cache ricaricata con successo!\",\n \"articles_count\": len(articles) if articles else 0,\n \"reloaded_at\": datetime.now().isoformat()\n }\n except Exception as e:\n import traceback\n return {\n \"success\": False,\n \"error\": str(e),\n \"traceback\": traceback.format_exc()\n }\n\n\n@app.post(\"/api/admin/create-youtube-video\")\ndef create_youtube_video(max_articles: int = 5):\n \"\"\"\n Crea un video YouTube automatico dalle notizie.\n\n Args:\n max_articles: Numero massimo di notizie da includere (default: 5)\n\n Returns:\n Informazioni sul video creato\n \"\"\"\n return _create_youtube_video_internal(max_articles=max_articles)\n\n\n@app.post(\"/api/admin/create-youtube-video-long\")\ndef create_youtube_video_long(duration_minutes: int = 60):\n \"\"\"\n Crea un video YouTube lungo per playlist 24/7.\n\n Args:\n duration_minutes: Durata target in minuti (default: 60 = 1 ora)\n Consigliato: 60-120 minuti per video\n\n Returns:\n Informazioni sul video creato\n \"\"\"\n return _create_youtube_video_internal(target_duration_minutes=duration_minutes)\n\n\n@app.post(\"/api/admin/create-youtube-short-video\")\ndef create_youtube_short_video():\n \"\"\"\n Crea video corto da 2 minuti con solo notizie che hanno immagini.\n Perfetto per live brevi ogni 20 minuti.\n\n Returns:\n Informazioni sul video creato\n \"\"\"\n try:\n import sys\n import os\n\n # Aggiungi il percorso del backend al PYTHONPATH\n backend_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\n if backend_path not in sys.path:\n sys.path.insert(0, backend_path)\n\n from youtube_short_video_generator import create_short_video_2min\n\n # Carica articoli\n articles = _load_articles()\n\n if not articles:\n return {\n \"success\": False,\n \"error\": \"Nessun articolo disponibile\"\n }\n\n # Crea video corto (solo notizie con immagini)\n video_path = create_short_video_2min(articles)\n\n if video_path:\n # Calcola durata reale\n try:\n from moviepy.editor import VideoFileClip\n video_clip = VideoFileClip(video_path)\n actual_duration_minutes = round(video_clip.duration / 60, 1)\n video_clip.close()\n except:\n actual_duration_minutes = 2.0\n\n file_size_mb = round(os.path.getsize(video_path) / (1024 * 1024), 2)\n\n return {\n \"success\": True,\n \"video_path\": video_path,\n \"duration_minutes\": actual_duration_minutes,\n \"target_duration_minutes\": 2,\n \"file_size_mb\": file_size_mb,\n \"articles_count\": len(articles),\n \"message\": \"Video corto da 2 minuti creato con successo!\"\n }\n else:\n return {\n \"success\": False,\n \"error\": \"Errore durante la creazione del video\"\n }\n\n except Exception as e:\n import traceback\n traceback.print_exc()\n return {\n \"success\": False,\n \"error\": str(e)\n }\n\n\n@app.post(\"/api/admin/create-youtube-live-video\")\ndef create_youtube_live_video(duration_minutes: int = 30):\n \"\"\"\n Crea un video per YouTube Live che ripete le notizie fino a raggiungere la durata desiderata.\n Perfetto per trasmettere un telegiornale continuo durante una live.\n\n Args:\n duration_minutes: Durata target del video per la live (default: 30 minuti)\n Consigliato: 30-60 minuti per live, 240 per TG 4 ore\n\n Returns:\n Informazioni sul video creato\n \"\"\"\n try:\n import sys\n import os\n # Aggiungi il percorso del backend al PYTHONPATH\n backend_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\n if backend_path not in sys.path:\n sys.path.insert(0, backend_path)\n\n from youtube_video_generator import YouTubeVideoGenerator\n\n # Carica le notizie\n articles = _load_articles()\n if not articles:\n return {\n \"success\": False,\n \"error\": \"Nessuna notizia disponibile\"\n }\n\n # Crea il generatore video\n generator = YouTubeVideoGenerator(articles)\n\n try:\n # Crea il video per live (ripete le notizie fino alla durata target)\n video_path = generator.create_live_video(duration_minutes=duration_minutes)\n\n if video_path:\n # Calcola durata reale del video\n try:\n from moviepy.editor import VideoFileClip\n video_clip = VideoFileClip(video_path)\n actual_duration_minutes = round(video_clip.duration / 60, 1)\n video_clip.close()\n except:\n actual_duration_minutes = duration_minutes\n\n result = {\n \"success\": True,\n \"message\": f\"Video per LIVE creato con successo!\",\n \"video_path\": video_path,\n \"file_size_mb\": round(os.path.getsize(video_path) / (1024 * 1024), 2) if os.path.exists(video_path) else 0,\n \"duration_minutes\": actual_duration_minutes,\n \"target_duration_minutes\": duration_minutes,\n \"articles_count\": len(articles),\n \"repetitions\": round(actual_duration_minutes * 60 / (len(articles) * 13), 1)\n }\n\n return result\n else:\n return {\n \"success\": False,\n \"error\": \"Errore nella creazione del video\"\n }\n finally:\n try:\n generator.cleanup()\n except:\n pass\n\n except ImportError as e:\n return {\n \"success\": False,\n \"error\": f\"Dipendenze mancanti: {str(e)}\",\n \"hint\": \"Installa con: pip install moviepy gtts pillow\"\n }\n except Exception as e:\n import traceback\n return {\n \"success\": False,\n \"error\": str(e),\n \"traceback\": traceback.format_exc()\n }\n\n\n@app.post(\"/api/admin/upload-tg-to-youtube\")\ndef upload_tg_to_youtube():\n \"\"\"\n Carica automaticamente il video TG su YouTube.\n Richiede YouTube API credentials configurate.\n\n Returns:\n Informazioni sull'upload\n \"\"\"\n try:\n import sys\n import os\n backend_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\n if backend_path not in sys.path:\n sys.path.insert(0, backend_path)\n\n from youtube_api_manager import YouTubeAPIManager\n\n # Verifica che il video esista\n video_path = os.path.join(backend_path, \"youtube_videos\", \"newsflow_tg.mp4\")\n if not os.path.exists(video_path):\n return {\n \"success\": False,\n \"error\": \"Video TG non trovato. Crea prima il video con /api/admin/create-tg-video\"\n }\n\n # Crea manager YouTube\n manager = YouTubeAPIManager()\n\n # Autentica\n if not manager.authenticate():\n return {\n \"success\": False,\n \"error\": \"Autenticazione YouTube fallita. Configura le credenziali.\",\n \"hint\": \"Vedi: CONFIGURA_YOUTUBE_API.ps1\"\n }\n\n # Upload video\n title = f\"TG NewsFlow - Notizie del {datetime.now().strftime('%d/%m/%Y')}\"\n description = \"Telegiornale automatico con le ultime notizie. Aggiornato automaticamente.\"\n tags = [\"notizie\", \"telegiornale\", \"news\", \"italia\", \"informazione\"]\n\n video_id = manager.upload_video(\n video_path=video_path,\n title=title,\n description=description,\n tags=tags,\n privacy_status=\"public\"\n )\n\n if video_id:\n return {\n \"success\": True,\n \"message\": \"Video caricato su YouTube con successo!\",\n \"video_id\": video_id,\n \"video_url\": f\"https://www.youtube.com/watch?v={video_id}\",\n \"title\": title\n }\n else:\n return {\n \"success\": False,\n \"error\": \"Errore durante l'upload del video\"\n }\n\n except Exception as e:\n import traceback\n return {\n \"success\": False,\n \"error\": str(e),\n \"traceback\": traceback.format_exc()\n }\n\n\n@app.post(\"/api/admin/create-youtube-live-auto\")\ndef create_youtube_live_auto(hour: int, minute: int = 0):\n \"\"\"\n Crea una live YouTube programmata automaticamente.\n Richiede YouTube API credentials configurate.\n\n Args:\n hour: Ora (0-23)\n minute: Minuto (0-59)\n\n Returns:\n Informazioni sulla live creata\n \"\"\"\n try:\n import sys\n import os\n from datetime import datetime, timedelta\n backend_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\n if backend_path not in sys.path:\n sys.path.insert(0, backend_path)\n\n from youtube_api_manager import YouTubeAPIManager\n\n # Calcola data/ora programmata\n now = datetime.now()\n scheduled_time = now.replace(hour=hour, minute=minute, second=0, microsecond=0)\n\n # Se l'ora è già passata oggi, programma per domani\n if scheduled_time < now:\n scheduled_time += timedelta(days=1)\n\n # Crea manager YouTube\n manager = YouTubeAPIManager()\n\n # Autentica\n if not manager.authenticate():\n return {\n \"success\": False,\n \"error\": \"Autenticazione YouTube fallita. Configura le credenziali.\",\n \"hint\": \"Vedi: CONFIGURA_YOUTUBE_API.ps1\"\n }\n\n # Crea live broadcast\n title = f\"TG NewsFlow Live - {scheduled_time.strftime('%d/%m/%Y %H:%M')}\"\n description = \"Telegiornale in diretta con le ultime notizie.\"\n\n broadcast = manager.create_live_broadcast(\n title=title,\n description=description,\n scheduled_start_time=scheduled_time,\n privacy_status=\"public\"\n )\n\n if broadcast:\n return {\n \"success\": True,\n \"message\": \"Live YouTube creata con successo!\",\n \"broadcast_id\": broadcast['broadcast_id'],\n \"title\": title,\n \"scheduled_start_time\": scheduled_time.isoformat(),\n \"note\": \"La live partirà automaticamente all'orario programmato\"\n }\n else:\n return {\n \"success\": False,\n \"error\": \"Errore durante la creazione della live\"\n }\n\n except Exception as e:\n import traceback\n return {\n \"success\": False,\n \"error\": str(e),\n \"traceback\": traceback.format_exc()\n }\n\n\n@app.post(\"/api/admin/create-tg-video\")\ndef create_tg_video():\n \"\"\"\n Crea un video TG con tutte le notizie disponibili (~18-20 minuti).\n Perfetto per live brevi che si accendono e spengono automaticamente.\n\n Returns:\n Informazioni sul video creato\n \"\"\"\n try:\n import sys\n import os\n backend_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\n if backend_path not in sys.path:\n sys.path.insert(0, backend_path)\n\n from youtube_video_generator import YouTubeVideoGenerator\n\n # Carica le notizie\n articles = _load_articles()\n if not articles:\n return {\n \"success\": False,\n \"error\": \"Nessuna notizia disponibile\"\n }\n\n # Crea il generatore video\n generator = YouTubeVideoGenerator(articles)\n\n try:\n # Crea video TG con TUTTE le notizie (non ripete, solo le 85 notizie)\n # Durata stimata: ~18-20 minuti\n output_filename = \"newsflow_tg.mp4\"\n\n # Usa create_video con tutte le notizie (non create_live_video che ripete)\n video_path = generator.create_video(max_articles=999, output_filename=output_filename)\n\n if video_path:\n # Calcola durata reale\n try:\n from moviepy.editor import VideoFileClip\n video_clip = VideoFileClip(video_path)\n actual_duration_minutes = round(video_clip.duration / 60, 1)\n video_clip.close()\n except:\n # Stima basata su numero articoli\n actual_duration_minutes = round(len(articles) * 13 / 60, 1)\n\n result = {\n \"success\": True,\n \"message\": \"Video TG creato con successo!\",\n \"video_path\": video_path,\n \"file_size_mb\": round(os.path.getsize(video_path) / (1024 * 1024), 2) if os.path.exists(video_path) else 0,\n \"duration_minutes\": actual_duration_minutes,\n \"articles_count\": len(articles),\n \"note\": \"Video pronto per live di ~20 minuti. Usa AVVIA_SCHEDULER_LIVE.ps1 per programmare live automatiche\"\n }\n\n return result\n else:\n return {\n \"success\": False,\n \"error\": \"Errore nella creazione del video\"\n }\n finally:\n try:\n generator.cleanup()\n except:\n pass\n\n except Exception as e:\n import traceback\n return {\n \"success\": False,\n \"error\": str(e),\n \"traceback\": traceback.format_exc()\n }\n\n\n@app.get(\"/api/admin/check-tg-sync\")\ndef check_tg_sync():\n \"\"\"\n Controlla se il video TG deve essere rigenerato (notizie aggiornate)\n\n Returns:\n Stato della sincronizzazione\n \"\"\"\n try:\n import sys\n import os\n backend_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\n if backend_path not in sys.path:\n sys.path.insert(0, backend_path)\n\n from news_sync_monitor import NewsSyncMonitor\n\n monitor = NewsSyncMonitor(\n news_file=\"final_news_italian.json\",\n video_file=\"youtube_videos/newsflow_live_4h.mp4\"\n )\n\n should_regenerate = monitor.should_regenerate_video()\n news_update_time = monitor.get_news_update_time()\n\n return {\n \"success\": True,\n \"should_regenerate\": should_regenerate,\n \"news_update_time\": news_update_time.isoformat() if news_update_time else None,\n \"video_exists\": os.path.exists(monitor.video_file),\n \"message\": \"Video deve essere rigenerato\" if should_regenerate else \"Video aggiornato\"\n }\n except Exception as e:\n import traceback\n return {\n \"success\": False,\n \"error\": str(e),\n \"traceback\": traceback.format_exc()\n }\n\n\ndef _create_youtube_video_internal(max_articles: int = None, target_duration_minutes: int = None):\n \"\"\"\n Funzione interna per creare video YouTube\n \"\"\"\n try:\n import sys\n import os\n # Aggiungi il percorso del backend al PYTHONPATH\n backend_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\n if backend_path not in sys.path:\n sys.path.insert(0, backend_path)\n\n from youtube_video_generator import YouTubeVideoGenerator\n\n # Carica le notizie\n articles = _load_articles()\n if not articles:\n return {\n \"success\": False,\n \"error\": \"Nessuna notizia disponibile\"\n }\n\n # Crea il generatore video\n generator = YouTubeVideoGenerator(articles)\n\n try:\n # Crea il video\n if target_duration_minutes:\n video_path = generator.create_video(target_duration_minutes=target_duration_minutes)\n # Calcola durata reale del video\n try:\n from moviepy.editor import VideoFileClip\n video_clip = VideoFileClip(video_path)\n actual_duration_minutes = round(video_clip.duration / 60, 1)\n video_clip.close()\n except:\n actual_duration_minutes = target_duration_minutes\n else:\n video_path = generator.create_video(max_articles=max_articles or 5)\n actual_duration_minutes = None\n\n if video_path:\n result = {\n \"success\": True,\n \"message\": f\"Video creato con successo!\",\n \"video_path\": video_path,\n \"file_size_mb\": round(os.path.getsize(video_path) / (1024 * 1024), 2) if os.path.exists(video_path) else 0\n }\n\n if target_duration_minutes:\n result[\"duration_minutes\"] = actual_duration_minutes or target_duration_minutes\n result[\"target_duration_minutes\"] = target_duration_minutes\n # Stima articoli inclusi\n result[\"articles_count\"] = int((actual_duration_minutes or target_duration_minutes) * 4)\n else:\n result[\"articles_count\"] = min(max_articles or 5, len(articles))\n\n return result\n else:\n return {\n \"success\": False,\n \"error\": \"Errore nella creazione del video\"\n }\n finally:\n try:\n generator.cleanup()\n except:\n pass\n\n except ImportError as e:\n return {\n \"success\": False,\n \"error\": f\"Dipendenze mancanti: {str(e)}\",\n \"hint\": \"Installa con: pip install moviepy gtts pillow\"\n }\n except Exception as e:\n import traceback\n return {\n \"success\": False,\n \"error\": str(e),\n \"traceback\": traceback.format_exc()\n }\n\n\n@app.post(\"/api/admin/create-daily-schedule\")\ndef create_daily_schedule():\n \"\"\"Crea programmazione giornaliera standard per YouTube Live\"\"\"\n try:\n articles = _load_articles()\n if not articles:\n return {\n \"success\": False,\n \"error\": \"Nessuna notizia disponibile\"\n }\n\n # Programmazione standard: 4 live al giorno\n schedule_config = [\n {\"hour\": 8, \"minute\": 0, \"duration_minutes\": 30, \"time_slot\": \"morning\"},\n {\"hour\": 12, \"minute\": 0, \"duration_minutes\": 30, \"time_slot\": \"afternoon\"},\n {\"hour\": 18, \"minute\": 0, \"duration_minutes\": 30, \"time_slot\": \"evening\"},\n {\"hour\": 22, \"minute\": 0, \"duration_minutes\": 60, \"time_slot\": \"night\"},\n ]\n\n # Salva programmazione (puoi salvare su file JSON)\n schedule_file = 'youtube_schedule.json'\n with open(schedule_file, 'w', encoding='utf-8') as f:\n json.dump({\n \"created_at\": datetime.now().isoformat(),\n \"scheduled_streams\": schedule_config\n }, f, indent=2, ensure_ascii=False)\n\n return {\n \"success\": True,\n \"message\": \"Programmazione giornaliera creata!\",\n \"scheduled_streams\": schedule_config,\n \"total_streams\": len(schedule_config),\n \"note\": \"Avvia AVVIA_SCHEDULER_LIVE.ps1 per eseguire i live automatici\"\n }\n except Exception as e:\n return {\n \"success\": False,\n \"error\": str(e)\n }\n\n\n@app.post(\"/api/admin/schedule-youtube-live\")\ndef schedule_youtube_live(hour: int, minute: int = 0, duration_minutes: int = 30):\n \"\"\"\n Programma un singolo live stream\n\n Args:\n hour: Ora (0-23)\n minute: Minuto (0-59)\n duration_minutes: Durata del live\n \"\"\"\n try:\n if not (0 <= hour <= 23):\n return {\"success\": False, \"error\": \"Ora deve essere tra 0 e 23\"}\n if not (0 <= minute <= 59):\n return {\"success\": False, \"error\": \"Minuto deve essere tra 0 e 59\"}\n\n # Carica programmazione esistente\n schedule_file = 'youtube_schedule.json'\n if os.path.exists(schedule_file):\n with open(schedule_file, 'r', encoding='utf-8') as f:\n schedule_data = json.load(f)\n else:\n schedule_data = {\"scheduled_streams\": []}\n\n # Determina time slot\n time_slot = \"morning\" if 6 <= hour < 12 else \\\n \"afternoon\" if 12 <= hour < 18 else \\\n \"evening\" if 18 <= hour < 22 else \"night\"\n\n # Aggiungi nuovo stream\n new_stream = {\n \"hour\": hour,\n \"minute\": minute,\n \"duration_minutes\": duration_minutes,\n \"time_slot\": time_slot\n }\n\n schedule_data[\"scheduled_streams\"].append(new_stream)\n schedule_data[\"updated_at\"] = datetime.now().isoformat()\n\n # Salva\n with open(schedule_file, 'w', encoding='utf-8') as f:\n json.dump(schedule_data, f, indent=2, ensure_ascii=False)\n\n return {\n \"success\": True,\n \"message\": f\"Live programmato alle {hour:02d}:{minute:02d}\",\n \"scheduled_stream\": new_stream\n }\n except Exception as e:\n return {\n \"success\": False,\n \"error\": str(e)\n }\n\n\n@app.get(\"/api/admin/youtube-schedule\")\ndef get_youtube_schedule():\n \"\"\"Ottieni la programmazione YouTube attuale\"\"\"\n try:\n schedule_file = 'youtube_schedule.json'\n if os.path.exists(schedule_file):\n with open(schedule_file, 'r', encoding='utf-8') as f:\n schedule_data = json.load(f)\n return {\n \"success\": True,\n \"scheduled_streams\": schedule_data.get(\"scheduled_streams\", []),\n \"created_at\": schedule_data.get(\"created_at\"),\n \"updated_at\": schedule_data.get(\"updated_at\")\n }\n else:\n return {\n \"success\": True,\n \"scheduled_streams\": [],\n \"message\": \"Nessuna programmazione trovata. Usa /api/admin/create-daily-schedule per crearne una.\"\n }\n except Exception as e:\n return {\n \"success\": False,\n \"error\": str(e)\n }\n\n\nif __name__ == \"__main__\":\n import uvicorn\n uvicorn.run(\"app.main_simple:app\", host=\"0.0.0.0\", port=8000, reload=True)\n\n"}}}}