muchas cosas

This commit is contained in:
jlimolina 2026-08-11 02:37:48 +02:00
parent e3936fd4cf
commit eff656adea
20 changed files with 697 additions and 51 deletions

View file

@ -4,6 +4,7 @@ import (
"context"
"crypto/md5"
"fmt"
"io"
"log"
"net/http"
"os"
@ -27,6 +28,7 @@ var (
batchSize = 10
enrichLimit = 20
scraperWorkers = 5
maxBodyBytes = int64(512 << 10)
)
type URLSource struct {
@ -169,7 +171,7 @@ func extractArticle(source URLSource) (*Article, error) {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
doc, err := goquery.NewDocumentFromReader(io.LimitReader(resp.Body, maxBodyBytes))
if err != nil {
return nil, err
}
@ -256,7 +258,7 @@ func extractContentFromURL(url string) (string, error) {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
doc, err := goquery.NewDocumentFromReader(io.LimitReader(resp.Body, maxBodyBytes))
if err != nil {
return "", err
}

View file

@ -170,8 +170,11 @@ func main() {
}
api.GET("/search", handlers.SearchNews)
api.GET("/search/suggestions", middleware.AuthRequired(), handlers.SearchSuggestions)
api.POST("/searchlog", middleware.AuthRequired(), handlers.LogSearch)
api.GET("/entities", handlers.GetEntities)
api.GET("/entities/news", handlers.GetEntityNews)
api.GET("/stats", handlers.GetStats)
@ -193,6 +196,7 @@ func main() {
admin.POST("/users/:id/demote", handlers.DemoteUser)
admin.POST("/reset-db", handlers.ResetDatabase)
admin.GET("/workers/status", handlers.GetWorkerStatus)
admin.GET("/workers/stats", handlers.GetTranslationStats)
admin.POST("/workers/config", handlers.SetWorkerConfig)
admin.POST("/workers/start", handlers.StartWorkers)
admin.POST("/workers/stop", handlers.StopWorkers)

View file

@ -2,6 +2,7 @@ package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
@ -276,29 +277,46 @@ func processBatch(ctx context.Context, topics []Topic, countries []Country) (int
processedIDs = append(processedIDs, item.ID)
}
// Insert topic relations
// Insert topic relations (batched, upsert)
if len(topicMatches) > 0 {
for _, tm := range topicMatches {
_, err := dbPool.Exec(ctx, `
const chunkSize = 500
for i := 0; i < len(topicMatches); i += chunkSize {
end := i + chunkSize
if end > len(topicMatches) {
end = len(topicMatches)
}
chunk := topicMatches[i:end]
values := make([]string, 0, len(chunk))
args := make([]interface{}, 0, len(chunk)*3)
for j, tm := range chunk {
values = append(values, fmt.Sprintf("($%d, $%d, $%d)", j*3+1, j*3+2, j*3+3))
args = append(args, tm.NoticiaID, tm.TopicID, tm.Score)
}
query := fmt.Sprintf(`
INSERT INTO news_topics (noticia_id, topic_id, score)
VALUES ($1, $2, $3)
VALUES %s
ON CONFLICT (noticia_id, topic_id) DO UPDATE SET score = EXCLUDED.score
`, tm.NoticiaID, tm.TopicID, tm.Score)
if err != nil {
logger.Printf("Error inserting topic: %v", err)
`, strings.Join(values, ","))
if _, err := dbPool.Exec(ctx, query, args...); err != nil {
logger.Printf("Error batch inserting topics: %v", err)
}
}
}
// Update country
// Update country (single bulk UPDATE)
if len(countryUpdates) > 0 {
for _, cu := range countryUpdates {
_, err := dbPool.Exec(ctx, `
UPDATE noticias SET pais_id = $1 WHERE id = $2
`, cu.PaisID, cu.NoticiaID)
if err != nil {
logger.Printf("Error updating country: %v", err)
}
paisSlice := make([]int32, len(countryUpdates))
idSlice := make([]string, len(countryUpdates))
for i, cu := range countryUpdates {
paisSlice[i] = int32(cu.PaisID)
idSlice[i] = cu.NoticiaID
}
if _, err := dbPool.Exec(ctx, `
UPDATE noticias n SET pais_id = u.pais
FROM unnest($1::int[], $2::varchar[]) AS u(pais, noticia_id)
WHERE n.id = u.noticia_id
`, paisSlice, idSlice); err != nil {
logger.Printf("Error bulk updating countries: %v", err)
}
}

View file

@ -23,8 +23,9 @@ var (
logger *log.Logger
pool *pgxpool.Pool
sleepInterval = 30
batchSize = 50
batchSize = 20
imagesDir = "/app/data/wiki_images"
maxImageBytes = int64(2 << 20)
)
type WikiSummary struct {
@ -101,13 +102,17 @@ func downloadImage(imgURL, destPath string) error {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
if resp.ContentLength > maxImageBytes {
return fmt.Errorf("image too large (%d bytes)", resp.ContentLength)
}
out, err := os.Create(destPath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
_, err = io.Copy(out, io.LimitReader(resp.Body, maxImageBytes))
return err
}
@ -210,9 +215,15 @@ func processTag(ctx context.Context, tag Tag) {
}
func main() {
var sleepVal, batchVal int
if val := os.Getenv("WIKI_SLEEP"); val != "" {
if sleep, err := fmt.Sscanf(val, "%d", &sleepInterval); err == nil && sleep > 0 {
sleepInterval = sleep
if _, err := fmt.Sscanf(val, "%d", &sleepVal); err == nil && sleepVal > 0 {
sleepInterval = sleepVal
}
}
if val := os.Getenv("WIKI_BATCH"); val != "" {
if _, err := fmt.Sscanf(val, "%d", &batchVal); err == nil && batchVal > 0 {
batchSize = batchVal
}
}

View file

@ -8,6 +8,7 @@ import (
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"os"
@ -386,6 +387,51 @@ func GetWorkerStatus(c *gin.Context) {
})
}
// GetTranslationStats returns real translation throughput and live worker count.
func GetTranslationStats(c *gin.Context) {
ctx := c.Request.Context()
var last1min, last5min, activeWorkers int64
var elapsed1min, elapsed5min float64
db.GetPool().QueryRow(ctx, `
SELECT COALESCE(SUM(items_translated),0), COALESCE(SUM(elapsed_ms),0)
FROM translation_stats WHERE created_at >= NOW() - INTERVAL '1 minute'
`).Scan(&last1min, &elapsed1min)
db.GetPool().QueryRow(ctx, `
SELECT COALESCE(SUM(items_translated),0), COALESCE(SUM(elapsed_ms),0)
FROM translation_stats WHERE created_at >= NOW() - INTERVAL '5 minutes'
`).Scan(&last5min, &elapsed5min)
// Live workers = distinct hosts that recorded stats in the last 2 minutes
db.GetPool().QueryRow(ctx, `
SELECT COUNT(DISTINCT hostname) FROM translation_stats
WHERE created_at >= NOW() - INTERVAL '2 minutes' AND hostname IS NOT NULL
`).Scan(&activeWorkers)
// Also count WS remote workers currently online
remoteOnline := 0
if err := db.GetPool().QueryRow(ctx, `
SELECT COUNT(*) FROM remote_workers WHERE status = 'online'
`).Scan(&remoteOnline); err != nil {
remoteOnline = 0
}
rateSec := 0.0
if elapsed1min > 0 {
rateSec = float64(last1min) / (elapsed1min / 1000.0)
}
c.JSON(http.StatusOK, gin.H{
"translations_last_1min": last1min,
"translations_last_5min": last5min,
"rate_per_second": math.Round(rateSec*100) / 100,
"rate_per_minute": math.Round(rateSec*60*100) / 100,
"active_workers": activeWorkers + int64(remoteOnline),
"remote_workers_online": remoteOnline,
})
}
func SetWorkerConfig(c *gin.Context) {
var req WorkerConfig
if err := c.ShouldBindJSON(&req); err != nil {

View file

@ -49,7 +49,13 @@ func GetNews(c *gin.Context) {
argNum := 1
if query != "" {
where += fmt.Sprintf(" AND (n.titulo ILIKE $%d OR n.resumen ILIKE $%d)", argNum, argNum)
if translatedOnly {
// With translated_only, search the translated fields too so users can
// find translated news by the Spanish (translated) wording.
where += fmt.Sprintf(" AND (n.titulo ILIKE $%d OR n.resumen ILIKE $%d OR t.titulo_trad ILIKE $%d OR t.resumen_trad ILIKE $%d)", argNum, argNum, argNum, argNum)
} else {
where += fmt.Sprintf(" AND (n.titulo ILIKE $%d OR n.resumen ILIKE $%d)", argNum, argNum)
}
args = append(args, "%"+query+"%")
argNum++
}
@ -149,6 +155,117 @@ func GetNews(c *gin.Context) {
})
}
// GetEntityNews returns the (translated) news that mention a given entity/alias value.
func GetEntityNews(c *gin.Context) {
valor := c.Query("valor")
tipo := c.DefaultQuery("tipo", "persona")
pageStr := c.DefaultQuery("page", "1")
perPageStr := c.DefaultQuery("per_page", "20")
page, _ := strconv.Atoi(pageStr)
perPage, _ := strconv.Atoi(perPageStr)
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 50 {
perPage = 20
}
offset := (page - 1) * perPage
daysStr := c.Query("days")
days := 0
if daysStr != "" {
days, _ = strconv.Atoi(daysStr)
if days < 1 {
days = 0
}
}
today := c.Query("today") == "true"
offsetStr := c.Query("day_offset")
dayOffset := 0
useDayOffset := false
if offsetStr != "" {
dayOffset, _ = strconv.Atoi(offsetStr)
if dayOffset < 0 {
dayOffset = 0
}
useDayOffset = true
}
params := []interface{}{valor, tipo}
next := 3
timeCond := ""
switch {
case useDayOffset:
// Día exacto: desde el inicio del día (hoy - offset) hasta el inicio del día siguiente
timeCond = fmt.Sprintf(
" AND n.fecha >= (CURRENT_DATE - %d)::timestamp AND n.fecha < (CURRENT_DATE - %d + 1)::timestamp",
dayOffset, dayOffset)
case today:
timeCond = " AND n.fecha >= date_trunc('day', NOW())"
case days > 0:
timeCond = fmt.Sprintf(" AND n.fecha >= NOW() - make_interval(days => $%d)", next)
params = append(params, days)
next++
}
base := fmt.Sprintf(`
FROM tags_noticia tn
JOIN tags t ON tn.tag_id = t.id
JOIN traducciones tr ON tn.traduccion_id = tr.id
JOIN noticias n ON tr.noticia_id = n.id
LEFT JOIN entity_aliases ea ON LOWER(ea.alias) = LOWER(t.valor) AND ea.tipo = t.tipo
WHERE LOWER(COALESCE(ea.canonical_name, t.valor)) = LOWER($1) AND t.tipo = $2%s`, timeCond)
var total int
if err := db.GetPool().QueryRow(c.Request.Context(),
fmt.Sprintf("SELECT COUNT(DISTINCT tn.noticia_id) %s", base), params...).Scan(&total); err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to count entity news", Message: err.Error()})
return
}
query := fmt.Sprintf(`
SELECT DISTINCT n.id, n.titulo, COALESCE(n.resumen,''), n.url, n.fecha, n.imagen_url,
n.fuente_nombre, tr.titulo_trad, tr.resumen_trad
%s
ORDER BY n.fecha DESC
LIMIT $%d OFFSET $%d`, base, next, next+1)
rows, err := db.GetPool().Query(c.Request.Context(), query, append(params, perPage, offset)...)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get entity news", Message: err.Error()})
return
}
defer rows.Close()
var news []NewsResponse
for rows.Next() {
var n NewsResponse
var img *string
if err := rows.Scan(&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &img,
&n.FuenteNombre, &n.TitleTranslated, &n.SummaryTranslated); err != nil {
continue
}
if img != nil {
n.ImagenURL = img
}
news = append(news, n)
}
if news == nil {
news = []NewsResponse{}
}
totalPages := (total + perPage - 1) / perPage
c.JSON(http.StatusOK, gin.H{
"news": news,
"total": total,
"page": page,
"per_page": perPage,
"total_pages": totalPages,
})
}
func GetNewsByID(c *gin.Context) {
id := c.Param("id")

View file

@ -3,13 +3,82 @@ package handlers
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/rss2/backend/internal/auth"
"github.com/rss2/backend/internal/db"
"github.com/rss2/backend/internal/models"
"github.com/rss2/backend/internal/services"
)
// LogSearch records a logged-in user's search term so frequent searches gain priority.
func LogSearch(c *gin.Context) {
user := c.MustGet("user").(*auth.Claims)
var req struct {
Query string `json:"q"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid request"})
return
}
term := strings.ToLower(strings.TrimSpace(req.Query))
if term == "" {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "q requerido"})
return
}
if len(term) > 100 {
term = term[:100]
}
_, err := db.GetPool().Exec(c.Request.Context(), `
INSERT INTO user_search_tags (user_id, term, count, last_used)
VALUES ($1, $2, 1, NOW())
ON CONFLICT (user_id, term)
DO UPDATE SET count = user_search_tags.count + 1, last_used = NOW()
`, user.UserID, term)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to save search", Message: err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// SearchSuggestions returns the user's most used search terms (dynamic tags).
func SearchSuggestions(c *gin.Context) {
user := c.MustGet("user").(*auth.Claims)
q := strings.TrimSpace(c.Query("q"))
query := `
SELECT term FROM user_search_tags
WHERE user_id = $1`
args := []interface{}{user.UserID}
if q != "" {
query += ` AND term ILIKE $2`
args = append(args, "%"+q+"%")
}
query += ` ORDER BY count DESC, last_used DESC LIMIT 10`
rows, err := db.GetPool().Query(c.Request.Context(), query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get suggestions", Message: err.Error()})
return
}
defer rows.Close()
terms := []string{}
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
continue
}
terms = append(terms, t)
}
c.JSON(http.StatusOK, gin.H{"terms": terms})
}
func SearchNews(c *gin.Context) {
query := c.Query("q")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))

View file

@ -46,6 +46,7 @@ services:
deploy:
resources:
limits:
cpus: '2'
memory: 8G
reservations:
memory: 4G
@ -94,7 +95,7 @@ services:
DB_PASS: ${DB_PASS}
RSS_MAX_WORKERS: ${RSS_MAX_WORKERS:-100}
RSS_POKE_INTERVAL_MIN: ${RSS_POKE_INTERVAL_MIN:-60}
RSS_HOST_DELAY_MS: ${RSS_HOST_DELAY_MS:-500}
RSS_HOST_DELAY_MS: ${RSS_HOST_DELAY_MS:-800}
TZ: Europe/Madrid
networks:
- backend
@ -153,10 +154,10 @@ services:
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
SCRAPER_SLEEP: 60
SCRAPER_SLEEP: 120
SCRAPER_BATCH: 10
SCRAPER_ENRICH_LIMIT: 20
SCRAPER_WORKERS: ${SCRAPER_WORKERS:-5}
SCRAPER_ENRICH_LIMIT: 10
SCRAPER_WORKERS: ${SCRAPER_WORKERS:-2}
TZ: Europe/Madrid
networks:
- backend
@ -215,6 +216,7 @@ services:
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
WIKI_SLEEP: 10
WIKI_BATCH: 20
TZ: Europe/Madrid
volumes:
- ./data/wiki_images:/app/data/wiki_images
@ -255,6 +257,11 @@ services:
redis:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '2'
memory: 512M
# ==================================================================================
# FRONTEND REACT
@ -272,6 +279,11 @@ services:
depends_on:
- backend-go
restart: unless-stopped
deploy:
resources:
limits:
cpus: '1'
memory: 512M
# ==================================================================================
# NGINX (Puerto 8001 - sirve React + proxy API)
@ -382,7 +394,8 @@ services:
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
TARGET_LANGS: es
SCHEDULER_BATCH: 1000
SCHEDULER_BATCH: 200
SCHEDULER_AGE_DAYS: 30
SCHEDULER_SLEEP: 30
TZ: Europe/Madrid
volumes:
@ -412,10 +425,10 @@ services:
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
EMB_MODEL: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
EMB_BATCH: 64
EMB_BATCH: 32
EMB_SLEEP_IDLE: 5
EMB_LANGS: es
EMB_LIMIT: 1000
EMB_LIMIT: 300
DEVICE: cpu
HF_HOME: /app/hf_cache
TZ: Europe/Madrid
@ -565,7 +578,7 @@ services:
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
NER_LANG: es
NER_BATCH: 64
NER_BATCH: 24
HF_HOME: /app/hf_cache
TZ: Europe/Madrid
volumes:

View file

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { api } from '../services/api'
import { Cpu, Play, Square, Settings, RefreshCw, Loader2, Server, Wifi, Plus, Trash2, Key } from 'lucide-react'
import { Cpu, Play, Square, Settings, RefreshCw, Loader2, Server, Wifi, Plus, Trash2, Key, TrendingUp } from 'lucide-react'
interface WorkerStatus {
type: string
@ -9,6 +9,15 @@ interface WorkerStatus {
running: number
}
interface TranslationStats {
translations_last_1min: number
translations_last_5min: number
rate_per_second: number
rate_per_minute: number
active_workers: number
remote_workers_online: number
}
interface RemoteWorker {
id: number
name: string
@ -21,6 +30,7 @@ interface RemoteWorker {
export function AdminWorkers() {
const [status, setStatus] = useState<WorkerStatus | null>(null)
const [tStats, setTStats] = useState<TranslationStats | null>(null)
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [configType, setConfigType] = useState<'cpu' | 'gpu'>('cpu')
@ -40,6 +50,9 @@ export function AdminWorkers() {
setStatus(res.data)
setConfigType(res.data.type)
setConfigWorkers(res.data.workers)
const statsRes = await api.get('/admin/workers/stats')
setTStats(statsRes.data)
} catch (err: any) {
console.error('Error fetching status:', err)
} finally {
@ -294,7 +307,32 @@ export function AdminWorkers() {
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<span className="font-medium">Workers activos</span>
<span className="text-lg font-bold">{status?.running || 0}</span>
<span className="text-lg font-bold">{tStats?.active_workers ?? status?.running ?? 0}</span>
</div>
<div className="rounded-lg border border-primary-200 dark:border-primary-800 p-4">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-primary-600" />
Rendimiento de Traducción
</h3>
<div className="grid grid-cols-3 gap-3 text-center">
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="text-xl font-bold text-primary-600">{tStats?.rate_per_second ?? 0}</div>
<div className="text-xs text-gray-500">noticias/seg</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="text-xl font-bold text-primary-600">{tStats?.rate_per_minute ?? 0}</div>
<div className="text-xs text-gray-500">noticias/min</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="text-xl font-bold text-primary-600">{tStats?.translations_last_1min ?? 0}</div>
<div className="text-xs text-gray-500">últ. 1 min</div>
</div>
</div>
<div className="text-xs text-gray-500 mt-2">
{tStats?.translations_last_5min ?? 0} traducidas en los últimos 5 min ·{' '}
{tStats?.remote_workers_online ?? 0} remotos online
</div>
</div>
<div className="flex gap-3 pt-2">

View file

@ -10,15 +10,18 @@ export function Feeds() {
const [filtroActivo, setFiltroActivo] = useState('')
const [filtroCategoria, setFiltroCategoria] = useState('')
const [filtroPais, setFiltroPais] = useState('')
const [page, setPage] = useState(1)
const fileInputRef = useRef<HTMLInputElement>(null)
const [importResult, setImportResult] = useState<{ imported: number; skipped: number; failed: number; message: string } | null>(null)
const { data: feedsData, isLoading } = useQuery({
queryKey: ['feeds', filtroActivo, filtroCategoria, filtroPais],
queryKey: ['feeds', filtroActivo, filtroCategoria, filtroPais, page],
queryFn: () => apiService.getFeeds({
activo: filtroActivo || undefined,
categoria_id: filtroCategoria || undefined,
pais_id: filtroPais || undefined,
page,
per_page: 50,
}),
})
@ -165,7 +168,7 @@ export function Feeds() {
<div className="flex flex-wrap gap-4">
<select
value={filtroActivo}
onChange={(e) => setFiltroActivo(e.target.value)}
onChange={(e) => { setFiltroActivo(e.target.value); setPage(1) }}
className="input w-auto"
>
<option value="">Todos</option>
@ -174,7 +177,7 @@ export function Feeds() {
</select>
<select
value={filtroCategoria}
onChange={(e) => setFiltroCategoria(e.target.value)}
onChange={(e) => { setFiltroCategoria(e.target.value); setPage(1) }}
className="input w-auto"
>
<option value="">Todas las categorías</option>
@ -184,7 +187,7 @@ export function Feeds() {
</select>
<select
value={filtroPais}
onChange={(e) => setFiltroPais(e.target.value)}
onChange={(e) => { setFiltroPais(e.target.value); setPage(1) }}
className="input w-auto"
>
<option value="">Todos los países</option>
@ -365,6 +368,30 @@ export function Feeds() {
</table>
</div>
)}
{!isLoading && feedsData && feedsData.total_pages > 1 && (
<div className="mt-6 flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div>
Página <span className="font-semibold text-gray-900 dark:text-white">{page}</span> de <span className="font-semibold text-gray-900 dark:text-white">{feedsData.total_pages}</span>
</div>
<div className="flex gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-4 py-2 border rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-600 dark:hover:bg-gray-700 transition-colors"
>
Anterior
</button>
<button
onClick={() => setPage(p => Math.min(feedsData.total_pages, p + 1))}
disabled={page >= feedsData.total_pages}
className="px-4 py-2 border rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-600 dark:hover:bg-gray-700 transition-colors"
>
Siguiente
</button>
</div>
</div>
)}
</div>
)
}

View file

@ -1,9 +1,9 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link, useSearchParams } from 'react-router-dom'
import { formatDistanceToNow } from 'date-fns'
import { es } from 'date-fns/locale'
import { apiService } from '../services/api'
import { apiService, api } from '../services/api'
import { Search, Globe, Newspaper, Filter } from 'lucide-react'
export function Home() {
@ -13,6 +13,15 @@ export function Home() {
const categoryId = searchParams.get('category_id') || ''
const countryId = searchParams.get('country_id') || ''
const translatedOnly = searchParams.get('translated_only') === 'true'
const [suggestions, setSuggestions] = useState<string[]>([])
// Load the user's most frequent search terms (dynamic tags) when logged in
useEffect(() => {
if (!localStorage.getItem('token')) return
api.get('/search/suggestions')
.then((res) => setSuggestions(res.data.terms || []))
.catch(() => {})
}, [])
const { data: categories } = useQuery({
queryKey: ['categories'],
@ -39,6 +48,10 @@ export function Home() {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const query = formData.get('q')
// Record the search for frequency-based suggestions (only when logged in)
if (localStorage.getItem('token') && query) {
api.post('/searchlog', { q: String(query) }).catch(() => {})
}
setSearchParams({
q: query as string,
page: '1',
@ -83,8 +96,12 @@ export function Home() {
name="q"
defaultValue={q}
placeholder="Buscar noticias..."
list="user-search-suggestions"
className="input pl-10 w-full"
/>
<datalist id="user-search-suggestions">
{suggestions.map((s) => <option key={s} value={s} />)}
</datalist>
<button type="submit" className="btn-primary">
Buscar
</button>

View file

@ -9,6 +9,9 @@ export function Login() {
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [successMessage, setSuccessMessage] = useState('')
const [sessionExpired] = useState<boolean>(
() => new URLSearchParams(window.location.search).get('expired') === '1',
)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@ -55,6 +58,12 @@ export function Login() {
</div>
)}
{sessionExpired && !successMessage && (
<div className="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded mb-4 text-center">
Tu sesión ha expirado. Inicia sesión de nuevo.
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{!isLogin && (
<>

View file

@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../services/api'
import { WikiTooltip } from '../components/ui/WikiTooltip'
@ -32,6 +33,15 @@ const TIPOS = [
{ value: 'tema', label: '📰 Tema', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' },
]
const TIMELINE_WINDOWS: { key: string; label: string; title: string; offset: number }[] = [
{ key: '0', label: 'Hoy', title: 'hoy', offset: 0 },
{ key: '1', label: '1d', title: 'hace 1 día', offset: 1 },
{ key: '2', label: '2d', title: 'hace 2 días', offset: 2 },
{ key: '3', label: '3d', title: 'hace 3 días', offset: 3 },
{ key: '4', label: '4d', title: 'hace 4 días', offset: 4 },
{ key: '5', label: '5d', title: 'hace 5 días', offset: 5 },
]
function getTipoInfo(tipo: string) {
return TIPOS.find(t => t.value === tipo) || TIPOS[3]
}
@ -52,6 +62,14 @@ export function Populares() {
const [saving, setSaving] = useState(false)
const [successMsg, setSuccessMsg] = useState('')
// News-by-entity modal state
const [newsModal, setNewsModal] = useState<Entity | null>(null)
const [newsModalMode, setNewsModalMode] = useState<'news' | 'timeline'>('news')
const [timelineLabel, setTimelineLabel] = useState('últimas 24 horas')
const [entityNews, setEntityNews] = useState<any[]>([])
const [newsLoading, setNewsLoading] = useState(false)
const [newsTotal, setNewsTotal] = useState(0)
// Form state for configure modal
const [newTipo, setNewTipo] = useState('')
const [aliasInput, setAliasInput] = useState('')
@ -108,6 +126,50 @@ export function Populares() {
setSuccessMsg('')
}
const openNews = async (entity: Entity) => {
setNewsModalMode('news')
setNewsModal(entity)
setNewsLoading(true)
setEntityNews([])
setNewsTotal(0)
try {
const params = new URLSearchParams()
params.append('valor', entity.valor)
params.append('tipo', entity.tipo)
params.append('per_page', '30')
const res = await api.get(`/entities/news?${params}`)
setEntityNews(res.data.news || [])
setNewsTotal(res.data.total || 0)
} catch (err) {
console.error(err)
} finally {
setNewsLoading(false)
}
}
const openTimeline = async (entity: Entity, win: typeof TIMELINE_WINDOWS[number]) => {
setTimelineLabel(win.title)
setNewsModalMode('timeline')
setNewsModal(entity)
setNewsLoading(true)
setEntityNews([])
setNewsTotal(0)
try {
const params = new URLSearchParams()
params.append('valor', entity.valor)
params.append('tipo', entity.tipo)
params.append('day_offset', String(win.offset))
params.append('per_page', '50')
const res = await api.get(`/entities/news?${params}`)
setEntityNews(res.data.news || [])
setNewsTotal(res.data.total || 0)
} catch (err) {
console.error(err)
} finally {
setNewsLoading(false)
}
}
const handleSaveTipo = async () => {
if (!configModal || newTipo === configModal.entity.tipo) return
setSaving(true)
@ -351,13 +413,36 @@ export function Populares() {
</span>
</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => openConfig(entity)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Configurar entidad"
>
Configurar
</button>
<div className="flex flex-col items-center gap-1">
<div className="flex items-center justify-center gap-1">
<button
onClick={() => openNews(entity)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Ver noticias donde se menciona"
>
🔍 Buscar
</button>
<button
onClick={() => openConfig(entity)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Configurar entidad"
>
Configurar
</button>
</div>
<div className="flex flex-wrap items-center justify-center gap-1">
{TIMELINE_WINDOWS.map((win) => (
<button
key={win.key}
onClick={() => openTimeline(entity, win)}
className="inline-flex items-center px-1.5 py-0.5 text-[10px] rounded bg-blue-600 text-white hover:bg-blue-700 transition-colors"
title={win.title}
>
{win.label}
</button>
))}
</div>
</div>
</td>
</tr>
)
@ -519,6 +604,129 @@ export function Populares() {
</div>
</div>
)}
{/* News-by-entity Modal */}
{newsModal && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" onClick={() => setNewsModal(null)}>
<div
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[85vh] overflow-hidden flex flex-col"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700">
<div>
<h2 className="text-lg font-bold">
{newsModalMode === 'timeline'
? <>📈 Timeline de &quot;{newsModal.valor}&quot;</>
: <>Noticias sobre &quot;{newsModal.valor}&quot;</>}
</h2>
<p className="text-sm text-gray-500 mt-0.5">
{newsModalMode === 'timeline'
? `Últimas ${Math.min(entityNews.length, 50)} noticias · ${timelineLabel} (${getTipoInfo(newsModal.tipo).label})`
: `${newsTotal} noticias encontradas (${getTipoInfo(newsModal.tipo).label})`}
</p>
</div>
<button
onClick={() => setNewsModal(null)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none"
>
</button>
</div>
<div className="p-4 overflow-y-auto">
{newsLoading ? (
<div className="text-center py-10 text-gray-400">Cargando...</div>
) : entityNews.length === 0 ? (
<div className="text-center py-10 text-gray-400">
{newsModalMode === 'timeline'
? `No hay noticias de esta entidad para ${timelineLabel}.`
: 'No hay noticias que mencionen a esta entidad.'}
</div>
) : newsModalMode === 'timeline' ? (
<ol className="relative border-l-2 border-blue-200 dark:border-blue-800 ml-2 space-y-4">
{entityNews.map((n) => (
<li key={n.id} className="ml-4 relative">
<span className="absolute -left-[21px] top-2 h-3 w-3 rounded-full bg-blue-500 ring-2 ring-blue-100 dark:ring-blue-900" />
<div className="flex items-baseline gap-2">
{n.fecha && (
<span className="text-[11px] font-semibold text-blue-600 dark:text-blue-400 shrink-0 tabular-nums">
{new Date(n.fecha).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })}
</span>
)}
{n.fecha && (
<span className="text-[10px] text-gray-400 shrink-0 tabular-nums">
{new Date(n.fecha).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: '2-digit' })}
</span>
)}
</div>
<Link
to={`/news/${n.id}`}
onClick={() => setNewsModal(null)}
className="block p-3 mt-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border border-gray-100 dark:border-gray-700"
>
<div className="flex items-start gap-3">
{n.imagen_url && (
<img
src={n.imagen_url}
alt=""
className="w-20 h-14 object-cover rounded-md shrink-0"
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
)}
<div className="min-w-0">
<div className="font-medium text-sm line-clamp-2">
{n.title_translated || n.titulo}
</div>
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
{n.summary_translated || n.resumen}
</p>
</div>
</div>
</Link>
</li>
))}
</ol>
) : (
<ul className="space-y-2">
{entityNews.map((n) => (
<li key={n.id}>
<Link
to={`/news/${n.id}`}
onClick={() => setNewsModal(null)}
className="block p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border border-gray-100 dark:border-gray-700"
>
<div className="flex items-start gap-3">
{n.imagen_url && (
<img
src={n.imagen_url}
alt=""
className="w-20 h-14 object-cover rounded-md shrink-0"
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
)}
<div className="min-w-0">
<div className="font-medium text-sm line-clamp-2">
{n.title_translated || n.titulo}
</div>
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
{n.summary_translated || n.resumen}
</p>
{n.fecha && (
<span className="text-[11px] text-gray-400 mt-1 block">
{new Date(n.fecha).toLocaleDateString('es-ES')}
</span>
)}
</div>
</div>
</Link>
</li>
))}
</ul>
)}
</div>
</div>
</div>
)}
</div>
)
}

View file

@ -1,7 +1,7 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useSearchParams, Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { apiService, News, Category, Country } from '../services/api'
import { apiService, api, News, Category, Country } from '../services/api'
import { Search as SearchIcon, Filter } from 'lucide-react'
export function Search() {
@ -10,6 +10,14 @@ export function Search() {
const lang = searchParams.get('lang') || ''
const categoria = searchParams.get('categoria') || ''
const pais = searchParams.get('pais') || ''
const [suggestions, setSuggestions] = useState<string[]>([])
useEffect(() => {
if (!localStorage.getItem('token')) return
api.get('/search/suggestions')
.then((res) => setSuggestions(res.data.terms || []))
.catch(() => {})
}, [])
const { data: categorias } = useQuery({
queryKey: ['categories'],
@ -34,7 +42,12 @@ export function Search() {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const newParams: Record<string, string> = {}
if (formData.get('q')) newParams.q = formData.get('q') as string
if (formData.get('q')) {
newParams.q = formData.get('q') as string
if (localStorage.getItem('token')) {
api.post('/searchlog', { q: formData.get('q') }).catch(() => {})
}
}
if (formData.get('lang')) newParams.lang = formData.get('lang') as string
if (formData.get('categoria')) newParams.categoria = formData.get('categoria') as string
if (formData.get('pais')) newParams.pais = formData.get('pais') as string
@ -58,8 +71,12 @@ export function Search() {
name="q"
defaultValue={q}
placeholder="Palabras clave..."
list="user-search-suggestions"
className="input w-full"
/>
<datalist id="user-search-suggestions">
{suggestions.map((s) => <option key={s} value={s} />)}
</datalist>
</div>
<div className="w-32">

View file

@ -17,6 +17,25 @@ api.interceptors.request.use((config) => {
return config
})
// If the session token expires (or is invalid), clear it and ask to log in again
// instead of failing with confusing "Invalid token" errors on admin actions.
api.interceptors.response.use(
(response) => response,
(error) => {
const status = error?.response?.status
const url = error?.config?.url || ''
const isAuthCall = url.includes('/auth/login') || url.includes('/auth/register')
if (status === 401 && !isAuthCall) {
localStorage.removeItem('token')
localStorage.removeItem('user')
if (!window.location.pathname.startsWith('/login')) {
window.location.href = '/login?expired=1'
}
}
return Promise.reject(error)
},
)
export interface News {
id: string
titulo: string

View file

@ -0,0 +1,4 @@
-- Translation throughput metrics (additive, no data loss)
ALTER TABLE translation_stats ADD COLUMN IF NOT EXISTS items_translated INTEGER DEFAULT 0;
ALTER TABLE translation_stats ADD COLUMN IF NOT EXISTS elapsed_ms INTEGER DEFAULT 0;
ALTER TABLE translation_stats ADD COLUMN IF NOT EXISTS hostname VARCHAR(100);

View file

@ -0,0 +1,11 @@
-- Dynamic search tags per user (additive, no data loss)
CREATE TABLE IF NOT EXISTS user_search_tags (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
term TEXT NOT NULL,
count INTEGER DEFAULT 1,
last_used TIMESTAMP DEFAULT NOW(),
UNIQUE (user_id, term)
);
CREATE INDEX IF NOT EXISTS idx_user_search_tags_user_count ON user_search_tags(user_id, count DESC, last_used DESC);

View file

@ -259,7 +259,7 @@ def fetch_traducciones_info_batch(conn, tr_ids: List[int]) -> Dict[int, Dict[str
"traduccion_id": tr_id,
"noticia_id": row["noticia_id"],
"fecha": row["fecha"],
"titulo_evento": row["titulo_evento"] or "",
"titulo_evento": (row["titulo_evento"] or "")[:255],
}
return result

View file

@ -583,7 +583,7 @@ def fetch_pending_translations(conn):
WHERE t.lang_to = %s
AND (t.titulo_trad IS NULL OR t.resumen_trad IS NULL)
AND (t.locked_at IS NULL OR t.locked_at < NOW() - INTERVAL '10 minutes')
ORDER BY n.fecha DESC
ORDER BY n.fecha ASC
LIMIT %s
FOR UPDATE SKIP LOCKED
""",
@ -593,7 +593,23 @@ def fetch_pending_translations(conn):
rows = cursor.fetchall()
if rows:
LOG.info(f"Found {len(rows)} pending translations for {lang}")
start_ts = time.time()
process_batch(conn, rows)
elapsed_ms = int((time.time() - start_ts) * 1000)
try:
metrics_cur = conn.cursor()
metrics_cur.execute(
"""
INSERT INTO translation_stats (lang_to, items_translated, elapsed_ms, hostname)
VALUES (%s, %s, %s, %s)
""",
(lang, len(rows), elapsed_ms, worker_id),
)
conn.commit()
metrics_cur.close()
except Exception as e:
LOG.error(f"Error recording translation stats: {e}")
conn.rollback()
total_found += len(rows)
cursor.close()

View file

@ -64,7 +64,7 @@ def create_translation_jobs(conn):
SELECT 1 FROM traducciones t
WHERE t.noticia_id = n.id AND t.lang_to = %s
)
ORDER BY n.fecha DESC
ORDER BY n.fecha ASC
LIMIT %s
ON CONFLICT (noticia_id, lang_to) DO NOTHING
RETURNING noticia_id