Los logs nunca mienten, pero tampoco cuentan toda la verdad a simple vista. Cada línea de acceso es una huella, una pisada en el pasillo de tu servidor. El problema es que ahora, además de los visitantes humanos y los rastreadores clásicos, hay una plaga nueva: los bots de inteligencia artificial.
Y no vienen a comprar. Vienen a entrenar.
La puerta giratoria
Gestiono centenares de sitios web. En los últimos meses, el tráfico «no humano» ha explotado. No es solo Googlebot ni Bingbot. Ahora hay decenas de actores (OpenAI, Anthropic, ByteDance, Perplexity, Amazon, Apple, Meta…) rastreando todo lo que publicamos para alimentar sus modelos. Sin aviso previo, sin contrato, sin compensación.
El coste no es solo ético. Es eléctrico y digital: ancho de banda, CPU, almacenamiento de logs. Cada petición que sirves a un bot de IA es una petición que no sirves a un lector real. Y en una infraestructura propia, cada giga cuenta.
Pero el primer paso para defenderte es saber quién está en tu puerta.
User-Agent: el carné fácil de falsificar
La solución ingenua es mirar el User-Agent. Y sí, es útil: GPTBot, ClaudeBot, Bytespider… todos se anuncian. Pero un User-Agent se puede escribir con curl -A. He visto «Googlebot» que venía de una VM en Google Cloud sin relación con los rangos oficiales de búsqueda. He visto peticiones sin User-Agent que resultaban ser SEO tools agresivas.
Confiar en la cadena de texto que envía el cliente es como creerle al lobo porque lleva puesta la piel de la abuela.
Necesitaba algo más forense.
Un script que no depende de nada
Así nació detect-ai-bots.sh. La filosofía es simple: zero dependencies. No quiero instalar Python, ni Elasticsearch, ni un parser de logs corporativo. Solo bash, awk y lo que ya existe en cualquier servidor Debian/Ubuntu. Opcionalmente, jq si quieres enriquecimiento, pero el núcleo funciona con lo que viene en la caja.
El script lee logs en formato combined (Apache o Angie/Nginx), comprimidos o no, acepta globs del shell y hace todo el trabajo en una sola pasada de awk. Incluye hasta un parser de fechas a epoch UTC sin llamar al binario date, porque en logs grandes, lanzar una subshell por línea es suicidio de rendimiento.
La ventana de tiempo es configurable. Por defecto mira las últimas 24 horas, pero puedes pedirle las últimas dos horas, una semana, lo que necesites.
Lo que descubre
Agrupa las peticiones en categorías. No solo «IA» genérico, sino quién exactamente:
- IA: OpenAI (GPTBot, ChatGPT-User, OAI-SearchBot)
- IA: Anthropic (ClaudeBot, Claude-Web, Anthropic-AI)
- IA: Google (Google-Extended, GoogleOther, CloudVertexBot)
- IA: ByteDance (Bytespider)
- IA: Perplexity, Amazon, Apple, Meta, Cohere, Diffbot, You.com…
- Y luego los buscadores tradicionales, SEO tools (Ahrefs, Semrush, Majestic), monitorización y el resto.
Puedes pedirle el detalle por User-Agent exacto para ver variantes. O pedirle el top de IP, filtrado por categoría. Esto es clave: si descubro que dos IP de un rango de DigitalOcean se hacen pasar por lectores humanos, pero hacen mil peticiones a la hora, tengo un candidato para el firewall.
El truco sucio: PTR y forward-confirm
Aquí es donde el script se pone serio. Con la flag -r, no solo hace resolución inversa (PTR) de cada IP. Eso cualquiera lo hace. Lo que hace es forward-confirm: toma el nombre que devuelve el PTR y resuelve todos sus registros A/AAAA con getent ahosts. Si ninguno coincide con la IP original, el bot está mintiendo.
Es la diferencia entre ver un carné y llamar a la central para verificarlo.
He pillado «Googlebot» que venía de un hostname de AWS. He pillado «Bingbot» que resultaba ser un scraper residencial. El forward-confirm es barato, rápido y usa solo NSS local. Sin API de terceros. Sin cuotas.
Cuando necesitas contexto geopolítico
Con la flag -e, enriquece cada IP consultando ip.robotstxt.es (nuesro propio servicio de geolocalización). Obtienes país, ciudad, organización y ASN. De repente, ese «bot de monitorización» resulta estar en un datacenter de Rusia. Ese «lector humano» es un nodo de Hetzner.
No es paranoia si realmente están rastreando tu contenido.
Ejemplos que uso a diario
Un vistazo rápido a las últimas dos horas:
bash detect-ai-bots.sh -h 2 /var/log/angie/access.log
Ver el detalle de qué variantes de Claude me han visitado:
bash detect-ai-bots.sh -h 24 -d /var/log/angie/*.log
Top 50 IP del último día, verificando PTR y geolocalizando:
bash detect-ai-bots.sh -t 50 -e -r /var/log/angie/access.log
Y mi favorito: los que se hacen pasar por humanos sin decir nada.
bash detect-ai-bots.sh -t 20 -c "Sin User-Agent" -e -r /var/log/angie/access.log
De la detección a la acción
Saber quién entra no es fetichismo de logs. Es soberanía digital. Con esta información puedo:
- Bloquear rangos de IP específicos en el firewall del nodo o del VPS.
- Ajustar
robots.txtcon reglas quirúrgicas (y servirlo como 404 para los que lo ignoran). - Identificar fugas: ¿por qué un bot de IA pide una URL antigua que no está enlazada en ningún sitio?
- Dimensionar recursos: si el 40% de mi tráfico es IA, quizá necesito una caché más agresiva o un rate limit específico.
Conclusión
No podemos detener la marea de los modelos de lenguaje. Pero sí podemos saber quién está en nuestra casa, cuándo y con qué excusa. Este script es mi lupa en el suelo del servidor. No necesita instalar nada, no envía datos a terceros (salvo que uses el enriquecimiento opcional) y me da la verdad sin maquillaje.
Porque en una infraestructura propia, la ignorancia no es felicidad. Es un gasto en la factura del hosting.
El script
#!/usr/bin/env bash
#
# detect-ai-bots.sh
# Analiza logs de acceso (Apache/Nginx, formato combined) y cuenta peticiones
# de bots/rastreadores de IA y otros bots en una ventana de tiempo reciente.
#
# Uso:
# detect-ai-bots.sh [-h HORAS] [-d] [-t N] [-c CATEGORIA] [-e] [-r] ruta_log [ruta_log2 ...]
#
# -h HORAS Ventana de tiempo en horas (por defecto: 24)
# -d Detalle: lista cada User-Agent con su recuento
# -t N Muestra el top N de IPs por número de peticiones
# -c CATEGORIA Limita el top de IPs (-t) a una categoría concreta
# (p.ej. "Sin User-Agent", "IA: OpenAI", "Otros bots/scripts")
# -e Enriquece el top de IPs con geolocalización/ASN vía
# ip.robotstxt.es (requiere 'jq' y acceso a internet)
# -r Añade resolución inversa (PTR) de cada IP y verifica si
# hace forward-confirm (detecta bots que falsifican su UA
# pero no tienen el PTR/red que dicen tener, p.ej. "Googlebot"
# que en realidad viene de una VM de Google Cloud)
#
# Acepta ficheros .log y .log.gz, y rutas con globs (las expande el propio shell).
set -euo pipefail
HOURS=24
SHOW_DETAIL=0
TOP_IPS=0
CATFILTER=""
ENRICH=0
RDNS=0
usage() {
cat >&2 <<EOF
Uso: $(basename "$0") [-h HORAS] [-d] [-t N] [-c CATEGORIA] [-e] [-r] ruta_log [ruta_log2 ...]
-h HORAS Ventana de tiempo a analizar, en horas (por defecto: 24)
-d Muestra el detalle por User-Agent dentro de cada categoría
-t N Muestra el top N de IPs con más peticiones
-c CATEGORIA Limita el top de IPs (-t) a una categoría concreta. El nombre
debe coincidir con el que aparece en "Peticiones por categoría"
(entre comillas si tiene espacios), p.ej.:
"Sin User-Agent", "IA: OpenAI", "IA: ByteDance",
"Otros bots/scripts", "Humano / navegador"
-e Enriquece el top de IPs con país/organización/ASN vía
ip.robotstxt.es (requiere 'jq' y salida a internet)
-r Añade PTR (resolución inversa) de cada IP y comprueba el
forward-confirm (PTR -> resuelve de vuelta a la misma IP).
Útil para detectar bots que dicen ser Googlebot/Bingbot
pero en realidad vienen de una VM en la nube.
Ejemplos:
$(basename "$0") /var/log/nginx/access.log
$(basename "$0") -h 12 -d /var/log/nginx/*.log
$(basename "$0") -t 20 /var/log/apache2/*access*.log /var/log/apache2/*access*.log.1.gz
$(basename "$0") -t 50 -c "Sin User-Agent" -e -r /var/log/nginx/*.log
EOF
exit 1
}
while getopts "h:dt:c:er" opt; do
case "$opt" in
h) HOURS="$OPTARG" ;;
d) SHOW_DETAIL=1 ;;
t) TOP_IPS="$OPTARG" ;;
c) CATFILTER="$OPTARG" ;;
e) ENRICH=1 ;;
r) RDNS=1 ;;
*) usage ;;
esac
done
shift $((OPTIND - 1))
if [[ $ENRICH -eq 1 ]] && ! command -v jq >/dev/null 2>&1; then
echo "Aviso: 'jq' no está instalado, se desactiva el enriquecimiento (-e). Instala con: apt install jq" >&2
ENRICH=0
fi
[[ $# -eq 0 ]] && usage
FILES=()
for f in "$@"; do
if [[ -e "$f" ]]; then
FILES+=("$f")
else
echo "Aviso: '$f' no existe, se ignora." >&2
fi
done
[[ ${#FILES[@]} -eq 0 ]] && { echo "Error: ningún fichero de log válido." >&2; exit 1; }
NOW_EPOCH=$(date -u +%s)
THRESHOLD=$(( NOW_EPOCH - HOURS * 3600 ))
RESULT=$(mktemp)
trap 'rm -f "$RESULT"' EXIT
read_files() {
for f in "${FILES[@]}"; do
case "$f" in
*.gz) zcat -- "$f" 2>/dev/null ;;
*) cat -- "$f" 2>/dev/null ;;
esac
done
}
read_files | awk -v threshold="$THRESHOLD" -v show_detail="$SHOW_DETAIL" -v top_ips="$TOP_IPS" -v catfilter="$CATFILTER" '
function mon2num(m, months, i) {
split("Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec", months, ",")
for (i = 1; i <= 12; i++) if (months[i] == m) return i
return 0
}
# Convierte fecha/hora local + offset de zona a epoch UTC, sin llamar a `date`.
function to_epoch(year, mon, day, hh, mm, ss, tzsign, tzhh, tzmm, a, y2, m2, jdn, days, offset) {
a = int((14 - mon) / 12)
y2 = year + 4800 - a
m2 = mon + 12 * a - 3
jdn = day + int((153 * m2 + 2) / 5) + 365 * y2 + int(y2 / 4) - int(y2 / 100) + int(y2 / 400) - 32045
days = jdn - 2440588
offset = tzhh * 3600 + tzmm * 60
if (tzsign == "-") offset = -offset
return days * 86400 + hh * 3600 + mm * 60 + ss - offset
}
function categorize(ua, l) {
l = tolower(ua)
if (l == "-" || l == "") return "Sin User-Agent"
if (index(l,"gptbot") || index(l,"chatgpt-user") || index(l,"oai-searchbot")) return "IA: OpenAI"
if (index(l,"claudebot") || index(l,"claude-web") || index(l,"claude-searchbot") || index(l,"anthropic-ai")) return "IA: Anthropic"
if (index(l,"ccbot")) return "IA: Common Crawl"
if (index(l,"google-extended") || index(l,"googleother") || index(l,"google-cloudvertexbot")) return "IA: Google"
if (index(l,"bytespider")) return "IA: ByteDance"
if (index(l,"perplexitybot") || index(l,"perplexity-user")) return "IA: Perplexity"
if (index(l,"amazonbot")) return "IA: Amazon"
if (index(l,"applebot-extended")) return "IA: Apple"
if (index(l,"meta-externalagent") || index(l,"meta-externalfetcher")) return "IA: Meta"
if (index(l,"cohere")) return "IA: Cohere"
if (index(l,"diffbot")) return "IA: Diffbot"
if (index(l,"youbot")) return "IA: You.com"
if (index(l,"ai2bot")) return "IA: Allen AI"
if (index(l,"timpibot") || index(l,"imagesiftbot")) return "IA: Otros"
if (index(l,"omgili") || index(l,"webzio")) return "IA: Omgili/Webz"
if (index(l,"mistralai")) return "IA: Mistral"
if (index(l,"magpie-crawler")) return "IA: Magpie"
if (index(l,"facebookbot") || index(l,"facebookexternalhit")) return "Bot: Meta/Facebook"
if (index(l,"googlebot")) return "Buscador: Google"
if (index(l,"bingbot") || index(l,"bingpreview")) return "Buscador: Bing"
if (index(l,"slurp")) return "Buscador: Yahoo"
if (index(l,"duckduckbot")) return "Buscador: DuckDuckGo"
if (index(l,"baiduspider")) return "Buscador: Baidu"
if (index(l,"yandexbot")) return "Buscador: Yandex"
if (index(l,"sogou")) return "Buscador: Sogou"
if (index(l,"exabot")) return "Buscador: Exalead"
if (index(l,"seznambot")) return "Buscador: Seznam"
if (index(l,"ahrefsbot")) return "SEO: Ahrefs"
if (index(l,"semrushbot")) return "SEO: Semrush"
if (index(l,"mj12bot")) return "SEO: Majestic"
if (index(l,"dotbot")) return "SEO: Moz"
if (index(l,"blexbot")) return "SEO: WebMeUp"
if (index(l,"petalbot")) return "SEO: Huawei Petal"
if (index(l,"dataforseobot")) return "SEO: DataForSEO"
if (index(l,"uptimerobot") || index(l,"pingdom") || index(l,"statuscake")) return "Monitorización"
if (index(l,"bot") || index(l,"spider") || index(l,"crawler") || index(l,"curl") || index(l,"wget") || index(l,"python-requests") || index(l,"scrapy") || index(l,"libwww") || index(l,"go-http-client")) return "Otros bots/scripts"
return "Humano / navegador"
}
{
total_lines++
if (!match($0, /\[[0-3][0-9]\/[A-Za-z]{3}\/[0-9]{4}:[0-2][0-9]:[0-5][0-9]:[0-5][0-9] [+-][0-9]{4}\]/)) {
unparsed++
next
}
ts = substr($0, RSTART + 1, RLENGTH - 2)
day = substr(ts, 1, 2) + 0
monname = substr(ts, 4, 3)
year = substr(ts, 8, 4) + 0
hh = substr(ts, 13, 2) + 0
mm = substr(ts, 16, 2) + 0
ss = substr(ts, 19, 2) + 0
tzsign = substr(ts, 22, 1)
tzhh = substr(ts, 23, 2) + 0
tzmm = substr(ts, 25, 2) + 0
mon = mon2num(monname)
if (mon == 0) { unparsed++; next }
epoch = to_epoch(year, mon, day, hh, mm, ss, tzsign, tzhh, tzmm)
if (epoch < threshold) { older++; next }
matched_lines++
ip = $1
n = split($0, q, "\"")
ua = (n >= 2) ? q[n - 1] : "-"
if (ua == "") ua = "-"
cat = categorize(ua)
cat_count[cat]++
if (show_detail) ua_count[cat SUBSEP ua]++
if (top_ips > 0) {
ip_count[ip]++
ip_cat_count[cat SUBSEP ip]++
}
}
END {
for (c in cat_count) print "CAT\t" c "\t" cat_count[c]
if (show_detail) {
for (k in ua_count) {
split(k, parts, SUBSEP)
print "UA\t" parts[1] "\t" parts[2] "\t" ua_count[k]
}
}
if (top_ips > 0) {
if (catfilter == "") {
for (ip in ip_count) print "IP\t" ip "\t" ip_count[ip]
} else {
for (k in ip_cat_count) {
split(k, parts, SUBSEP)
if (parts[1] == catfilter) print "IP\t" parts[2] "\t" ip_cat_count[k]
}
}
}
print "META\ttotal_lines\t" total_lines + 0
print "META\tmatched_lines\t" matched_lines + 0
print "META\tunparsed\t" unparsed + 0
print "META\tolder\t" older + 0
}
' > "$RESULT"
get_meta() {
awk -F'\t' -v k="$1" '$1=="META" && $2==k {print $3}' "$RESULT"
}
TOTAL_LINES=$(get_meta total_lines)
MATCHED=$(get_meta matched_lines)
UNPARSED=$(get_meta unparsed)
OLDER=$(get_meta older)
echo "===================================================================="
echo " Bots / rastreos de IA — ventana de ${HOURS}h — ${#FILES[@]} fichero(s)"
echo "===================================================================="
echo "Líneas leídas en total : $TOTAL_LINES"
echo "Líneas dentro de la ventana : $MATCHED"
echo "Líneas fuera de la ventana : $OLDER"
echo "Líneas sin timestamp válido : $UNPARSED"
echo
if [[ "$MATCHED" -eq 0 ]]; then
echo "No hay peticiones dentro de la ventana de ${HOURS}h."
exit 0
fi
echo "--- Peticiones por categoría (ventana de ${HOURS}h) ---"
grep '^CAT' "$RESULT" | cut -f2,3 | sort -t$'\t' -k2,2 -rn \
| awk -F'\t' '{printf "%-28s %10d\n", $1, $2}'
if [[ "$SHOW_DETAIL" -eq 1 ]]; then
echo
echo "--- Detalle por User-Agent ---"
grep '^UA' "$RESULT" | cut -f2,3,4 | sort -t$'\t' -k3,3 -rn \
| awk -F'\t' '{printf "%-22s %-55s %8d\n", $1, $2, $3}'
fi
lookup_ip() {
local ip="$1"
local json
json=$(curl -s -m 4 "https://ip.robotstxt.es/${ip}/json" 2>/dev/null || true)
if [[ -z "$json" ]]; then
printf '%s\t%s\t%s\t%s\n' "-" "-" "-" "-"
return
fi
echo "$json" | jq -r '[(.country // "-"), (.city // "-"), (.org // "-"), (.asn // "-")] | @tsv' 2>/dev/null \
|| printf '%s\t%s\t%s\t%s\n' "-" "-" "-" "-"
}
# PTR (resolución inversa) vía NSS local, sin dependencias externas.
resolve_ptr() {
local ip="$1"
local ptr
ptr=$(getent hosts "$ip" 2>/dev/null | awk '{print $2}' | head -n1)
[[ -z "$ptr" ]] && ptr="-"
echo "$ptr"
}
# Forward-confirm: resuelve el PTR de vuelta y comprueba que apunta a la misma IP.
# Sin esto, un PTR a secas no demuestra nada (se puede falsificar/no coincidir).
# Usa 'ahosts' (no 'hosts') para comprobar TODOS los registros A/AAAA del nombre,
# no solo el primero -- nombres con varias IPs (round-robin) darían falso negativo si no.
verify_ptr() {
local ip="$1" ptr="$2"
[[ "$ptr" == "-" ]] && { echo "-"; return; }
if getent ahosts "$ptr" 2>/dev/null | awk '{print $1}' | grep -qx "$ip"; then
echo "OK"
else
echo "NO"
fi
}
if [[ "$TOP_IPS" -gt 0 ]]; then
echo
if [[ -n "$CATFILTER" ]]; then
echo "--- Top $TOP_IPS IPs — categoría: $CATFILTER (ventana de ${HOURS}h) ---"
else
echo "--- Top $TOP_IPS IPs con más peticiones (ventana de ${HOURS}h) ---"
fi
if [[ "$ENRICH" -eq 1 || "$RDNS" -eq 1 ]]; then
header=$(printf "%-22s %10s" "IP" "PETICIONES")
[[ "$ENRICH" -eq 1 ]] && header+=$(printf " %-5s %-20s %-30s %-12s" "PAÍS" "CIUDAD" "ORGANIZACIÓN" "ASN")
[[ "$RDNS" -eq 1 ]] && header+=$(printf " %-45s %-8s" "PTR (rDNS)" "CONFIRMA")
echo "$header"
fi
grep '^IP' "$RESULT" | cut -f2,3 | sort -t$'\t' -k2,2 -rn | head -n "$TOP_IPS" \
| while IFS=$'\t' read -r ip count; do
if [[ "$ENRICH" -eq 1 || "$RDNS" -eq 1 ]]; then
line=$(printf "%-22s %10d" "$ip" "$count")
if [[ "$ENRICH" -eq 1 ]]; then
info=$(lookup_ip "$ip")
IFS=$'\t' read -r country city org asn <<< "$info"
line+=$(printf " %-5s %-20s %-30s %-12s" "$country" "$city" "$org" "$asn")
fi
if [[ "$RDNS" -eq 1 ]]; then
ptr=$(resolve_ptr "$ip")
confirm=$(verify_ptr "$ip" "$ptr")
line+=$(printf " %-45s %-8s" "$ptr" "$confirm")
fi
echo "$line"
else
printf "%-22s %10d\n" "$ip" "$count"
fi
done
fi





Deja una respuesta