muchas cosas
This commit is contained in:
parent
e3936fd4cf
commit
eff656adea
20 changed files with 697 additions and 51 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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 "{newsModal.valor}"</>
|
||||
: <>Noticias sobre "{newsModal.valor}"</>}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
4
init-db/41-translation-metrics.sql
Normal file
4
init-db/41-translation-metrics.sql
Normal 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);
|
||||
11
init-db/42-user-search-tags.sql
Normal file
11
init-db/42-user-search-tags.sql
Normal 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);
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue