Me llamó un viernes por la noche. La voz tranquila, pero en ella podía escuchar el cansancio de alguien que lleva semanas luchando con un problema sobre el que no tiene control. Dirige una empresa de servicios que atiende a clientes en cuatro países europeos. Pagó a una agencia por una plataforma web profesional. Recibió algo que parecía una plataforma profesional. Bajo la superficie se escondía una catástrofe.
Por un acuerdo de confidencialidad no puedo revelar el nombre de la empresa ni el sector. En cambio, puedo contar exactamente lo que encontré, lo que hice y por qué elegí unas tecnologías y no otras. Esta historia es un aviso para cualquiera que encargue la construcción de una plataforma a una agencia externa.
Lo que encontré tras auditar la plataforma de la agencia
El primer paso es siempre la auditoría. No evalúo, no crítico: recojo hechos. Tras tres días de análisis tenía un panorama completo.
Código espagueti en PHP 5.6: la agencia no usó ningún framework. Toda la plataforma es código procedural monolítico en PHP 5.6 (fin de soporte en 2018) con consultas SQL pegadas directamente en las plantillas HTML. Sin ORM, sin capa de abstracción, sin enrutador. Archivos de 3000 líneas mezclando lógica de negocio con presentación.
// Código encontrado -- consulta SQL directamente en la plantilla (anonimizado)
<?php
$result = mysql_query("SELECT * FROM services
WHERE category = '" . $_GET['cat'] . "'
ORDER BY id DESC");
// SQL injection -- sin ninguna validación de datos de entrada
while ($row = mysql_fetch_assoc($result)) {
echo "<div class='service'>";
echo "<h2>" . $row['title'] . "</h2>"; // XSS -- sin escapado
echo "<p>" . $row['description'] . "</p>";
echo "</div>";
}
?>
MySQL 5.5 sin índices: base de datos con 47 tablas, ninguna con índices salvo las claves primarias. Una consulta que listaba servicios con filtros ejecutaba un full table scan sobre 200 000 registros, con un tiempo medio de respuesta de 4.7 segundos.
jQuery 1.x + Bootstrap 3: frontend del año 2014. Doce archivos jQuery cargados en cada página, incluyendo tres versiones distintas de la librería. Sin minificación, sin bundler, sin tree-shaking. Peso total de los scripts: 2.8 MB.
FTP como “despliegue”: sin repositorio Git, sin CI/CD, sin entorno de staging. La agencia subía los archivos directamente por FTP al servidor de producción. Sin sistema de control de versiones. Sin tests.
Cero seguridad: contraseñas de usuarios almacenadas en MD5 sin sal. Sesiones en archivos en un servidor compartido. SQL injection en 23 puntos. XSS en formularios. Sin tokens CSRF. Sin HTTPS en el panel de inicio de sesión.
Métricas encontradas
| Métrica | Valor | Evaluación |
|---|---|---|
| PageSpeed (móvil) | 18 | Crítico |
| LCP | 12.4s | Crítico |
| INP | 1100ms | Crítico |
| CLS | 0.52 | Crítico |
| TTFB | 4.7s | Crítico |
| Peso de la página | 11.2 MB | Excesivo |
| Tiempo de respuesta API | 4.7s (promedio) | Crítico |
| Tráfico orgánico | Caída del 72% interanual | Crítico |
| Vulnerabilidades detectadas | 23 SQL injection, 14 XSS | Crítico |
Lo peor era que el clientes no sabía nada de ninguno de estos problemas. La agencia le enviaba durante un año informes de “optimizaciones” que no tenían ningún reflejo en la realidad.
Por qué elegí esta arquitectura tecnológica
La decisión sobre la arquitectura objetivo es el momento más importante del proyecto. El clientes tenía dudas razonables: la agencia anterior prometió “una solución moderna” y entregó código del año 2014. Tenía que elegir tecnologías que resolvieran problemas concretos, no las que estaban de moda.
Analicé los requisitos y ajusté las herramientas a las tareas:
Python + Django (API backend): el clientes necesitaba un backend sólido con panel de administración, autenticación, validación de datos y REST API. Django ofrece todo eso de serie. Django REST Framework es un ecosistema maduro y estable con documentación excelente. El clientes atiende a 4 mercados europeos, y Django tiene internacionalización integrada.
PostgreSQL (base de datos): migrar de MySQL 5.5 a PostgreSQL no es un capricho. PostgreSQL ofrece mejores índices (GIN, GiST para búsqueda de texto completo), mejores tipos de datos (JSONB, arrays), particionamiento de tablas maduro y transacciones ACID fiables. Para 200 000 registros con búsqueda de texto completo multilingüe es la elección natural.
Redis (caché y colas): el tiempo de respuesta de la API desde 4.7 segundos tenía que bajar por debajo de 100 milisegundos. Redis cachea los resultados de las consultas, almacena las sesiones de usuario y gestiona las colas de tareas asíncronas (Celery). Una herramienta, tres funciones críticas.
React + TypeScript (frontend interactivo): el dashboard del clientes, el buscador con filtros, los formularios multietapa, todo eso requiere una UI reactiva. React con TypeScript ofrece componentes tipados, excelentes herramientas de desarrollo y un ecosistema de librerías enorme.
Rust (microservicio de alto rendimiento): indexar la búsqueda de 200 000 registros en 4 idiomas, procesar archivos CSV/Excel de los clientes, transformar datos. Estas tareas requerían rendimiento puro. Rust procesa el índice de búsqueda en 1.8 segundos en lugar de los 47 segundos de la antigua implementación PHP. No es una diferencia porcentual, sino una diferencia de un orden de magnitud.
Astro (sitio de marketing): la página principal, el blog, la oferta, las páginas de servicios son contenidos estáticos que no necesitan JavaScript. Astro genera HTML limpio con coste de ejecución cero. Los elementos interactivos (buscador, formulario de contacto) funcionan como islas React aisladas. Como especialista en desarrollo Astro, este framework fue la eleccion natural para el sitio público.
Arquitectura objetivo:
┌─────────────────────────────────────────────────────┐
│ Cloudflare CDN │
├──────────────┬───────────────┬────────────────────────┤
│ Astro SSG │ React SPA │ Django REST API │
│ (marketing) │ (dashboard) │ (backend) │
│ HTML/CSS │ TypeScript │ Python 3.12 │
├──────────────┴───────────────┴────────────────────────┤
│ │
│ ┌─────────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ PostgreSQL │ │ Redis │ │ Rust service │ │
│ │ (primary DB) │ │ (cache) │ │ (search index, │ │
│ │ │ │ (queue) │ │ data processing│ │
│ └─────────────┘ └──────────┘ └──────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Python AI pipeline (content processing, NLP) │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
API backend en Django REST Framework
El núcleo de la nueva plataforma es Django con Django REST Framework. Construí una API que gestiona el catálogo multilingüe de servicios, el sistema de consultas de clientes, la autenticación JWT y el panel de administración.
# services/models.py -- modelo de servicio con multilingüismo
from django.db import models
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField
class Service(models.Model):
slug = models.SlugField(max_length=200, unique=True)
category = models.ForeignKey(
'Category', on_delete=models.PROTECT, related_name='services'
)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
class ServiceTranslation(models.Model):
service = models.ForeignKey(
Service, on_delete=models.CASCADE, related_name='translations'
)
language = models.CharField(max_length=5, choices=[
('pl', 'Polski'), ('en', 'English'),
('de', 'Deutsch'), ('fr', 'Français'),
])
title = models.CharField(max_length=200)
description = models.TextField()
meta_title = models.CharField(max_length=70)
meta_description = models.CharField(max_length=160)
search_vector = SearchVectorField(null=True)
class Meta:
unique_together = ['service', 'language']
indexes = [
GinIndex(fields=['search_vector']),
models.Index(fields=['language', 'service']),
]
# services/serializers.py -- serializador con validación
from rest_framework import serializers
class ServiceSerializer(serializers.ModelSerializer):
translations = ServiceTranslationSerializer(many=True, read_only=True)
category_name = serializers.CharField(
source='category.name', read_only=True
)
class Meta:
model = Service
fields = [
'id', 'slug', 'category_name',
'is_active', 'translations', 'created_at',
]
# services/views.py -- vistas API con caché Redis
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from rest_framework import viewsets, filters
from django_filters.rest_framework import DjangoFilterBackend
class ServiceViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Service.objects.filter(
is_active=True
).select_related(
'category'
).prefetch_related(
'translations'
)
serializer_class = ServiceSerializer
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
filterset_fields = ['category__slug']
search_fields = ['translations__title', 'translations__description']
@method_decorator(cache_page(60 * 15)) # Cache 15 minut
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
Configuración de Redis como backend de caché y broker de colas Celery:
# settings.py -- konfiguracja Redis
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': 'redis://127.0.0.1:6379/0',
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.clientes.DefaultClient',
'SERIALIZER': 'django_redis.serializers.json.JSONSerializer',
'CONNECTION_POOL_KWARGS': {'max_connections': 50},
},
'KEY_PREFIX': 'platform',
'TIMEOUT': 900, # 15 minut domyślnie
}
}
# Sesje w Redis (szybsze niż baza danych)
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
SESSION_CACHE_ALIAS = 'default'
# Celery z Redis jako broker
CELERY_BROKER_URL = 'redis://127.0.0.1:6379/1'
CELERY_RESULT_BACKEND = 'redis://127.0.0.1:6379/2'
CELERY_TASK_SERIALIZER = 'json'
Impacto tras el despliegue de Django + PostgreSQL + Redis: el tiempo medio de respuesta de la API cayó de 4.7 segundos a 45 milisegundos. Las consultas servidas desde la caché de Redis se resuelven en 3-5 milisegundos.
Microservicio Rust para indexación de búsqueda
El reto técnico más interesante fue el buscador. El clientes tiene un catálogo de 200 000 registros en 4 idiomas. La antigua implementación PHP ejecutaba LIKE '%term%' sobre MySQL sin índices: 47 segundos por consulta. Inutilizable.
PostgreSQL con índices GIN y tsvector resolvió el problema para las consultas estándar. Pero el clientes también necesitaba:
- búsqueda con tolerancia a errores tipográficos (fuzzy matching),
- filtrado por múltiples atributos a la vez con resultados instantáneos,
- reconstrucción del índice tras la importación de datos desde archivos CSV/Excel.
Para estas tareas construí un microservicio en Rust con la librería Tantivy (equivalente a Apache Lucene para Rust):
// search-service/src/indexer.rs -- indeksowanie wyszukiwania w Rust
use tantivy::{
schema::*, doc, Index, IndexWriter,
tokenizer::NgramTokenizer,
};
use serde::Deserialize;
use std::time::Instant;
#[derive(Deserialize)]
pub struct ServiceRecord {
pub id: i64,
pub slug: String,
pub title: String,
pub description: String,
pub category: String,
pub language: String,
pub attributes: Vec<String>,
}
pub struct SearchIndexer {
index: Index,
schema: Schema,
}
impl SearchIndexer {
pub fn new(index_path: &str) -> Result<Self, Box<dyn std::error::Error>> {
let mut schema_builder = Schema::builder();
schema_builder.add_i64_field("id", STORED | INDEXED);
schema_builder.add_text_field("slug", STORED);
schema_builder.add_text_field("title", TEXT | STORED);
schema_builder.add_text_field("description", TEXT | STORED);
schema_builder.add_text_field("category", STRING | STORED);
schema_builder.add_text_field("language", STRING | STORED);
schema_builder.add_text_field("attributes", TEXT | STORED);
// N-gram field for fuzzy/partial matching
schema_builder.add_text_field("title_ngram", TEXT);
let schema = schema_builder.build();
let index = Index::create_in_dir(index_path, schema.clone())?;
// Register n-gram tokenizer for typo tolerance
let ngram_tokenizer = NgramTokenizer::new(2, 4, false)
.expect("Failed to create ngram tokenizer");
index
.tokenizers()
.register("ngram", ngram_tokenizer);
Ok(Self { index, schema })
}
pub fn build_index(
&self,
records: Vec<ServiceRecord>,
) -> Result<usize, Box<dyn std::error::Error>> {
let start = Instant::now();
let mut writer: IndexWriter = self.index.writer(128_000_000)?; // 128MB buffer
let title_field = self.schema.get_field("title").unwrap();
let description_field = self.schema.get_field("description").unwrap();
let title_ngram_field = self.schema.get_field("title_ngram").unwrap();
let count = records.len();
for record in records {
writer.add_document(doc!(
self.schema.get_field("id").unwrap() => record.id,
self.schema.get_field("slug").unwrap() => record.slug,
title_field => record.title.clone(),
description_field => record.description,
self.schema.get_field("category").unwrap() => record.category,
self.schema.get_field("language").unwrap() => record.language,
self.schema.get_field("attributes").unwrap() =>
record.attributes.join(" "),
title_ngram_field => record.title,
))?;
}
writer.commit()?;
let duration = start.elapsed();
println!(
"Indexed {} records in {:.2}s",
count,
duration.as_secs_f64()
);
Ok(count)
}
}
API HTTP en Rust con el framework Actix-web:
// search-service/src/main.rs -- API wyszukiwania
use actix_web::{web, App, HttpServer, HttpResponse};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct SearchQuery {
q: String,
lang: Option<String>,
category: Option<String>,
limit: Option<usize>,
}
#[derive(Serialize)]
struct SearchResult {
id: i64,
slug: String,
title: String,
excerpt: String,
category: String,
score: f32,
}
async fn search(
query: web::Query<SearchQuery>,
indexer: web::Data<SearchIndexer>,
) -> HttpResponse {
let limit = query.limit.unwrap_or(20);
let lang = query.lang.as_deref().unwrap_or("pl");
let results = indexer.search(
&query.q, lang, query.category.as_deref(), limit
);
HttpResponse::Ok().json(results)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let indexer = SearchIndexer::new("./search_index")
.expect("Failed to create indexer");
let indexer_data = web::Data::new(indexer);
HttpServer::new(move || {
App::new()
.app_data(indexer_data.clone())
.route("/search", web::get().to(search))
.route("/health", web::get().to(|| async {
HttpResponse::Ok().body("ok")
}))
})
.bind("127.0.0.1:8081")?
.run()
.await
}
Resultados del benchmark del microservicio Rust:
| Operación | PHP antiguo | Rust nuevo | Mejora |
|---|---|---|---|
| Construcción del índice (200k registros) | 47s | 1.8s | 26x más rápido |
| Búsqueda simple | 4.7s | 2ms | 2350x más rápido |
| Búsqueda con filtros | 8.3s | 5ms | 1660x más rápido |
| Fuzzy matching (errores tipográficos) | No disponible | 8ms | Nueva función |
| Uso de memoria | 512 MB | 84 MB | 6x menos |
Rust no fue una elección “porque está de moda”. Fue una elección porque para esta tarea concreta, procesar 200 000 registros con indexación n-gram y fuzzy matching, ofrece un rendimiento inalcanzable para los intérpretes.
Frontend React con dashboard interactivo
El dashboard del clientes requería una UI reactiva: tablas con ordenación, filtros multinivel, formularios multietapa, gráficos con datos en tiempo real. React con TypeScript es la elección natural.
// src/components/ServiceSearch.tsx -- wyszukiwarka z filtrami
import { useState, useCallback, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useDebounce } from '@/hooks/useDebounce';
interface SearchResult {
id: number;
slug: string;
title: string;
excerpt: string;
category: string;
score: number;
}
interface SearchFilters {
category: string | null;
language: string;
}
export function ServiceSearch({ locale }: { locale: string }) {
const [query, setQuery] = useState('');
const [filters, setFilters] = useState<SearchFilters>({
category: null,
language: locale,
});
const debouncedQuery = useDebounce(query, 300);
const { data: results, isLoading } = useQuery<SearchResult[]>({
queryKey: ['search', debouncedQuery, filters],
queryFn: async () => {
const params = new URLSearchParams({
q: debouncedQuery,
lang: filters.language,
...(filters.category && { category: filters.category }),
});
const res = await fetch(`/api/search?${params}`);
return res.json();
},
enabled: debouncedQuery.length >= 2,
staleTime: 5 * 60 * 1000, // 5 minut cache
});
const handleCategoryChange = useCallback((category: string | null) => {
setFilters(prev => ({ ...prev, category }));
}, []);
return (
<div className="search-container">
<div className="relative">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={locale === 'pl' ? 'Szukaj usług...' : 'Search services...'}
className="w-full px-4 py-3 rounded-lg border border-gray-200
dark:border-gray-700 bg-white dark:bg-gray-800
focus:ring-2 focus:ring-emerald-500 focus:outline-none"
/>
{isLoading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<span className="animate-spin h-5 w-5 border-2
border-emerald-500 border-t-transparent rounded-full
inline-block" />
</div>
)}
</div>
{results && results.length > 0 && (
<div className="mt-4 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{results.map((result) => (
<a
key={result.id}
href={`/${locale}/${result.slug}/`}
className="block p-4 rounded-lg border border-gray-100
dark:border-gray-700 hover:border-emerald-500
transition-colors"
>
<span className="text-xs font-medium text-emerald-600
dark:text-emerald-400">
{result.category}
</span>
<h3 className="mt-1 font-semibold text-gray-900
dark:text-white">
{result.title}
</h3>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400
line-clamp-2">
{result.excerpt}
</p>
</a>
))}
</div>
)}
</div>
);
}
El componente React se incrusta en la página Astro como una isla interactiva:
---
// src/pages/[lang]/services.astro -- strona usług
import { ServiceSearch } from '../../components/ServiceSearch';
import Layout from '../../layouts/Layout.astro';
---
<Layout title="Usługi">
<section class="services-hero">
<h1>Nasze usługi</h1>
</section>
<!-- Wyspa React -- ładuje się przy scrollu, nie blokuje reszty strony -->
<ServiceSearch clientes:visible locale={Astro.params.lang} />
</Layout>
Gracias a la arquitectura Islands, el sitio de marketing carga 0 KB de JavaScript por defecto. El componente del buscador (38 KB gzipped) se carga solo cuando el usuario hace scroll hasta esa sección.
Pipeline de IA para procesamiento de contenido
Migrar 1200 páginas de contenido desde HTML sucio a Markdown limpio requería automatización. Muchas páginas tenían metadatos SEO incompletos, descripciones ausentes, encabezados no optimizados. En lugar de corregirlos a mano, construí un pipeline en Python que aprovecha un modelo de lenguaje personalizado.
El pipeline funcionaba en tres fases:
# ai_content_pipeline.py -- przetwarzanie treści z AI
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import markdownify
@dataclass
class ContentAnalysis:
entities: list[dict] = field(default_factory=list)
meta_description: str = ''
heading_issues: list[str] = field(default_factory=list)
word_count: int = 0
is_thin: bool = False
suggested_internal_links: list[str] = field(default_factory=list)
def clean_legacy_html(raw_html: str) -> str:
"""Faza 1: Czyszczenie brudnego HTML z legacy platformy."""
# Usuwanie inline styles, komentarzy warunkowych IE, pustych tagów
cleaned = re.sub(r'style="[^"]*"', '', raw_html)
cleaned = re.sub(r'<!--\[if.*?\]>.*?<!\[endif\]-->', '', cleaned,
flags=re.DOTALL)
cleaned = re.sub(r'<(div|span|p)[^>]*>\s*</\1>', '', cleaned)
cleaned = re.sub(r' ', ' ', cleaned)
return cleaned
def convert_to_markdown(html: str) -> str:
"""Faza 2: Konwersja HTML na Markdown."""
return markdownify.markdownify(
html,
heading_style="ATX",
strip=['script', 'style', 'iframe', 'object', 'embed'],
convert=['h1', 'h2', 'h3', 'h4', 'p', 'a', 'img',
'ul', 'ol', 'li', 'strong', 'em', 'table'],
)
def analyze_with_ai(content: str, model_client) -> ContentAnalysis:
"""Faza 3: Analiza treści za pomocą niestandardowego modelu AI."""
prompt = f"""Przeanalizuj poniższą treść strony internetowej.
Zwróć JSON z polami:
- entities: lista obiektów {{name, type, relevance_score}}
- meta_description: optymalny opis meta (max 155 znaków, po polsku)
- heading_issues: lista problemów ze strukturą nagłówków
- word_count: liczba słów
- suggested_internal_links: sugerowane frazy do linkowania wewnętrznego
Treść:
{content[:4000]}"""
response = model_client.generate(prompt, max_tokens=1024)
data = json.loads(response.text)
return ContentAnalysis(
entities=data.get('entities', []),
meta_description=data.get('meta_description', ''),
heading_issues=data.get('heading_issues', []),
word_count=data.get('word_count', 0),
is_thin=data.get('word_count', 0) < 300,
suggested_internal_links=data.get('suggested_internal_links', []),
)
def process_batch(
content_dir: Path, model_client, max_workers: int = 4
) -> dict:
"""Przetwarzanie wsadowe z wielowątkowością."""
stats = {'processed': 0, 'thin': 0, 'entities': 0}
def process_file(md_file: Path) -> ContentAnalysis:
content = md_file.read_text(encoding='utf-8')
return analyze_with_ai(content, model_client)
files = list(content_dir.glob('*.md'))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
results = executor.map(process_file, files)
for analysis in results:
stats['processed'] += 1
if analysis.is_thin:
stats['thin'] += 1
stats['entities'] += len(analysis.entities)
return stats
El pipeline procesó 1247 páginas en 4 horas sobre infraestructura propia, no en la nube ni a través de ninguna API externa. Encontró 3400 entidades únicas, generó las meta descripciones faltantes para 680 páginas e identificó 89 páginas con thin content que necesitaban ampliación.
El modelo de IA funcionaba en un servidor dedicado con GPU, sin usar AWS, Azure ni ninguna solución cloud gestionada. Control total sobre los datos del clientes, cero transmisión de contenido a APIs externas.
Migración de datos de MySQL a PostgreSQL
La migración de la base de datos de MySQL 5.5 a PostgreSQL requería no solo trasladar los datos, sino una reconstrucción fundamental del esquema. El esquema antiguo no tenía relaciones, índices ni restricciones de integridad.
-- Migracja schematu: od chaosu do porządku
-- Stary MySQL (brak relacji, brak indeksów)
-- CREATE TABLE services (id INT AUTO_INCREMENT, title VARCHAR(255), ...);
-- CREATE TABLE categories (id INT AUTO_INCREMENT, name VARCHAR(100), ...);
-- Brak FOREIGN KEY, brak INDEX poza PK
-- Nowy PostgreSQL z prawidłową strukturą
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
slug VARCHAR(200) UNIQUE NOT NULL,
parent_id INTEGER REFERENCES categories(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE services (
id SERIAL PRIMARY KEY,
slug VARCHAR(200) UNIQUE NOT NULL,
category_id INTEGER NOT NULL REFERENCES categories(id),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE service_translations (
id SERIAL PRIMARY KEY,
service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE,
language VARCHAR(5) NOT NULL,
title VARCHAR(200) NOT NULL,
description TEXT NOT NULL,
meta_title VARCHAR(70) NOT NULL,
meta_description VARCHAR(160) NOT NULL,
search_vector TSVECTOR,
UNIQUE(service_id, language)
);
-- Indeksy dla wydajnego wyszukiwania
CREATE INDEX idx_translations_search ON service_translations
USING GIN(search_vector);
CREATE INDEX idx_translations_lang ON service_translations(language);
CREATE INDEX idx_services_category ON services(category_id)
WHERE is_active = true;
-- Trigger automatycznej aktualizacji search_vector
CREATE OR REPLACE FUNCTION update_search_vector()
RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('simple', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('simple', COALESCE(NEW.description, '')), 'B');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_search_vector
BEFORE INSERT OR UPDATE ON service_translations
FOR EACH ROW EXECUTE FUNCTION update_search_vector();
Migración SEO: redirecciones y datos estructurados
El elemento más crítico de la migración es preservar las posiciones en Google. A pesar de que la plataforma antigua estaba perdiendo tráfico, seguía teniendo cientos de URL indexadas y decenas de backlinks.
Mapeé cada URL antigua a la nueva y desplegué redirecciones 301 a nivel de Cloudflare Workers:
// redirects.ts -- mapa przekierowań ze starej platformy
const redirectMap: Record<string, string> = {
'/uslugi.php?id=1': '/pl/uslugi/konsulting/',
'/services.php?id=1': '/en/services/consulting/',
'/index.php?page=about': '/pl/o-nas/',
'/kontakt.php': '/pl/kontakt/',
'/leistungen.php': '/de/dienstleistungen/',
// ... 963 przekierowania wygenerowane automatycznie
};
export function handleRedirects(url: URL): Response | null {
// Sprawdź dokładne dopasowanie
const exactMatch = redirectMap[url.pathname + url.search];
if (exactMatch) {
return new Response(null, {
status: 301,
headers: { Location: exactMatch },
});
}
// Sprawdź dopasowanie bez query string
const pathMatch = redirectMap[url.pathname];
if (pathMatch) {
return new Response(null, {
status: 301,
headers: { Location: pathMatch },
});
}
return null;
}
Implementé datos estructurados Schema.org completos con hreflang para cada versión lingüística. La plataforma antigua no tenía ningún dato estructurado, por lo que Google no entendía de qué trataba la página.
Resultados a los 4 meses
| Métrica | Antes | Después | Cambio |
|---|---|---|---|
| PageSpeed (móvil) | 18 | 99 | +450% |
| LCP | 12.4s | 0.3s | -98% |
| INP | 1100ms | 22ms | -98% |
| CLS | 0.52 | 0.01 | -98% |
| TTFB | 4.7s | 0.03s | -99% |
| Peso de la página | 11.2 MB | 0.28 MB | -97% |
| Tiempo de respuesta API | 4700ms | 45ms | -99% |
| Búsqueda (200k registros) | 47s | 2ms | 23500x |
| Tráfico orgánico | Base | +340% | Crecimiento |
| Consultas de clientes | ~2/semana | ~14/semana | +600% |
| Vulnerabilidades de seguridad | 37 | 0 | Eliminadas |
Arquitectura tecnológica completa del proyecto:
- API backend: Python 3.12, Django 5, Django REST Framework, Celery
- Base de datos: PostgreSQL 16 con índices GIN y búsqueda de texto completo
- Caché y colas: Redis 7 (caché, sesiones, broker Celery)
- Microservicio de búsqueda: Rust, Actix-web, Tantivy
- Frontend interactivo: React 19, TypeScript, TanStack Query, Tailwind CSS
- Sitio de marketing: Astro 5 con islas React
- Pipeline de IA: Python, modelo de lenguaje personalizado en GPU dedicada
- Despliegue: Cloudflare Pages + Workers, GitHub Actions CI/CD
- Monitoreo: Sentry, Prometheus, Grafana
Lo que me enseñó este proyecto
No juzgues, diagnostica. El clientes llegó herido por una mala experiencia con una agencia. Lo último que necesitaba era otro “experto” diciéndole lo mal que estaban las cosas. En su lugar, presenté los hechos en forma de informe, expliqué los riesgos y propuse un plan de acción con un calendario claro.
Elige la herramienta para la tarea, no la tarea para la herramienta. Django para la API, React para la UI interactiva, Rust para el procesamiento de datos, Astro para las páginas estáticas. Cada tecnología resuelve un problema concreto. Un framework monolítico para todo es una receta para compromisos continuos.
La IA es una herramienta, no magia. El pipeline de IA procesó 1247 páginas en 4 horas, un trabajo que a mano habría llevado semanas. Pero cada resultado requería verificación humana. La IA generaba sugerencias, la persona tomaba decisiones. Un modelo personalizado en servidor propio da control total sobre los datos del clientes.
Rust se justifica en casos concretos. No escribí toda la plataforma en Rust. Escribí en Rust un único microservicio que procesa 200 000 registros, y ahí la diferencia de rendimiento es de un orden de magnitud. El resto del sistema funciona perfectamente en Python y TypeScript.
La migración SEO no es opcional, es obligatoria. Sin mapeo de URL y redirecciones 301, el clientes habría perdido los últimos restos de tráfico orgánico. Gracias a la migración correcta, el tráfico creció un 340% en 4 meses.
¿Necesitas un rescate tras una mala experiencia con una agencia?
Si tu plataforma fue construida con tecnologías obsoletas, es lenta, insegura o simplemente no funciona como debería, contacta con WPPoland. Realizaré una auditoría inicial gratuita y presentaré un plan de acción con un calendario claro y un alcance de trabajo definido.
Cada proyecto de rescate empieza con una llamada. Este clientes llamó un viernes por la noche. El lunes por la mañana tenía el informe. Ocho semanas después tenía una plataforma de la que está orgulloso.


