feat(shell): auto-commit con 31 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 23:55:16 +02:00
parent 1430039688
commit e1e9bb7499
31 changed files with 3917 additions and 0 deletions
@@ -0,0 +1,65 @@
---
name: close_onlyoffice_instance
kind: function
lang: bash
domain: shell
version: "1.0.0"
purity: impure
signature: "close_onlyoffice_instance(instance: string = demo, [--purge]) -> json"
description: "Termina el/los proceso(s) DesktopEditors de una INSTANCIA AISLADA (slot) de ONLYOFFICE Desktop Editors, identificados por su HOME=/tmp/oo_<instance> leido de /proc/<pid>/environ — asi NUNCA mata la instancia personal del usuario, solo la aislada. Envia SIGTERM, espera ~3s por evento (read -t, sin sleep foreground) y SIGKILL a los que sigan vivos. Con el flag --purge borra ademas los directorios del slot (/tmp/oo_<instance>*). Imprime JSON con instance, killed_pids (array), purged y status (closed|not_running)."
tags: [onlyoffice, desktop, x11, shell]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: instance
desc: "nombre del slot aislado a cerrar (default: demo). Solo se matan procesos DesktopEditors cuyo HOME sea /tmp/oo_<instance>"
- name: --purge
desc: "flag opcional: si se pasa, borra los directorios del slot (/tmp/oo_<instance>*) tras matar los procesos. Sin el flag, solo termina procesos y deja el estado del slot en disco"
output: "una linea JSON a stdout: {\"instance\":\"<i>\",\"killed_pids\":[<pids>],\"purged\":true|false,\"status\":\"closed\"|\"not_running\"}. Exit 0 siempre que opere bien (closed si mato procesos, not_running si no habia ninguno del slot), exit 1 si falta dependencia, exit 2 si flag desconocido"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/shell/close_onlyoffice_instance.sh"
---
## Ejemplo
```bash
# Cerrar el slot demo (deja /tmp/oo_demo* en disco para reusar la config)
bash bash/functions/shell/close_onlyoffice_instance.sh demo
# Cerrar y limpiar todo el estado del slot
bash bash/functions/shell/close_onlyoffice_instance.sh demo --purge
# Slot por defecto (demo) sin argumentos
bash bash/functions/shell/close_onlyoffice_instance.sh
# Via fn run
./fn run close_onlyoffice_instance_bash_shell reporte --purge
# Sourceado
source bash/functions/shell/close_onlyoffice_instance.sh
out=$(close_onlyoffice_instance demo --purge)
echo "$out"
# {"instance":"demo","killed_pids":[12345,12350],"purged":true,"status":"closed"}
```
## Cuando usarla
- Cuando terminas un flujo automatizado con ONLYOFFICE Desktop y quieres **cerrar la instancia aislada por completo** (cerrar la ventana con `wmctrl` deja el proceso vivo; esta funcion mata el proceso real).
- Para **liberar recursos** de un slot que ya no usas, opcionalmente borrando su estado en /tmp con `--purge`.
- Como ultimo paso del ciclo open -> reload -> close, garantizando que no quedan procesos huerfanos de la instancia aislada.
## Gotchas
- **Solo mata la instancia aislada**: identifica procesos por `HOME=/tmp/oo_<instance>` en `/proc/<pid>/environ`. La instancia personal del usuario (HOME real) NUNCA se toca. Esto es por diseño y por seguridad.
- **Cerrar la ventana NO mata el proceso**: por eso esta funcion existe. Tras `reload`/`wmctrl -ic` el proceso de la instancia aislada sigue vivo (deseable para reusar). Usa esta funcion para terminarlo de verdad.
- **`--purge` borra /tmp/oo_<instance>***: pierdes la config del slot (perfil, recientes). El slot se recreara limpio en el siguiente `open`. Sin `--purge`, el estado persiste y el siguiente arranque reusa esa config.
- **El slot vive en /tmp**: aunque no purgues, `/tmp/oo_<instance>*` se pierde al reiniciar el PC. Estado desechable.
- **Requiere X11 + wmctrl + xdotool** instalados (coherencia con el grupo, aunque esta funcion solo usa /proc para matar). Comprueba `command -v` y falla claro si falta alguna; no funciona en Wayland puro sin XWayland para el resto del grupo.
- **Carrera de /proc**: si un pid muere entre listarlo y leer su environ, se ignora silenciosamente (guardas `2>/dev/null || true`); no rompe la funcion (`set -uo pipefail` sin `-e`).
- **SIGKILL como ultimo recurso**: tras ~3s de SIGTERM, los procesos vivos reciben SIGKILL. Cambios sin guardar en la app (si los hubiera) se pierden — pero el flujo previsto edita en disco, no en la app, asi que no deberia haber estado sin guardar.
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# close_onlyoffice_instance — termina el/los proceso(s) DesktopEditors de una
# INSTANCIA AISLADA (slot) de ONLYOFFICE Desktop Editors, identificados por su
# HOME=/tmp/oo_<instance> en /proc/<pid>/environ. Opcionalmente limpia los
# directorios del slot con --purge.
#
# Funcion impura: lee /proc, envia señales a procesos y (con --purge) borra
# directorios bajo /tmp. NO toca la instancia personal del usuario: solo mata
# procesos cuyo HOME apunta al slot aislado.
#
# Slot aislado: cada instance usa HOME=/tmp/oo_<instance>,
# XDG_RUNTIME_DIR=/tmp/oo_<instance>_run, XDG_CONFIG_HOME=/tmp/oo_<instance>/.config.
# Sin -e: lecturas de /proc/<pid>/environ pueden fallar por carrera (el pid
# muere entre listar y leer); no deben abortar la funcion.
set -uo pipefail
close_onlyoffice_instance() {
local instance="demo"
local purge=false
# Parseo de args: [instance] y/o --purge en cualquier orden.
local a
for a in "$@"; do
case "$a" in
--purge) purge=true ;;
-*) echo "close_onlyoffice_instance: flag desconocido '$a'" >&2; return 2 ;;
*) instance="$a" ;;
esac
done
# 1. Dependencias del sistema (consistencia con el grupo, aunque aqui solo
# se usa /proc; onlyoffice/wmctrl/xdotool deben existir para operar el slot).
local dep
for dep in onlyoffice-desktopeditors wmctrl xdotool; do
if ! command -v "$dep" >/dev/null 2>&1; then
echo "close_onlyoffice_instance: falta dependencia '$dep'" >&2
return 1
fi
done
local oo_home="/tmp/oo_${instance}"
# 2. Encontrar pids de DesktopEditors con HOME=/tmp/oo_<instance>.
local pids=() pid environ
for pid in $(pgrep -f '/opt/onlyoffice/desktopeditors/DesktopEditors' 2>/dev/null || true); do
# Leer el entorno del proceso; saltar si no se puede (carrera/permisos).
environ=$(tr '\0' '\n' <"/proc/${pid}/environ" 2>/dev/null || true)
[[ -z "$environ" ]] && continue
if grep -qx "HOME=${oo_home}" <<<"$environ" 2>/dev/null; then
pids+=("$pid")
fi
done
# 3. Si no hay procesos del slot: not_running (purge opcional igualmente).
if [[ ${#pids[@]} -eq 0 ]]; then
local purged=false
if [[ "$purge" == true ]]; then
rm -rf -- /tmp/oo_"${instance}"* 2>/dev/null || true
purged=true
fi
printf '{"instance":"%s","killed_pids":[],"purged":%s,"status":"not_running"}\n' \
"$instance" "$purged"
return 0
fi
# 4. SIGTERM a todos los pids del slot.
kill -TERM "${pids[@]}" 2>/dev/null || true
# 5. Esperar ~3s a que mueran (NUNCA sleep foreground): read -t 0.3 x10.
local w=0 wmax=10
while [[ $w -lt $wmax ]]; do
local alive=false p
for p in "${pids[@]}"; do
if kill -0 "$p" 2>/dev/null; then alive=true; break; fi
done
[[ "$alive" == false ]] && break
read -t 0.3 _ </dev/null 2>/dev/null || true
w=$((w + 1))
done
# 6. SIGKILL a los que sigan vivos.
local p
for p in "${pids[@]}"; do
if kill -0 "$p" 2>/dev/null; then
kill -KILL "$p" 2>/dev/null || true
fi
done
# 7. Purge opcional de los dirs del slot.
local purged=false
if [[ "$purge" == true ]]; then
rm -rf -- /tmp/oo_"${instance}"* 2>/dev/null || true
purged=true
fi
# 8. JSON con el array de pids terminados.
local pids_json
pids_json=$(printf '%s,' "${pids[@]}")
pids_json="[${pids_json%,}]"
printf '{"instance":"%s","killed_pids":%s,"purged":%s,"status":"closed"}\n' \
"$instance" "$pids_json" "$purged"
return 0
}
# Ejecutable directo o sourceado.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
close_onlyoffice_instance "$@"
fi
@@ -0,0 +1,78 @@
---
name: monitor_listening_ports
kind: function
lang: bash
domain: shell
version: "0.3.0"
purity: impure
signature: "monitor_listening_ports([--interval N], [--once]) -> void"
description: "TUI ligera de terminal que refresca cada N segundos una tabla de los sockets TCP en escucha (LISTEN) del equipo local: IP | PUERTO | PROCESO | PID | TIEMPO ACTIVO | CMD (cmdline real, util para distinguir python3/node genericos), ordenada por tiempo de vida del proceso dueño (descendente). Una fila por pid. Lanzada como root rellena tambien los sockets de otros usuarios. Modo --once imprime un solo frame y sale."
tags: [recon, ports, monitor, tui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: --interval N
desc: "segundos entre refrescos en modo bucle (default: 1, acepta decimales)"
- name: --once
desc: "imprime un único frame de la tabla y termina con exit 0 (no interactivo; úsalo en tests y en `fn run` para no colgar)"
output: "tabla a stdout con columnas IP, PUERTO, PROCESO, PID, TIEMPO ACTIVO ordenada por uptime del proceso descendente; sin --once refresca en bucle infinito hasta Ctrl-C"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/shell/monitor_listening_ports.sh"
---
## Ejemplo
```bash
# Un solo frame (no cuelga) — ideal para fn run o un pipe
./fn run monitor_listening_ports_bash_shell --once
# Como script directo
bash bash/functions/shell/monitor_listening_ports.sh --once
# Sourceada, en bucle interactivo refrescando cada segundo (Ctrl-C para salir)
source bash/functions/shell/monitor_listening_ports.sh
monitor_listening_ports --interval 1
# Refresco mas lento
monitor_listening_ports --interval 5
```
Salida (frame `--once`, recortado):
```
IP PUERTO PROCESO PID TIEMPO ACTIVO
* 8420 registry_api 1885 4d 23:40:46
:: 8889 mitmweb 1892 4d 23:40:46
127.0.0.1 8484 sqlite_api 1889 4d 23:40:42
127.0.0.1 8899 jupyter-lab 155100 4d 19:33:55
::1 631 - - ?
```
## Cuando usarla
- Cuando quieras vigilar **qué puertos abren tus dev-servers / procesos web locales y desde cuándo** llevan vivos, en una sola pantalla que se actualiza sola.
- Para detectar de un vistazo un proceso recién levantado (aparece al fondo, con poco TIEMPO ACTIVO) o uno que lleva días escuchando (arriba del todo).
- Como paso de reconocimiento local del grupo `recon`: inventario rápido de superficie de escucha TCP del propio equipo, con el dueño de cada socket.
- En tests o automatizaciones que solo necesitan un snapshot: añade `--once` para obtener un frame y salir.
## Gotchas
- **Impura**: depende de `ss` (paquete iproute2) y `ps` (procps). Si falta cualquiera, sale con exit 1 y un mensaje a stderr.
- **Sin sudo no ves PROCESO/PID/CMD de sockets de otros usuarios** (típicamente procesos de root, ej. systemd-resolved en `127.0.0.54:53`, kernels Jupyter de otra sesión, o servidores en contenedores). Esas filas muestran `-`/`?`. La función **no usa sudo** a propósito; para **rellenarlos, lánzala como root**: `pass show claude/sudo | sudo -S bash bash/functions/shell/monitor_listening_ports.sh --interval 1` (el password se pipea, no queda en la cmdline). Como root, `ss` resuelve el dueño de todos los sockets.
- **Columna CMD = cmdline real** (`ps -o args=`, recortada a 90 chars). Es lo que distingue un `python3`/`node` genérico (PROCESO) de lo que realmente ejecuta: `python3 -m ipykernel_launcher ...`, `registry_api -port 8420`, etc. Procesos en distinto namespace (docker) pueden seguir sin CMD aunque corras como root.
- **Una fila por pid**: un mismo puerto con varios workers (ej. nginx, gunicorn) genera varias filas, una por cada pid dueño del socket.
- **`--once` evita colgar**: sin `--once` corre en bucle infinito. No lo lances así en tests ni en `fn run` desatendido — usa `--once`.
- **El orden es por uptime del PROCESO, no por el tiempo de la conexión**. `ps -o etimes=` mide cuánto lleva vivo el proceso completo, no cuándo abrió ese socket concreto.
- **Carrera ps**: si un pid muere entre `ss` y `ps`, su TIEMPO ACTIVO sale como `?` y la fila se ordena al final (no rompe el bucle; el script usa `set -uo pipefail` sin `-e`).
- En modo bucle oculta el cursor (`tput civis`) y lo restaura + limpia en un `trap` EXIT/INT/TERM, de modo que Ctrl-C deja la terminal limpia.
## Capability growth log
- v0.3.0 (14/06/2026) — añade columna **CMD** con la cmdline real del proceso (mapa pid→args construido en la misma llamada `ps -eo pid=,etimes=,args=`), para distinguir un `python3`/`node` genérico de lo que realmente ejecuta. Documenta cómo rellenar los sockets de otros usuarios (`-`) lanzando la TUI como root. Anchos de columna reajustados para dar sitio a CMD.
- v0.2.0 (14/06/2026) — corrige parpadeo y cuelgue del modo bucle. (1) Doble-buffer ANSI: cada frame se computa completo en una variable y se pinta con cursor-home `\033[H` + clear-to-end `\033[J` en vez de `tput clear` antes de recolectar, eliminando el instante en blanco. (2) Rendimiento: una sola llamada a `ps -eo pid=,etimes=` (mapa pid→uptime en memoria, antes era un fork de `ps` por pid) y construcción de filas con `printf -v` (builtin, antes un `$( )` por fila); frame de ~130 ms con cientos de sockets. (3) Bugfix de cuelgue: el avance del parser multi-pid usaba `BASH_REMATCH[0]`, que queda sobrescrito por el `[[ =~ ]]` interno de `_mlp_fmt_etime` → no recortaba el string y entraba en bucle infinito. Ahora el needle se captura justo tras el match, con guard anti-cuelgue si el recorte no progresa.
@@ -0,0 +1,271 @@
#!/usr/bin/env bash
# monitor_listening_ports — TUI ligera que refresca una tabla de sockets TCP en
# escucha (LISTEN) del equipo local, ordenada por tiempo de vida del proceso
# dueño (descendente). Columnas: IP | PUERTO | PROCESO | PID | TIEMPO ACTIVO.
#
# Funcion impura: lee estado del sistema (sockets via `ss`, uptime de procesos
# via `ps`). Sin --once corre en bucle infinito refrescando cada N segundos.
#
# Rendimiento: cada frame hace UNA sola llamada a `ss` y UNA sola a `ps`
# (mapa pid->etimes en memoria). El parseo de cada socket es bash puro y SIN
# command substitution por fila: las cadenas se construyen con `printf -v`
# (builtin, cero forks) y el formato de tiempo se devuelve en una variable
# global. El modo bucle usa doble-buffer ANSI (cursor home + clear-to-end) en
# lugar de limpiar la pantalla antes de computar, para que nunca se vea vacia
# entre refrescos.
# No usamos -e a proposito: una carrera donde un pid muere entre `ss` y `ps`
# no debe matar el bucle entero. -u y pipefail se mantienen para robustez.
set -uo pipefail
# Formatea segundos a texto humano legible y lo deja en la global _mlp_human.
# Se evita `$( )` (un fork por fila) usando una variable de retorno.
# <1h -> MM:SS ej. 12:45
# <1d -> HH:MM:SS ej. 03:12:45
# >=1d -> Nd HH:MM:SS ej. 1d 03:12:45
_mlp_human=""
_mlp_fmt_etime() {
local secs="$1"
# Si no es un numero entero valido, devolver tal cual (ej. "?").
if ! [[ "$secs" =~ ^[0-9]+$ ]]; then
_mlp_human="$secs"
return 0
fi
local days=$(( secs / 86400 ))
local rem=$(( secs % 86400 ))
local hours=$(( rem / 3600 ))
local mins=$(( (rem % 3600) / 60 ))
local s=$(( rem % 60 ))
if (( days > 0 )); then
printf -v _mlp_human '%dd %02d:%02d:%02d' "$days" "$hours" "$mins" "$s"
elif (( hours > 0 )); then
printf -v _mlp_human '%02d:%02d:%02d' "$hours" "$mins" "$s"
else
printf -v _mlp_human '%02d:%02d' "$mins" "$s"
fi
}
# Imprime un unico frame de la tabla a stdout.
# Estrategia de rendimiento (cero forks por fila):
# 1. Un solo `ps -eo pid=,etimes=` construye un mapa pid -> segundos vivo.
# 2. Un solo `ss -H -tlnp` lista los sockets en escucha.
# 3. Cada linea se parsea con bash puro: IP/puerto por parameter expansion,
# (nombre,pid) del campo users:(...) iterando con BASH_REMATCH, y cada
# fila se arma con `printf -v` (builtin). El uptime se resuelve por lookup
# O(1) en el mapa.
# 4. Se ordena por segundos vivo descendente con un unico `sort`.
_mlp_render_frame() {
# Mapas pid -> etimes (segundos vivo) y pid -> cmdline completa. Una sola
# invocacion de ps por frame. `args=` va al ultimo porque lleva espacios,
# asi `read` lo captura entero en la tercera variable.
local -A etmap=() argmap=()
local _pid _et _args
while read -r _pid _et _args; do
[[ -z "$_pid" ]] && continue
etmap["$_pid"]="$_et"
argmap["$_pid"]="$_args"
done < <(ps -eo pid=,etimes=,args= 2>/dev/null)
# Cada fila intermedia: "<etimes>\t<ip>\t<puerto>\t<proceso>\t<pid>\t<humano>"
local -a rows=()
local line row
while IFS= read -r line; do
[[ -z "$line" ]] && continue
# Campos de `ss -H -tlnp`: State Recv-Q Send-Q Local:Port Peer:Port users:(...)
# Local:Port es el 4o token. Lo extraemos sin fork con read en array.
local -a F=()
read -ra F <<<"$line"
local local_addr="${F[3]:-}"
[[ -z "$local_addr" ]] && continue
# Separar IP y PUERTO partiendo por el ULTIMO ':'.
local ip port
port="${local_addr##*:}"
ip="${local_addr%:*}"
# Quitar corchetes de IPv6: [::] -> :: , [::1] -> ::1
ip="${ip#[}"
ip="${ip%]}"
# Caso de bind sin direccion explicita (raro): dejar marcador.
[[ -z "$ip" ]] && ip="*"
# Extraer el bloque users:(...) del final de la linea (si existe).
local users=""
[[ "$line" == *"users:("* ]] && users="${line#*users:(}"
if [[ -z "$users" ]]; then
# Socket sin info de proceso (pertenece a otro usuario y no corremos
# como root). Para verlo, lanzar la TUI como root (ver Gotchas).
printf -v row '%s\t%s\t%s\t%s\t%s\t%s\t%s' "-1" "$ip" "$port" "-" "-" "?" "-"
rows+=("$row")
continue
fi
# Dentro de users puede haber varios ("nombre",pid=N,fd=M). Una fila por
# pid. Iteramos con BASH_REMATCH avanzando sobre el string (cero forks).
local s="$users" pname pid etimes needle prev_s cmd found_any=0
while [[ "$s" =~ \"([^\"]*)\",pid=([0-9]+) ]]; do
# IMPORTANTE: capturar nombre/pid/needle ANTES de cualquier otra
# comparacion `[[ =~ ]]` (p.ej. dentro de _mlp_fmt_etime), porque
# cada `=~` SOBREESCRIBE BASH_REMATCH. Si se usara BASH_REMATCH[0]
# despues, contendria el match del ultimo `=~` y el recorte de `s`
# no avanzaria -> bucle infinito.
pname="${BASH_REMATCH[1]}"
pid="${BASH_REMATCH[2]}"
needle="${BASH_REMATCH[0]}"
found_any=1
# Lookup O(1) en el mapa. Si el pid ya no esta (carrera), marcar "?".
etimes="${etmap[$pid]:-}"
if [[ -z "$etimes" || ! "$etimes" =~ ^[0-9]+$ ]]; then
etimes="-1"
_mlp_human="?"
else
_mlp_fmt_etime "$etimes"
fi
# Comando real (cmdline completa) del pid; dice QUE es realmente un
# "python3"/"node" generico. Se recorta para no romper la tabla.
cmd="${argmap[$pid]:-}"
[[ -z "$cmd" ]] && cmd="-"
cmd="${cmd:0:90}"
printf -v row '%s\t%s\t%s\t%s\t%s\t%s\t%s' "$etimes" "$ip" "$port" "$pname" "$pid" "$_mlp_human" "$cmd"
rows+=("$row")
# Avanzar mas alla del match actual para no repetir el primer pid.
# Guard: si el recorte no cambia `s`, cortar para no colgar nunca.
prev_s="$s"
s="${s#*"$needle"}"
[[ "$s" == "$prev_s" ]] && break
done
# Si el formato fue inesperado y no se parseo ningun par, fila placeholder.
if (( found_any == 0 )); then
printf -v row '%s\t%s\t%s\t%s\t%s\t%s\t%s' "-1" "$ip" "$port" "-" "-" "?" "-"
rows+=("$row")
fi
done < <(ss -H -tlnp 2>/dev/null)
# Estilo de cabecera (negrita) si la terminal lo soporta.
local bold="" reset=""
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1; then
bold=$(tput bold 2>/dev/null || true)
reset=$(tput sgr0 2>/dev/null || true)
fi
# Anchos fijos para alineacion estable (no usamos column -t). La ultima
# columna (CMD) es libre: muestra la cmdline real del proceso.
local fmt='%-26s %-7s %-16s %-8s %-13s %s\n'
# shellcheck disable=SC2059
printf "${bold}${fmt}${reset}" "IP" "PUERTO" "PROCESO" "PID" "TIEMPO ACTIVO" "CMD"
if (( ${#rows[@]} == 0 )); then
printf '(sin sockets TCP en escucha)\n'
return 0
fi
# Ordenar por la primera columna (etimes) numerica descendente y emitir las
# 5 columnas visibles (descartando la columna de orden).
printf '%s\n' "${rows[@]}" \
| sort -t$'\t' -k1,1nr \
| while IFS=$'\t' read -r _etimes ip port pname pid human cmd; do
# shellcheck disable=SC2059
printf "$fmt" "$ip" "$port" "$pname" "$pid" "$human" "$cmd"
done
}
monitor_listening_ports() {
local interval=1
local once=0
# Parseo de flags.
while (( $# > 0 )); do
case "$1" in
--interval)
interval="${2:-1}"
shift 2
;;
--interval=*)
interval="${1#*=}"
shift
;;
--once)
once=1
shift
;;
-h|--help)
cat <<'USAGE'
monitor_listening_ports [--interval N] [--once]
--interval N Segundos entre refrescos (default: 1, acepta decimales).
--once Imprime un solo frame de la tabla y termina (exit 0).
Tabla de sockets TCP en escucha (LISTEN) ordenada por tiempo de vida del
proceso dueño (descendente). Columnas: IP | PUERTO | PROCESO | PID | TIEMPO ACTIVO.
USAGE
return 0
;;
*)
printf 'monitor_listening_ports: argumento desconocido: %s\n' "$1" >&2
return 1
;;
esac
done
# Dependencias minimas.
if ! command -v ss >/dev/null 2>&1; then
printf 'monitor_listening_ports: requiere `ss` (paquete iproute2)\n' >&2
return 1
fi
if ! command -v ps >/dev/null 2>&1; then
printf 'monitor_listening_ports: requiere `ps` (paquete procps)\n' >&2
return 1
fi
# Modo single-frame: util para tests y para `fn run` sin colgar.
if (( once == 1 )); then
_mlp_render_frame
return 0
fi
# Modo bucle interactivo: oculta cursor y lo restaura + limpia al salir.
local have_tput=0
command -v tput >/dev/null 2>&1 && have_tput=1
_mlp_cleanup() {
if (( have_tput == 1 )); then
tput cnorm 2>/dev/null || true # restaurar cursor
tput sgr0 2>/dev/null || true # resetear atributos
fi
printf '\n'
}
trap '_mlp_cleanup; trap - INT TERM EXIT; return 0 2>/dev/null || exit 0' INT TERM EXIT
(( have_tput == 1 )) && tput civis 2>/dev/null || true # ocultar cursor
# Limpiamos la pantalla UNA sola vez al entrar. A partir de aqui cada frame
# se computa COMPLETO en una variable y luego se pinta con doble-buffer:
# cursor a home (\033[H), volcado del frame, y clear-to-end (\033[J) para
# borrar restos de un frame anterior mas largo. Asi nunca hay un instante
# con la pantalla vacia mientras se recolectan los datos.
printf '\033[2J'
local frame
while true; do
frame=$(
printf 'monitor_listening_ports — %s — intervalo %ss — orden: TIEMPO ACTIVO desc (Ctrl-C para salir)\n\n' \
"$(date '+%d/%m/%Y %H:%M:%S')" "$interval"
_mlp_render_frame
)
printf '\033[H' # cursor al inicio (sin borrar todavia)
printf '%s\n' "$frame" # volcar el frame ya calculado de golpe
printf '\033[J' # borrar de aqui al final (restos del frame previo)
sleep "$interval" || break
done
}
# Auto-invocacion cuando se ejecuta como script (no al hacer source).
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
monitor_listening_ports "$@"
fi
@@ -0,0 +1,62 @@
---
name: open_onlyoffice_file
kind: function
lang: bash
domain: shell
version: "1.0.0"
purity: impure
signature: "open_onlyoffice_file(file_path: string, instance: string = demo) -> json"
description: "Abre un archivo en una INSTANCIA AISLADA de ONLYOFFICE Desktop Editors (Linux/X11) sin perturbar la instancia personal del usuario. Cada 'instance' (slot, default demo) usa su propio HOME=/tmp/oo_<instance>, XDG_RUNTIME_DIR y XDG_CONFIG_HOME bajo /tmp, lo que rompe el single-instance lock de ONLYOFFICE y permite una ventana propia en vez de una pestaña en la instancia del usuario. Espera la ventana por evento (xdotool, basename del archivo, timeout ~25s) sin sleep en foreground. Idempotente: si ya hay ventana para ese basename, no relanza y devuelve el wid existente. NO crea archivos: si file_path no existe, falla. Imprime una linea JSON con instance, file (ruta absoluta), wid (hex), pid y status (open|timeout)."
tags: [onlyoffice, desktop, x11, shell]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: file_path
desc: "ruta (relativa o absoluta) al archivo a abrir; DEBE existir, esta funcion no crea archivos. Se normaliza con readlink -f y se busca la ventana por su basename"
- name: instance
desc: "nombre del slot aislado (default: demo). Determina el env: HOME=/tmp/oo_<instance>, XDG_RUNTIME_DIR=/tmp/oo_<instance>_run, XDG_CONFIG_HOME=/tmp/oo_<instance>/.config. Usa el MISMO instance en reload/close para operar la misma instancia"
output: "una linea JSON a stdout: {\"instance\":\"<i>\",\"file\":\"<abs>\",\"wid\":\"<hex>|null\",\"pid\":<n>|null,\"status\":\"open\"|\"timeout\"}. Exit 0 si abrio (status open), exit 1 si la ventana no aparecio en el timeout (status timeout) o falta dependencia/archivo, exit 2 si falta el argumento file_path"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/shell/open_onlyoffice_file.sh"
---
## Ejemplo
```bash
# Como script directo (slot 'demo' por defecto)
bash bash/functions/shell/open_onlyoffice_file.sh /tmp/demo_reload.xlsx
# Slot nombrado distinto (ventana propia, no perturba la instancia personal)
bash bash/functions/shell/open_onlyoffice_file.sh /tmp/informe.docx reporte
# Via fn run
./fn run open_onlyoffice_file_bash_shell /tmp/demo_reload.xlsx demo
# Sourceado, capturando el wid del JSON
source bash/functions/shell/open_onlyoffice_file.sh
out=$(open_onlyoffice_file /tmp/demo_reload.xlsx demo)
echo "$out"
# {"instance":"demo","file":"/tmp/demo_reload.xlsx","wid":"0x3c00007","pid":12345,"status":"open"}
```
## Cuando usarla
- Cuando necesites **abrir un archivo en ONLYOFFICE Desktop desde terminal en su propia ventana aislada**, sin que se agregue como pestaña a la instancia personal del usuario.
- Como primer paso de un flujo automatizado open -> (editas el archivo en disco) -> `reload_onlyoffice_file` -> `close_onlyoffice_instance`.
- Cuando quieras un slot reproducible por nombre (`instance`) que reuse la misma instancia aislada entre llamadas (reabrir rapido en vez de arrancar el motor de cero).
## Gotchas
- **ONLYOFFICE Desktop es single-instance por usuario**: sin el slot aislado (HOME/XDG_RUNTIME_DIR propios), un segundo lanzamiento se reenvia a la instancia viva y abre el archivo como PESTAÑA, no ventana nueva. El lock NO se rompe con XDG_CONFIG_HOME solo; SI con HOME + XDG_RUNTIME_DIR propios. Esta funcion ya aplica esa convencion.
- **NO hay reload nativo de cambios externos** (GitHub Issue #2313 abierto, no implementado). Esta funcion solo abre; para reflejar ediciones hechas en disco hay que cerrar+reabrir con `reload_onlyoffice_file`.
- **NO crea archivos**: si `file_path` no existe, falla con exit 1. Crea el archivo por tu cuenta antes de llamar.
- **El slot vive en /tmp**: los dirs `/tmp/oo_<instance>*` se pierden al reiniciar el PC (tmpfs en muchos sistemas). No guardes nada importante ahi; es estado desechable de la instancia aislada.
- **Requiere X11 + wmctrl + xdotool**: no funciona en Wayland puro sin XWayland (xdotool no encontrara la ventana). La funcion comprueba `command -v` de las 3 deps y falla claro si falta alguna.
- **El pid reportado es el del launcher** (`onlyoffice-desktopeditors`), que puede reexec/fork al proceso real `DesktopEditors`; sirve como referencia best-effort, no para `kill` fiable (usa `close_onlyoffice_instance`, que localiza el proceso real por su HOME).
- **Idempotencia por basename**: si ya existe una ventana cuyo titulo contiene el basename del archivo (lo abrio el usuario en su instancia personal, por ejemplo), la funcion la considera "ya abierta" y devuelve ese wid sin relanzar. Usa un basename unico para el slot de pruebas si quieres evitar colisiones.
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# open_onlyoffice_file — abre un archivo en una INSTANCIA AISLADA de ONLYOFFICE
# Desktop Editors (Linux/X11), sin perturbar la instancia personal del usuario.
#
# Funcion impura: lanza un proceso GUI, lee estado de ventanas (xdotool) y
# escribe directorios en /tmp. Imprime una linea JSON con el resultado.
#
# Por que "instancia aislada": ONLYOFFICE Desktop es single-instance por
# usuario — un segundo `onlyoffice-desktopeditors <file>` se reenvia a la
# instancia viva y abre el archivo como PESTAÑA en su ventana. El lock
# single-instance NO se rompe con XDG_CONFIG_HOME, pero SI se rompe lanzando
# con HOME y XDG_RUNTIME_DIR propios. Por eso cada "slot" nombrado (instance)
# usa su propio HOME/XDG_RUNTIME_DIR/XDG_CONFIG_HOME bajo /tmp.
# Sin -e: las busquedas de ventana (xdotool search) pueden no matchear y
# devolver exit !=0; no deben abortar la funcion. -u y pipefail se mantienen.
set -uo pipefail
open_onlyoffice_file() {
local file_path="${1:-}"
local instance="${2:-demo}"
if [[ -z "$file_path" ]]; then
echo "open_onlyoffice_file: falta <file_path>" >&2
echo "uso: open_onlyoffice_file <file_path> [instance]" >&2
return 2
fi
# 1. Dependencias del sistema.
local dep
for dep in onlyoffice-desktopeditors wmctrl xdotool; do
if ! command -v "$dep" >/dev/null 2>&1; then
echo "open_onlyoffice_file: falta dependencia '$dep' (instala el paquete correspondiente)" >&2
return 1
fi
done
# 2. El archivo DEBE existir — esta funcion no crea archivos.
if [[ ! -f "$file_path" ]]; then
echo "open_onlyoffice_file: el archivo no existe: $file_path (esta funcion no crea archivos)" >&2
return 1
fi
# Ruta absoluta y basename para titular/buscar la ventana.
local abs_path base
abs_path=$(readlink -f -- "$file_path")
base=$(basename -- "$abs_path")
# 3. Slot aislado: HOME/XDG_RUNTIME_DIR/XDG_CONFIG_HOME propios bajo /tmp.
local oo_home="/tmp/oo_${instance}"
local oo_run="/tmp/oo_${instance}_run"
local oo_cfg="${oo_home}/.config"
mkdir -p "$oo_home" "$oo_cfg" "$oo_run"
chmod 700 "$oo_run" 2>/dev/null || true
# 4. Idempotencia: si ya hay ventana para ese basename, no relanzar.
local existing_wid
existing_wid=$(xdotool search --name -- "$base" 2>/dev/null | head -1 || true)
if [[ -n "$existing_wid" ]]; then
local wid_hex
wid_hex=$(printf '0x%x' "$existing_wid" 2>/dev/null || echo "$existing_wid")
printf '{"instance":"%s","file":"%s","wid":"%s","pid":null,"status":"open"}\n' \
"$instance" "$abs_path" "$wid_hex"
return 0
fi
# 5. Lanzar la instancia aislada con su env propio. setsid lo desacopla de
# la terminal; redirige todo a un log del slot.
env HOME="$oo_home" XDG_RUNTIME_DIR="$oo_run" XDG_CONFIG_HOME="$oo_cfg" \
setsid onlyoffice-desktopeditors "$abs_path" \
>"/tmp/oo_${instance}.log" 2>&1 </dev/null &
local launch_pid=$!
# 6. Esperar la ventana por evento (NUNCA sleep en foreground).
# ~25s con read -t 0.3 => ~83 iteraciones.
local wid="" i=0 max=83
while [[ $i -lt $max ]]; do
wid=$(xdotool search --name -- "$base" 2>/dev/null | head -1 || true)
[[ -n "$wid" ]] && break
read -t 0.3 _ </dev/null 2>/dev/null || true
i=$((i + 1))
done
if [[ -z "$wid" ]]; then
printf '{"instance":"%s","file":"%s","wid":null,"pid":%s,"status":"timeout"}\n' \
"$instance" "$abs_path" "$launch_pid"
return 1
fi
local wid_hex
wid_hex=$(printf '0x%x' "$wid" 2>/dev/null || echo "$wid")
# El pid del proceso real (DesktopEditors) puede diferir del launcher; el
# launcher reexec/fork. Reportamos el pid del launcher (best-effort).
printf '{"instance":"%s","file":"%s","wid":"%s","pid":%s,"status":"open"}\n' \
"$instance" "$abs_path" "$wid_hex" "$launch_pid"
return 0
}
# Ejecutable directo: `bash open_onlyoffice_file.sh <file> [instance]`.
# Sourceado: define la funcion sin ejecutarla.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
open_onlyoffice_file "$@"
fi
@@ -0,0 +1,61 @@
---
name: reload_onlyoffice_file
kind: function
lang: bash
domain: shell
version: "1.0.0"
purity: impure
signature: "reload_onlyoffice_file(file_path: string, instance: string = demo) -> json"
description: "Recarga en la ventana de ONLYOFFICE Desktop Editors los datos que el caller edito EN DISCO, cerrando y reabriendo el archivo en la INSTANCIA AISLADA (slot). Es la funcion estrella del grupo: ONLYOFFICE no recarga cambios externos del archivo (GitHub Issue #2313 abierto, no implementado), asi que la unica forma de mostrar datos editados fuera de la app es cerrar la ventana (wmctrl -ic) y reabrir (ONLYOFFICE lee fresco del disco al abrir). Localiza la ventana por basename, la cierra y espera a que desaparezca (timeout ~10s), relanza con el env del slot aislado y espera la ventana nueva (timeout ~25s), todo por evento sin sleep en foreground. Si no habia ventana previa, actua como open. NO edita el archivo: el caller lo edita antes de llamar. Imprime JSON con wid_old, wid_new, reopened, elapsed_s y status (reloaded|timeout)."
tags: [onlyoffice, desktop, x11, shell]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: file_path
desc: "ruta (relativa o absoluta) al archivo cuya ventana se recarga; DEBE existir. El caller ya lo edito en disco antes de llamar. Se busca la ventana por su basename"
- name: instance
desc: "nombre del slot aislado (default: demo); debe coincidir con el usado en open_onlyoffice_file para reusar la misma instancia. Determina HOME/XDG_RUNTIME_DIR/XDG_CONFIG_HOME bajo /tmp"
output: "una linea JSON a stdout: {\"instance\":\"<i>\",\"file\":\"<abs>\",\"wid_old\":\"<hex>|null\",\"wid_new\":\"<hex>|null\",\"reopened\":true|false,\"elapsed_s\":<n>,\"status\":\"reloaded\"|\"timeout\"}. Exit 0 si reabrio (status reloaded), exit 1 si la ventana nueva no aparecio en el timeout (status timeout) o falta dependencia/archivo, exit 2 si falta file_path"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/shell/reload_onlyoffice_file.sh"
---
## Ejemplo
```bash
# Flujo tipico: editas el .xlsx en disco con tu herramienta y refrescas la vista
# (este ejemplo asume que /tmp/demo_reload.xlsx ya esta abierto en el slot demo)
bash bash/functions/shell/reload_onlyoffice_file.sh /tmp/demo_reload.xlsx demo
# Via fn run
./fn run reload_onlyoffice_file_bash_shell /tmp/demo_reload.xlsx demo
# Sourceado, dentro de un bucle de "editar en disco -> ver en ONLYOFFICE"
source bash/functions/shell/reload_onlyoffice_file.sh
# ... el caller modifica /tmp/demo_reload.xlsx por su cuenta ...
out=$(reload_onlyoffice_file /tmp/demo_reload.xlsx demo)
echo "$out"
# {"instance":"demo","file":"/tmp/demo_reload.xlsx","wid_old":"0x3c00007","wid_new":"0x3c0000b","reopened":true,"elapsed_s":4,"status":"reloaded"}
```
## Cuando usarla
- Cuando **editaste un archivo en disco fuera de ONLYOFFICE** (script, otra herramienta, generador) y necesitas que la ventana de ONLYOFFICE muestre los datos nuevos: esta funcion cierra y reabre para forzar la lectura fresca del disco.
- En bucles de iteracion rapida "modificar el archivo -> ver el resultado en ONLYOFFICE" sin tocar la instancia personal del usuario.
- Como reemplazo del reload nativo inexistente (Issue #2313): es la unica via fiable de refrescar la vista desde disco.
## Gotchas
- **No edita el archivo**: solo recarga la ventana desde disco. El caller es responsable de modificar el archivo ANTES de llamar; si no lo modifico, reabrira los mismos datos.
- **ONLYOFFICE no tiene reload de cambios externos** (GitHub Issue #2313 abierto, no implementado): por eso esta funcion existe y hace cerrar+reabrir. No hay forma "in-place" de refrescar.
- **`wmctrl -ic` puede disparar el dialogo "Guardar cambios"** si el usuario edito EN la app (no en disco) y hay cambios sin guardar en esa ventana. El flujo previsto es editar SOLO en disco con la ventana sin tocar; si editaste en la app, guarda o descarta antes, o el cierre se quedara esperando interaccion (la funcion saldra por timeout).
- **Single-instance + slot aislado**: usa el mismo `instance` que en `open_onlyoffice_file`. Con HOME/XDG_RUNTIME_DIR propios el relaunch reenvia a la instancia aislada viva y reabre rapido; con env por defecto se reenviaria a la instancia personal del usuario (no deseado).
- **El slot vive en /tmp**: `/tmp/oo_<instance>*` se pierde al reiniciar el PC. Estado desechable.
- **Requiere X11 + wmctrl + xdotool**: no funciona en Wayland puro sin XWayland. Comprueba las 3 deps y falla claro si falta alguna.
- **Carrera de cierre**: si la ventana tarda mas de ~10s en cerrarse (dialogo modal, app ocupada), la funcion continua igualmente al relaunch; el resultado puede acabar en `timeout` si la ventana nueva no aparece a tiempo.
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# reload_onlyoffice_file — cierra y reabre un archivo en la INSTANCIA AISLADA de
# ONLYOFFICE Desktop Editors para que la ventana muestre los datos editados
# EN DISCO por el caller (ONLYOFFICE no recarga cambios externos: GitHub Issue
# #2313 abierto, no implementado — la unica forma es cerrar+reabrir).
#
# Funcion impura: cierra una ventana GUI (wmctrl), relanza un proceso y espera
# la ventana nueva por evento. NO edita el archivo — solo recarga la ventana
# desde el disco. El caller edita el archivo antes de llamar a esta funcion.
#
# Instancia aislada (slot): mismo HOME/XDG_RUNTIME_DIR/XDG_CONFIG_HOME que usa
# open_onlyoffice_file, para que el relaunch reenvie a la instancia aislada
# viva y reabra rapido en vez de arrancar el motor de cero.
# Sin -e: busquedas de ventana (xdotool/wmctrl) pueden no matchear; no deben
# abortar la funcion. -u y pipefail se mantienen.
set -uo pipefail
reload_onlyoffice_file() {
local file_path="${1:-}"
local instance="${2:-demo}"
if [[ -z "$file_path" ]]; then
echo "reload_onlyoffice_file: falta <file_path>" >&2
echo "uso: reload_onlyoffice_file <file_path> [instance]" >&2
return 2
fi
# 1. Dependencias del sistema.
local dep
for dep in onlyoffice-desktopeditors wmctrl xdotool; do
if ! command -v "$dep" >/dev/null 2>&1; then
echo "reload_onlyoffice_file: falta dependencia '$dep' (instala el paquete correspondiente)" >&2
return 1
fi
done
# 2. El archivo DEBE existir — no editamos ni creamos archivos.
if [[ ! -f "$file_path" ]]; then
echo "reload_onlyoffice_file: el archivo no existe: $file_path" >&2
return 1
fi
local abs_path base
abs_path=$(readlink -f -- "$file_path")
base=$(basename -- "$abs_path")
# 3. Slot aislado (identico a open_onlyoffice_file).
local oo_home="/tmp/oo_${instance}"
local oo_run="/tmp/oo_${instance}_run"
local oo_cfg="${oo_home}/.config"
mkdir -p "$oo_home" "$oo_cfg" "$oo_run"
chmod 700 "$oo_run" 2>/dev/null || true
local start_ts
start_ts=$(date +%s)
# 4. Localizar la ventana actual del archivo por basename.
local wid_old=""
wid_old=$(xdotool search --name -- "$base" 2>/dev/null | head -1 || true)
local wid_old_hex="null"
if [[ -n "$wid_old" ]]; then
wid_old_hex=$(printf '0x%x' "$wid_old" 2>/dev/null || echo "$wid_old")
# 5. Cerrar la ventana (sin teclear en la app) y esperar a que
# desaparezca (~10s con read -t 0.3 => ~33 iteraciones).
wmctrl -ic "$wid_old" 2>/dev/null || true
local g=0 gmax=33
while [[ $g -lt $gmax ]]; do
if ! xdotool search --name -- "$base" 2>/dev/null | grep -q .; then
break
fi
read -t 0.3 _ </dev/null 2>/dev/null || true
g=$((g + 1))
done
fi
# 6. Relanzar con el env del slot aislado. (Si no habia ventana previa,
# esto actua simplemente como open.)
env HOME="$oo_home" XDG_RUNTIME_DIR="$oo_run" XDG_CONFIG_HOME="$oo_cfg" \
setsid onlyoffice-desktopeditors "$abs_path" \
>"/tmp/oo_${instance}.log" 2>&1 </dev/null &
# 7. Esperar la ventana nueva por evento (~25s => ~83 iteraciones).
local wid_new="" i=0 max=83
while [[ $i -lt $max ]]; do
wid_new=$(xdotool search --name -- "$base" 2>/dev/null | head -1 || true)
# Si hubo ventana previa, aceptar cualquier wid que aparezca (el old
# ya se cerro; el nuevo puede reutilizar id o no). Si no la hubo,
# cualquier wid sirve.
[[ -n "$wid_new" ]] && break
read -t 0.3 _ </dev/null 2>/dev/null || true
i=$((i + 1))
done
local now_ts elapsed
now_ts=$(date +%s)
elapsed=$((now_ts - start_ts))
if [[ -z "$wid_new" ]]; then
printf '{"instance":"%s","file":"%s","wid_old":"%s","wid_new":null,"reopened":false,"elapsed_s":%s,"status":"timeout"}\n' \
"$instance" "$abs_path" "$wid_old_hex" "$elapsed"
return 1
fi
local wid_new_hex
wid_new_hex=$(printf '0x%x' "$wid_new" 2>/dev/null || echo "$wid_new")
printf '{"instance":"%s","file":"%s","wid_old":"%s","wid_new":"%s","reopened":true,"elapsed_s":%s,"status":"reloaded"}\n' \
"$instance" "$abs_path" "$wid_old_hex" "$wid_new_hex" "$elapsed"
return 0
}
# Ejecutable directo o sourceado.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
reload_onlyoffice_file "$@"
fi
BIN
View File
Binary file not shown.
+2
View File
@@ -56,6 +56,8 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [recon](recon.md) | 8 | Reconocimiento de red OSINT: whois, rdap, dns (dig), ping, traceroute, nmap por perfiles. Cada scan se archiva en OSINT (nota vault + tabla DuckDB network_scans) via el sink save_scan_to_osint o el pipeline one-shot recon_osint. Perfiles nmap pesados (full-tcp/vuln/udp-top) en segundo plano. No es framework de explotacion; solo hosts autorizados |
| [osint-passive](osint-passive.md) | 8 | Recoleccion OSINT pasiva (fuentes publicas, no intrusiva): EXIF/PDF metadata, whois RDAP, DNS, subdominios crt.sh, guess emails, username enumeration, search dorks |
| [osint-enrich](osint-enrich.md) | 3 | Orquestadores de enriquecimiento OSINT: componen osint-passive para aumentar datapoints de personas (emails/usernames/dorks), orgs (whois+dns+subdominios) y metadatos de attachments |
| [market-intel](market-intel.md) | 8 | Inteligencia de mercado para captacion de clientes: scrapers de tendencias de productos/nichos (Amazon, Google Trends, TikTok, AliExpress) + precios de competencia, aterrizados en Postgres (pg_insert_rows/pg_apply_sql) y analizados en Metabase. Dispatcher ingest_market_trends invocado por dag_engine. TikTok/AliExpress por HTTP caen (anti-bot); pendiente browser CDP |
| [onlyoffice](onlyoffice.md) | 3 | Operar ONLYOFFICE Desktop Editors (binario onlyoffice-desktopeditors) en Linux/X11 desde terminal via instancia aislada (slot HOME=/tmp/oo_<instance>): abrir un archivo en ventana propia, cerrar+reabrir para mostrar datos editados en disco (no hay reload nativo, Issue #2313), y matar el proceso del slot. Solo gestiona la ventana, NO edita ni crea archivos. Requiere X11 + wmctrl + xdotool. No confundir con el Document Server (web/Docker) |
## Como anadir grupo
+54
View File
@@ -0,0 +1,54 @@
# market-intel
Inteligencia de mercado para captación de clientes: scrapers de señales de demanda y
tendencias de productos/nichos desde varias fuentes públicas, más vigilancia de precios de
la competencia, aterrizados en Postgres y analizados con Metabase. Scheduling con
`dag_engine`. Origen: proyecto `captacion_clientes`.
## Funciones
| ID | Firma corta | Qué hace |
|---|---|---|
| `scrape_amazon_bestsellers_py_datascience` | `(marketplace, categories, list_type, max_items)` | Amazon Best Sellers + Movers & Shakers (ranking real de demanda). HTTP, funciona. |
| `scrape_google_trends_py_datascience` | `(keywords, geo, timeframe, include_related)` | Interés de búsqueda (0-100) + rising/top via pytrends. Backoff ante 429. |
| `scrape_tiktok_creative_py_datascience` | `(country, kind, limit, period)` | TikTok Creative Center (hashtags/songs/creators). **Bloqueado por anti-bot vía HTTP**; pendiente browser CDP. |
| `scrape_aliexpress_trending_py_datascience` | `(query, category, limit, ship_to)` | Productos populares AliExpress (orders/rating). **Bloqueado por captcha vía HTTP**; pendiente browser CDP. |
| `scrape_competitor_prices_py_datascience` | `(targets) -> list[dict]` | Precio actual de una lista de URLs de competidores (cascada: selector → JSON-LD → meta → heurística). |
| `pg_insert_rows_py_infra` | `(dsn, table, rows, add_snapshot_date=True)` | Insert append-only por lote en Postgres (execute_values parametrizado, añade snapshot_date). |
| `pg_apply_sql_py_infra` | `(dsn, sql_path) -> int` | Aplica un `.sql` de migración a Postgres (idempotente con IF NOT EXISTS). |
| `ingest_market_trends_py_pipelines` | `(source)` | Dispatcher: scrapea una fuente y la aterriza en su tabla. Lo invoca `dag_engine`. |
## Ejemplo canónico (end-to-end)
```bash
# 1. (una vez) Stack Metabase + Postgres en Docker
fn run init_metabase_go_infra --project captacion --metabase-port 3030 --pg-port 5433 \
--pg-user captacion --pg-password "$(pass show captacion/postgres | head -1)"
docker exec captacion-postgres psql -U captacion -d metabase -c "CREATE DATABASE trends OWNER captacion"
# 2. (una vez) Aplicar el schema
python3 -c "import sys; sys.path.insert(0,'python/functions'); from infra import pg_apply_sql; \
pg_apply_sql('postgresql://captacion:PW@localhost:5433/trends', 'projects/captacion_clientes/db/migrations/001_schema.sql')"
# 3. Ingesta una fuente (manual o vía dag_engine)
fn run ingest_market_trends_py_pipelines amazon
fn run ingest_market_trends_py_pipelines google_trends
# 4. dag_engine lo hace solo: dags market-intel-daily (06:30) y competitor-prices-hourly
```
## Fronteras
- NO hace explotación ni bypass agresivo de anti-bot: TikTok/AliExpress por HTTP-directo
caen desde datacenter; la vía robusta es el browser MCP/CDP (grupo `navegator`/`web-proxy`,
doctrina `flow_replay.md`), aún no implementada para estas dos fuentes.
- NO es un grupo de visualización: el análisis vive en Metabase (grupo `metabase`).
- NO gestiona el scheduling: eso es `dag_engine` (grupo `scheduler`).
- El DSN de Postgres y credenciales NO se hardcodean: van en `pass`/`.env` del proyecto.
## Notas
- Las tablas de `trends` son append-only particionadas por `snapshot_date` — pensadas para
series temporales en Metabase (qué tendencia sube/baja). No correr en bucle apretado.
- `competitor_prices` se nutre de la tabla `competitor_targets` (el usuario inserta los
objetivos a vigilar: competidor + product_key + URL).
+79
View File
@@ -0,0 +1,79 @@
# Capability group: onlyoffice
Operar **ONLYOFFICE Desktop Editors** (binario `/usr/bin/onlyoffice-desktopeditors`) en Linux/X11 desde terminal, gestionando la **ventana** de los archivos sin perturbar la instancia personal del usuario.
Este grupo NO es el ONLYOFFICE **Document Server** (web/Docker) — para eso ver `start_documentserver_bash_infra`, `documentserver_health_go_infra`, `onlyoffice_command_service_go_infra` y compañia. Este grupo es el editor de **escritorio**.
## Convencion de instancia aislada (slot)
ONLYOFFICE Desktop es **single-instance por usuario**: un segundo `onlyoffice-desktopeditors <file>` se reenvia a la instancia viva y abre el archivo como PESTAÑA en su ventana, no como ventana nueva. El lock single-instance NO se rompe con `XDG_CONFIG_HOME`, pero SI se rompe lanzando con `HOME` y `XDG_RUNTIME_DIR` propios.
Por eso las 3 funciones comparten un "slot" nombrado por `instance` (string, default `demo`):
```
HOME=/tmp/oo_<instance>
XDG_RUNTIME_DIR=/tmp/oo_<instance>_run (mkdir -p + chmod 700)
XDG_CONFIG_HOME=/tmp/oo_<instance>/.config
```
Lanzamiento canonico (identico en open y reload):
```bash
env HOME=/tmp/oo_<instance> XDG_RUNTIME_DIR=/tmp/oo_<instance>_run \
XDG_CONFIG_HOME=/tmp/oo_<instance>/.config \
setsid onlyoffice-desktopeditors <file> >/tmp/oo_<instance>.log 2>&1 </dev/null &
```
Usar el MISMO `instance` en todas las operaciones del slot: asi el relaunch reenvia a la instancia aislada viva y reabre rapido en vez de arrancar el motor de cero.
## Funciones
| ID | Firma corta | Que hace |
|---|---|---|
| `open_onlyoffice_file_bash_shell` | `open_onlyoffice_file <file> [instance]` | Abre un archivo existente en el slot aislado; espera la ventana por basename (~25s); JSON con wid/status. Idempotente, NO crea archivos. |
| `reload_onlyoffice_file_bash_shell` | `reload_onlyoffice_file <file> [instance]` | **Funcion estrella**: cierra (wmctrl -ic) y reabre el archivo en el slot para mostrar datos editados EN DISCO (ONLYOFFICE no tiene reload nativo, Issue #2313). JSON con wid_old/wid_new/elapsed_s/status. NO edita el archivo. |
| `close_onlyoffice_instance_bash_shell` | `close_onlyoffice_instance [instance] [--purge]` | Mata los procesos DesktopEditors del slot (por HOME=/tmp/oo_<instance> en /proc), SIGTERM->SIGKILL; con --purge borra /tmp/oo_<instance>*. JSON con killed_pids/status. |
## Ejemplo canonico (end-to-end)
Flujo completo "abrir -> editar el archivo en disco -> recargar la vista -> cerrar", todo sobre un slot aislado `demo` que no toca la instancia personal del usuario:
```bash
cd /home/enmanuel/fn_registry
# 0. El caller prepara el archivo (esta funcion NO crea archivos)
printf 'a,b\n1,2\n' > /tmp/demo_reload.csv
# 1. Abrir en el slot aislado 'demo' -> ventana propia
./fn run open_onlyoffice_file_bash_shell /tmp/demo_reload.csv demo
# {"instance":"demo","file":"/tmp/demo_reload.csv","wid":"0x3c00007","pid":12345,"status":"open"}
# 2. El caller edita el archivo EN DISCO (script, generador, otra herramienta)
printf 'a,b\n1,2\n3,4\n5,6\n' > /tmp/demo_reload.csv
# 3. Recargar la ventana para que muestre los datos nuevos (cierra+reabre)
./fn run reload_onlyoffice_file_bash_shell /tmp/demo_reload.csv demo
# {"instance":"demo","file":"/tmp/demo_reload.csv","wid_old":"0x3c00007","wid_new":"0x3c0000b","reopened":true,"elapsed_s":4,"status":"reloaded"}
# 4. Cerrar la instancia aislada y limpiar su estado
./fn run close_onlyoffice_instance_bash_shell demo --purge
# {"instance":"demo","killed_pids":[12345],"purged":true,"status":"closed"}
```
## Fronteras (que NO hace el grupo)
- **NO edita ni crea archivos**. Solo gestiona la VENTANA (abrir, cerrar+reabrir, matar proceso). El contenido lo prepara y modifica el caller en disco.
- **NO es el Document Server** (web/Docker/JWT/Command Service). Eso es otro conjunto de funciones (`*documentserver*`, `*onlyoffice_jwt*`, `onlyoffice_command_service_*`).
- **NO recarga in-place**: ONLYOFFICE Desktop no soporta reload de cambios externos (Issue #2313 abierto). `reload_onlyoffice_file` lo emula con cerrar+reabrir; no hay alternativa "sin parpadeo".
- **NO toca la instancia personal del usuario**: todo opera sobre el slot aislado (HOME=/tmp/oo_<instance>). `close` solo mata procesos cuyo HOME es del slot.
## Prerequisitos
- Linux con **X11** (o XWayland). En Wayland puro sin XWayland, `xdotool`/`wmctrl` no encuentran la ventana.
- Binarios en PATH: `onlyoffice-desktopeditors`, `wmctrl`, `xdotool`. Cada funcion comprueba `command -v` y falla con exit !=0 si falta alguno.
## Notas
- Las esperas son **por evento** (`xdotool search` + `read -t`), nunca `sleep` en foreground, para no colgar bajo `fn run` ni tests.
- El slot vive en `/tmp` y se pierde al reiniciar el PC (estado desechable). `--purge` lo borra explicitamente.
- `wmctrl -ic` puede disparar el dialogo "Guardar cambios" SOLO si se edito dentro de la app con cambios sin guardar; el flujo previsto edita en disco, asi que la ventana no tiene estado pendiente.
+10
View File
@@ -10,8 +10,18 @@ from .datascience import (
autocorrelation,
linspace,
)
from .scrape_amazon_bestsellers import scrape_amazon_bestsellers
from .scrape_google_trends import scrape_google_trends
from .scrape_competitor_prices import scrape_competitor_prices
from .scrape_tiktok_creative import scrape_tiktok_creative
from .scrape_aliexpress_trending import scrape_aliexpress_trending
__all__ = [
"scrape_amazon_bestsellers",
"scrape_google_trends",
"scrape_competitor_prices",
"scrape_tiktok_creative",
"scrape_aliexpress_trending",
"pearson",
"standardize",
"min_max_scale",
@@ -0,0 +1,274 @@
"""Scrapea productos trending de AliExpress conduciendo un Chrome real por CDP.
Variante que SI funciona frente al bloqueo por captcha: en vez de pedir el HTML
por HTTP (que devuelve un challenge/captcha para la busqueda de AliExpress),
abre una pestana en un Chrome con perfil real (puerto de remote debugging) que
ejecuta el JavaScript de la SPA y renderiza los productos. La extraccion se hace
con `cdp_eval` del registry, scrolleando para forzar el lazy-load de tarjetas.
Devuelve dicts con claves 1:1 con la tabla Postgres `aliexpress_trends`
(sin id/snapshot_date/scraped_at), listos para insertar.
"""
import json
import re
import time
import urllib.parse
import requests
from browser.cdp_eval import cdp_eval
# Expresion JS de extraccion. Se evalua una sola vez tras el scroll y devuelve
# JSON.stringify de la lista de filas. Tolerante: campos ausentes -> null, nunca
# aborta una tarjeta. Deduplica por product_id dentro del propio JS.
_EXTRACT_JS = r"""
(function () {
// 1. product_id desde el href: /item/<ID>.html, o promo (?productIds=<ID>:...
// o x_object_id=<ID>).
function productIdFromHref(href) {
if (!href) return null;
var m = href.match(/\/item\/(\d+)\.html/);
if (m) return m[1];
m = href.match(/[?&]productIds=(\d+)/);
if (m) return m[1];
m = href.match(/x_object_id(?:%3A|:|=)(\d+)/);
if (m) return m[1];
return null;
}
// 2. href absoluto al producto. Prefiere un <a href*="/item/"> dentro del card;
// si no, el href del propio anchor de la tarjeta.
function absUrl(href) {
if (!href) return null;
if (href.indexOf("//") === 0) return "https:" + href;
if (href.indexOf("http") === 0) return href;
return "https://www.aliexpress.com" + href;
}
// 3. precio EUR -> float (coma decimal ES). "0,33€" -> 0.33. "GRATIS" -> null.
function parsePrice(txt) {
if (!txt) return null;
// primer token monetario con € o EUR
var m = txt.match(/([\d.]+,\d+)\s*(?:€|EUR)/);
if (!m) m = txt.match(/(?:€|EUR)\s*([\d.]+,\d+)/);
if (!m) m = txt.match(/([\d.]+)\s*(?:€|EUR)/);
if (!m) return null;
var raw = m[1].replace(/\./g, "").replace(",", ".");
var v = parseFloat(raw);
return isFinite(v) ? v : null;
}
// 4. pedidos: "100K+ vendidos", "50.000+ vendidos", "1.000+ sold", "234 sold".
function parseOrders(txt) {
if (!txt) return null;
var m = txt.match(/([\d.,]+)\s*([KkMm])?\s*\+?\s*(?:vendidos|sold|orders|pedidos)/);
if (!m) return null;
var num = m[1].replace(/\./g, "").replace(/,/g, ".");
var val = parseFloat(num);
if (!isFinite(val)) return null;
var suf = (m[2] || "").toLowerCase();
if (suf === "k") val *= 1000;
else if (suf === "m") val *= 1000000;
return Math.round(val);
}
// 5. rating: primer "4.9" / "4,9" tras el bloque de precio (0-5).
function parseRating(txt) {
if (!txt) return null;
var matches = txt.match(/\b([0-5][.,]\d)\b/g);
if (!matches) return null;
for (var i = 0; i < matches.length; i++) {
var v = parseFloat(matches[i].replace(",", "."));
if (v >= 0 && v <= 5) return v;
}
return null;
}
var anchors = Array.prototype.slice.call(
document.querySelectorAll("a.search-card-item")
);
var seen = {};
var rows = [];
for (var i = 0; i < anchors.length; i++) {
var a = anchors[i];
var card = a.closest(".search-item-card-wrapper-gallery") || a;
// href al producto: primero un <a href*="/item/"> dentro del card.
var href = null;
var inner = card.querySelectorAll("a");
for (var j = 0; j < inner.length; j++) {
var h = inner[j].getAttribute("href") || "";
if (/\/item\/\d+\.html/.test(h)) { href = h; break; }
}
if (!href) href = a.getAttribute("href") || "";
var pid = productIdFromHref(href);
if (!pid || seen[pid]) continue;
seen[pid] = true;
var img = card.querySelector("img");
var title = img ? (img.getAttribute("alt") || "") : "";
if (!title) title = (a.innerText || "").trim();
title = (title || "").trim() || null;
var text = card.innerText || "";
rows.push({
product_id: pid,
title: title,
price: parsePrice(text),
currency: "EUR",
orders: parseOrders(text),
rating: parseRating(text),
url: absUrl(href)
});
}
return JSON.stringify(rows);
})()
"""
def cdp_scrape_aliexpress_trending(
query: str = "gadgets",
limit: int = 40,
ship_to: str = "ES",
port: int = 9222,
) -> list[dict]:
"""Scrapea productos trending de AliExpress via CDP sobre un Chrome real.
Abre una pestana en la busqueda de AliExpress ordenada por popularidad
(numero de pedidos), espera al render, scrollea para disparar el lazy-load
de tarjetas y extrae los productos con un unico `cdp_eval`.
Args:
query: Termino de busqueda. Tambien se usa como `category` en cada fila.
limit: Maximo de productos a devolver tras deduplicar por product_id.
ship_to: Codigo de pais de envio (afecta precios/moneda mostrados).
port: Puerto de remote debugging del Chrome con perfil real. Default 9222.
Returns:
Lista de dicts con claves exactas (1:1 con la tabla `aliexpress_trends`):
category, product_id, title, price, currency, orders, rating, url.
price es float|None, orders int|None, rating float|None; el resto str.
Raises:
RuntimeError: si no se puede abrir la pestana, si CDP devuelve un error
de evaluacion, o si el JSON de extraccion no se puede parsear.
"""
base = "http://localhost:%d" % port
target_url = (
"https://www.aliexpress.com/w/wholesale-%s.html"
"?SortType=total_tranpro_desc&shipCountry=%s"
% (urllib.parse.quote(query), urllib.parse.quote(ship_to))
)
# 1. Abrir pestana via DevTools HTTP API (esta build exige PUT en /json/new).
tab_id = ""
try:
new_url = "%s/json/new?%s" % (base, urllib.parse.quote(target_url, safe=""))
resp = requests.put(new_url, timeout=10)
if resp.status_code != 200:
# Fallback a POST por compatibilidad con builds antiguas.
resp = requests.post(new_url, timeout=10)
resp.raise_for_status()
tab = resp.json()
tab_id = tab.get("id", "")
if not tab_id:
raise RuntimeError("DevTools /json/new no devolvio id de pestana")
except Exception as exc: # noqa: BLE001 — red/HTTP/JSON
raise RuntimeError("no se pudo abrir pestana en %s: %s" % (base, exc))
substr = "aliexpress.com/w/wholesale-%s" % urllib.parse.quote(query)
try:
# 2. Esperar render inicial.
time.sleep(6.0)
# 3. Scroll en bucle para forzar lazy-load hasta tener >= limit tarjetas
# o hasta que el conteo deje de crecer (estabilizado).
count_js = (
'document.querySelectorAll("a.search-card-item").length'
)
prev = -1
stable = 0
for _ in range(15):
cdp_eval(
"window.scrollBy(0, 2500)",
port=port,
target_url_substr=substr,
)
time.sleep(1.2)
res = cdp_eval(count_js, port=port, target_url_substr=substr)
n = res.get("value") if res.get("ok") else None
n = int(n) if isinstance(n, (int, float)) else 0
if n >= limit:
break
if n <= prev:
stable += 1
if stable >= 2:
break
else:
stable = 0
prev = n
# 4. Extraer con un unico cdp_eval (devuelve JSON.stringify de las filas).
res = cdp_eval(_EXTRACT_JS, port=port, target_url_substr=substr)
if not res.get("ok"):
raise RuntimeError(
"cdp_eval fallo en la extraccion: %s" % res.get("error", "")
)
raw = res.get("value")
if not raw:
return []
try:
rows = json.loads(raw)
except Exception as exc: # noqa: BLE001 — JSON malformado
raise RuntimeError("JSON de extraccion invalido: %s" % exc)
# 5. Anadir category y truncar a limit. Saneo defensivo de tipos.
out: list[dict] = []
seen: set[str] = set()
for r in rows:
pid = r.get("product_id")
if not pid or pid in seen:
continue
seen.add(pid)
price = r.get("price")
orders = r.get("orders")
rating = r.get("rating")
out.append(
{
"category": query,
"product_id": str(pid),
"title": r.get("title"),
"price": float(price) if isinstance(price, (int, float)) else None,
"currency": r.get("currency") or "EUR",
"orders": int(orders) if isinstance(orders, (int, float)) else None,
"rating": float(rating) if isinstance(rating, (int, float)) else None,
"url": r.get("url"),
}
)
if len(out) >= limit:
break
return out
finally:
# 6. Cerrar la pestana siempre (best-effort).
if tab_id:
try:
requests.get("%s/json/close/%s" % (base, tab_id), timeout=5)
except Exception: # noqa: BLE001 — cierre best-effort
pass
if __name__ == "__main__":
import sys
q = sys.argv[1] if len(sys.argv) > 1 else "gadgets"
lim = int(sys.argv[2]) if len(sys.argv) > 2 else 40
products = cdp_scrape_aliexpress_trending(query=q, limit=lim, port=9222)
print("%d productos" % len(products))
print(json.dumps(products[:5], ensure_ascii=False, indent=2))
@@ -0,0 +1,81 @@
---
name: scrape_aliexpress_trending
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def scrape_aliexpress_trending(query: str | None = None, category: str | None = None, limit: int = 40, ship_to: str = 'ES') -> list[dict]"
description: "Capta productos populares de AliExpress como señal de e-commerce/dropshipping (orders, rating, precio). Hace una request HTTP a la página de listado ordenada por número de pedidos y extrae el JSON embebido en el HTML (window.runParams / _dida_config). Best-effort: ante anti-bot lanza RuntimeError, ante HTML sin JSON devuelve []. NUNCA inventa datos."
tags: [aliexpress, ecommerce, dropshipping, trends, market-intel, datascience]
params:
- name: query
desc: "Texto de búsqueda (ej. 'kitchen gadgets'). Si se da, manda en la URL sobre category."
- name: category
desc: "ID numérico de categoría AliExpress o slug. Ignorado si hay query. None usa un listado 'hot products' genérico."
- name: limit
desc: "Número máximo de productos a devolver. Default 40."
- name: ship_to
desc: "Código de país ISO-2 (ES, US, GB, DE, ...) que fija región y moneda via cookies de AliExpress. Default 'ES'."
output: "Lista de dicts con claves exactas (casan 1:1 con la tabla Postgres aliexpress_trends, sin id/snapshot_date/scraped_at): category (str|None), product_id (str), title (str|None), price (float|None), currency (str|None), orders (int|None), rating (float|None), url (str). Lista vacía si el HTML no traía JSON parseable."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [requests]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/datascience/scrape_aliexpress_trending.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.scrape_aliexpress_trending import scrape_aliexpress_trending
# Top productos por número de pedidos para una búsqueda concreta, enviando a España.
rows = scrape_aliexpress_trending(query="phone holder", limit=20, ship_to="ES")
for r in rows[:3]:
print(r["title"], "->", r["orders"], "pedidos |", r["price"], r["currency"])
# Cada dict (las 8 claves casan con la tabla aliexpress_trends):
# {"category": "phone holder", "product_id": "100500...", "title": "...",
# "price": 3.21, "currency": "EUR", "orders": 12000, "rating": 4.8,
# "url": "https://www.aliexpress.com/item/100500....html"}
```
## Cuando usarla
Cuando necesites una señal de qué productos están vendiendo bien en AliExpress para
research de dropshipping o market-intel: detectar tendencias, sourcing de productos
ganadores, o alimentar un histórico (tabla `aliexpress_trends`) que cruce orders /
rating / precio por categoría. Úsala antes de decidir un nicho o para vigilar
periódicamente una keyword. El output va directo a un `INSERT` Postgres (las 8 claves
coinciden con las columnas no autogeneradas).
## Gotchas
- **Anti-bot fuerte (CRÍTICO):** AliExpress bloquea agresivamente headless/datacenter
con captcha (`/_____tmd_____/punish`), 403/429 y fingerprinting. Desde una IP de
datacenter o un patrón de scraping evidente, esta función **lanzará `RuntimeError`**
con frecuencia. Para extracción fiable y sostenida, la alternativa robusta es el
**browser MCP/CDP con sesión real** (Chrome del usuario, cookies legítimas), no
`requests`. Esta función es la vía barata; si falla repetidamente, sube de nivel.
- **JSON embebido volátil:** el nombre/estructura del blob (`window.runParams`,
`_dida_config_`, `_init_data_`) cambia con frecuencia. Se prueban varios patrones y
un walk genérico, pero si AliExpress cambia el layout la función devuelve `[]`
(HTML válido sin JSON parseable) — **NO inventa datos**. Diferencia clave:
`RuntimeError` = bloqueado; `[]` = layout cambiado o shell vacío.
- **Región/moneda dependen de `ship_to`:** se setean por cookies (`aep_usuc_f`,
`intl_locale`). Un `ship_to` no mapeado cae a `ES`/`EUR`. El `currency` devuelto
depende de lo que AliExpress decida servir, no se fuerza tras el fetch.
- **`orders`/`price`/`rating` pueden venir `None`** si el item no expone ese campo en
el JSON (productos nuevos sin ventas, listados sin rating). No asumir no-null.
- **Una sola página:** devuelve hasta `limit` items de la primera página de resultados;
no pagina. Para más volumen, llamar con queries/categorías distintas.
- **Sin reintentos ni rotación de proxy/UA:** es una request única con headers fijos.
Para uso periódico, orquestar reintentos y backoff fuera de la función.
@@ -0,0 +1,393 @@
"""Capta productos populares de AliExpress como señal de e-commerce/dropshipping.
Extrae el JSON que AliExpress embebe en el HTML de su página de búsqueda/listado
(``window.runParams`` / ``_dida_config`` / scripts ``data``) en lugar de parsear
el DOM renderizado por JS. AliExpress es anti-bot fuerte (captcha, 403, fingerprint
sobre headless/datacenter), por lo que esta función es best-effort: cuando el fetch
real es bloqueado lanza ``RuntimeError`` con un mensaje claro. NUNCA inventa datos.
"""
from __future__ import annotations
import json
import re
from typing import Any
_BASE = "https://www.aliexpress.com"
_WHOLESALE = f"{_BASE}/wholesale"
# Headers realistas de un navegador desktop. AliExpress fingerprint-ea agresivamente,
# así que enviamos un perfil coherente (Chrome estable + Accept-Language acorde a region).
_DESKTOP_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"Accept": (
"text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,image/apng,*/*;q=0.8"
),
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Cache-Control": "max-age=0",
}
# AliExpress decide moneda/region por estas cookies. Mapa ship_to -> (region, locale, currency).
_REGION_MAP: dict[str, tuple[str, str, str]] = {
"ES": ("ES", "es_ES", "EUR"),
"US": ("US", "en_US", "USD"),
"GB": ("GB", "en_GB", "GBP"),
"FR": ("FR", "fr_FR", "EUR"),
"DE": ("DE", "de_DE", "EUR"),
"IT": ("IT", "it_IT", "EUR"),
"PT": ("PT", "pt_PT", "EUR"),
"MX": ("MX", "es_MX", "USD"),
"BR": ("BR", "pt_BR", "BRL"),
}
# Señales de bloqueo anti-bot en la respuesta.
_BLOCK_MARKERS = (
"punish", # /_____tmd_____/punish — captcha slider de AliExpress
"nc_token", # NoCaptcha de Alibaba
"captcha",
"Access Denied",
"baxia-dialog", # widget de verificacion
)
def _region_cookies(ship_to: str) -> dict[str, str]:
region, locale, currency = _REGION_MAP.get(
ship_to.upper(), _REGION_MAP["ES"]
)
return {
"aep_usuc_f": f"site=glo&c_tp={currency}&region={region}&b_locale={locale}",
"intl_locale": locale,
"xman_us_f": f"x_l=0&no_popup_today=n&zero_order=n&x_locale={locale}",
}
def _build_url(query: str | None, category: str | None) -> str:
if query:
# /wholesale?SearchText=... es el listado de búsqueda con runParams embebido.
from urllib.parse import quote_plus
return f"{_WHOLESALE}?SearchText={quote_plus(query)}&SortType=total_tranpro_desc"
if category:
# Categorías numéricas: /category/<id>/x.html. Si llega un slug, lo usamos como texto.
if category.isdigit():
return f"{_BASE}/category/{category}/x.html?SortType=total_tranpro_desc"
from urllib.parse import quote_plus
return f"{_WHOLESALE}?SearchText={quote_plus(category)}&SortType=total_tranpro_desc"
# Sin query ni categoría: listado de best-selling genérico.
return f"{_WHOLESALE}?SearchText=hot+products&SortType=total_tranpro_desc"
def _looks_blocked(html: str, status_code: int) -> bool:
if status_code in (403, 429, 503):
return True
head = html[:6000].lower()
return any(marker.lower() in head for marker in _BLOCK_MARKERS)
def _extract_embedded_json(html: str) -> dict[str, Any] | None:
"""Intenta varios patrones de JSON embebido que AliExpress ha usado a lo largo del tiempo.
El nombre/forma cambia con frecuencia, así que probamos en orden y nos quedamos
con el primero que parsee y contenga algo con pinta de items.
"""
patterns = (
r"window\.runParams\s*=\s*({.*?})\s*;\s*</script>",
r"window\._dida_config_\s*=\s*({.*?})\s*;",
r"_init_data_\s*=\s*{\s*data:\s*({.*?})\s*}\s*</script>",
r"window\.runParams\s*=\s*({.*?});",
)
for pat in patterns:
m = re.search(pat, html, re.DOTALL)
if not m:
continue
blob = m.group(1)
try:
data = json.loads(blob)
except (json.JSONDecodeError, ValueError):
continue
if isinstance(data, dict):
return data
return None
def _dig_items(data: dict[str, Any]) -> list[dict[str, Any]]:
"""Localiza la lista de productos dentro del JSON embebido, sea cual sea su anidación.
Las claves han variado entre 'mods.itemList.content', 'items', 'result.items'...
así que hacemos un walk genérico buscando la primera lista de dicts con pinta de
producto (tienen productId/title/trade).
"""
found: list[dict[str, Any]] = []
def _is_product(d: dict[str, Any]) -> bool:
keys = set(d.keys())
id_keys = {"productId", "product_id", "productid", "id"}
title_keys = {"title", "subject", "name"}
return bool(keys & id_keys) and bool(keys & title_keys)
def _walk(node: Any) -> None:
if found:
return
if isinstance(node, list):
product_like = [x for x in node if isinstance(x, dict) and _is_product(x)]
if len(product_like) >= 2:
found.extend(product_like)
return
for x in node:
_walk(x)
elif isinstance(node, dict):
for v in node.values():
_walk(v)
_walk(data)
return found
def _to_float(value: Any) -> float | None:
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
s = str(value)
# Quita símbolos de moneda y separadores de miles; deja el primer número decimal.
m = re.search(r"\d[\d.,]*", s.replace(" ", " "))
if not m:
return None
num = m.group(0)
# Heurística: si hay coma y punto, asume coma = miles. Si solo coma, coma = decimal.
if "," in num and "." in num:
num = num.replace(",", "")
elif "," in num:
num = num.replace(",", ".")
try:
return float(num)
except ValueError:
return None
def _to_orders(value: Any) -> int | None:
if value is None:
return None
if isinstance(value, int):
return value
s = str(value).lower()
# Formatos: "1,234 sold", "2.3k sold", "10000+ orders".
mult = 1
if "k" in s:
mult = 1000
m = re.search(r"\d[\d.,]*", s)
if not m:
return None
num = m.group(0).replace(",", "")
try:
base = float(num)
except ValueError:
return None
return int(base * mult)
def _normalize_item(
raw: dict[str, Any], category: str | None
) -> dict[str, Any] | None:
pid = (
raw.get("productId")
or raw.get("product_id")
or raw.get("productid")
or raw.get("id")
)
if pid is None:
return None
product_id = str(pid)
title = raw.get("title") or raw.get("subject") or raw.get("name")
if isinstance(title, dict):
title = title.get("displayTitle") or title.get("seoTitle")
title = str(title).strip() if title else None
# Precio: AliExpress lo mete en 'prices.salePrice.minPrice' o variantes planas.
price_node = (
raw.get("prices", {}).get("salePrice", {})
if isinstance(raw.get("prices"), dict)
else {}
)
price = _to_float(
(price_node.get("minPrice") if isinstance(price_node, dict) else None)
or raw.get("salePrice")
or raw.get("price")
or raw.get("minPrice")
)
currency = None
if isinstance(price_node, dict):
currency = price_node.get("currencyCode")
currency = currency or raw.get("currency") or raw.get("currencyCode")
currency = str(currency) if currency else None
orders = _to_orders(
raw.get("trade", {}).get("tradeDesc")
if isinstance(raw.get("trade"), dict)
else None
)
if orders is None:
orders = _to_orders(
raw.get("orders") or raw.get("tradeCount") or raw.get("sales")
)
rating = _to_float(
(
raw.get("evaluation", {}).get("starRating")
if isinstance(raw.get("evaluation"), dict)
else None
)
or raw.get("rating")
or raw.get("averageStar")
or raw.get("starRating")
)
url = raw.get("productDetailUrl") or raw.get("url") or raw.get("detail_url")
if url:
url = str(url)
if url.startswith("//"):
url = "https:" + url
else:
url = f"{_BASE}/item/{product_id}.html"
return {
"category": category,
"product_id": product_id,
"title": title,
"price": price,
"currency": currency,
"orders": orders,
"rating": rating,
"url": url,
}
def scrape_aliexpress_trending(
query: str | None = None,
category: str | None = None,
limit: int = 40,
ship_to: str = "ES",
) -> list[dict]:
"""Capta productos populares de AliExpress (señal e-commerce/dropshipping).
Hace UNA request HTTP a la página de listado de AliExpress ordenada por número
de pedidos (``total_tranpro_desc``) y extrae el JSON embebido en el HTML. Es
best-effort: AliExpress bloquea agresivamente headless/datacenter, por lo que
ante un bloqueo (403/429/captcha) lanza ``RuntimeError`` con un mensaje claro y
ante un HTML sin JSON parseable devuelve ``[]``. NUNCA inventa datos.
Args:
query: Texto de búsqueda (ej. "kitchen gadgets"). Si se da, manda en la URL.
category: ID numérico de categoría AliExpress o slug. Ignorado si hay ``query``.
limit: Número máximo de productos a devolver. Default 40.
ship_to: Código de país ISO-2 para fijar región/moneda via cookies. Default "ES".
Returns:
Lista de dicts con claves exactas:
``category, product_id, title, price, currency, orders, rating, url``.
``price``/``rating`` son ``float | None``, ``orders`` es ``int | None``.
Lista vacía si el HTML no traía JSON parseable.
Raises:
RuntimeError: Si AliExpress bloquea la request (captcha/403/429) o la red falla.
"""
import requests
url = _build_url(query, category)
cookies = _region_cookies(ship_to)
headers = dict(_DESKTOP_HEADERS)
_, locale, _ = _REGION_MAP.get(ship_to.upper(), _REGION_MAP["ES"])
headers["Accept-Language"] = f"{locale.replace('_', '-')},en;q=0.8"
try:
resp = requests.get(
url,
headers=headers,
cookies=cookies,
timeout=20,
allow_redirects=True,
)
except requests.RequestException as exc:
raise RuntimeError(
f"scrape_aliexpress_trending: fallo de red contra {url}: {exc}"
) from exc
html = resp.text or ""
if _looks_blocked(html, resp.status_code):
raise RuntimeError(
f"scrape_aliexpress_trending: AliExpress bloqueó la request "
f"(status={resp.status_code}, captcha/anti-bot). "
f"Usa el browser MCP/CDP con sesión real para esta fuente."
)
data = _extract_embedded_json(html)
if data is None:
# HTML sin el JSON esperado: layout cambió o respondió un shell vacío.
# Devolvemos [] honesto en vez de inventar.
return []
raw_items = _dig_items(data)
cat_label = category if (category and not query) else (query or category)
out: list[dict] = []
seen: set[str] = set()
for raw in raw_items:
norm = _normalize_item(raw, cat_label)
if norm is None:
continue
if norm["product_id"] in seen:
continue
seen.add(norm["product_id"])
out.append(norm)
if len(out) >= limit:
break
return out
if __name__ == "__main__":
# Self-test honesto: import OK obligatorio + UN fetch real en try/except.
# NUNCA falla la build por la red.
print("import OK: scrape_aliexpress_trending")
expected_keys = {
"category",
"product_id",
"title",
"price",
"currency",
"orders",
"rating",
"url",
}
try:
rows = scrape_aliexpress_trending(query="phone holder", limit=5, ship_to="ES")
if rows:
got_keys = set(rows[0].keys())
keys_ok = got_keys == expected_keys
print(
f"fetch real: {len(rows)} filas obtenidas | "
f"claves correctas={keys_ok}"
)
print(f" muestra: {rows[0]}")
else:
print(
"fetch real: 0 filas (HTML sin JSON embebido parseable "
"— layout cambió o shell vacío). NO se inventan datos."
)
except RuntimeError as exc:
print(f"fetch real: BLOQUEADO/ERROR honesto -> {exc}")
@@ -0,0 +1,72 @@
---
name: scrape_amazon_bestsellers
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def scrape_amazon_bestsellers(marketplace: str = 'amazon.es', categories: list[str] | None = None, list_type: str = 'bestsellers', max_items: int = 50) -> list[dict]"
description: "Scrapea los rankings de Amazon (Best Sellers y Movers & Shakers) de un marketplace para captar señales de demanda de productos: rank, ASIN, titulo, precio, rating, reseñas y, en movers, el cambio porcentual."
tags: [amazon, scraping, trends, market-intel, datascience]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [requests, bs4]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/datascience/scrape_amazon_bestsellers.py"
params:
- name: marketplace
desc: "Dominio Amazon objetivo (amazon.es, amazon.com, amazon.co.uk, amazon.de, ...). Determina la URL, el Accept-Language enviado y la moneda fallback."
- name: categories
desc: "Lista de slugs de categoria a scrapear (ej. 'electronics', 'videogames'). Si es None, scrapea la portada general del ranking elegido. Cada slug genera una pagina/peticion."
- name: list_type
desc: "Tipo de ranking: 'bestsellers' (URL /gp/bestsellers/<cat>) o 'movers_shakers' (URL /gp/movers-and-shakers/<cat>). Cualquier otro valor lanza ValueError."
- name: max_items
desc: "Numero maximo de productos recolectados por categoria. Default 50 (una pagina de ranking suele tener ~50 items)."
output: "Lista de dicts, uno por producto, con exactamente estas claves: marketplace, list_type, category, rank, asin, title, price, currency, rating, reviews, pct_change, url. None donde no haya dato. price/rating/pct_change son float; rank/reviews son int. pct_change solo se rellena en movers_shakers. Casa 1:1 con la tabla Postgres amazon_bestsellers (el ingest añade id/snapshot_date/scraped_at)."
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.scrape_amazon_bestsellers import scrape_amazon_bestsellers
# Best Sellers de electronica y videojuegos en Amazon.es
rows = scrape_amazon_bestsellers(
marketplace="amazon.es",
categories=["electronics", "videogames"],
list_type="bestsellers",
max_items=50,
)
print(len(rows), "items")
print(rows[0])
# {'marketplace': 'amazon.es', 'list_type': 'bestsellers', 'category': 'electronics',
# 'rank': 1, 'asin': 'B0...', 'title': '...', 'price': 29.99, 'currency': 'EUR',
# 'rating': 4.5, 'reviews': 1234, 'pct_change': None, 'url': 'https://www.amazon.es/dp/B0...'}
# Movers & Shakers (productos que mas suben) — incluye pct_change
movers = scrape_amazon_bestsellers(
marketplace="amazon.com",
list_type="movers_shakers",
max_items=30,
)
```
## Cuando usarla
Usala cuando necesites captar señales de demanda de mercado desde Amazon: que se esta vendiendo mas (Best Sellers) o que esta subiendo de golpe en ventas (Movers & Shakers), por marketplace y categoria. Util como fuente de un pipeline de market intelligence / trend detection que luego ingesta a la tabla `amazon_bestsellers` y cruza snapshots diarios para detectar productos al alza. Llamala antes de cualquier analisis de tendencias de catalogo; el dict devuelto esta listo para insertar tras añadir `snapshot_date`/`scraped_at`.
## Gotchas
- **Anti-bot fuerte**: Amazon detecta scraping HTTP puro y puede devolver captcha, `503` o `429`. La funcion detecta el bloqueo (status 429/503 o markers de captcha en el HTML) y, tras agotar reintentos, lanza `RuntimeError` con el status. **Si HTTP puro falla repetidamente, la alternativa es el navegador del ecosistema (browser MCP / CDP)** sobre una pestaña real de Chrome, que pasa el anti-bot mejor que `requests`.
- **HTML fragil**: Amazon cambia las plantillas del DOM con frecuencia y sirve varias a la vez segun A/B test. Los selectores estan escritos defensivamente (varios fallbacks por campo) pero **pueden necesitar mantenimiento** cuando Amazon rota plantillas. Si un campo no aparece en ninguna plantilla conocida, se devuelve `None` en vez de petar.
- **Campos opcionales = None**: no todos los items traen precio/rating/reviews/pct_change. `pct_change` solo se rellena en `list_type="movers_shakers"`; en bestsellers siempre es `None`.
- **rank fallback posicional**: si Amazon no renderiza el badge de rank, se usa la posición (1-indexada) del item en la pagina como rank.
- **Una peticion por categoria**: cada slug en `categories` dispara una peticion HTTP independiente (con 2 reintentos + backoff). Listas largas de categorias multiplican el riesgo de throttling — espacia las llamadas si scrapeas muchas.
- **Moneda best-effort**: `currency` se infiere del simbolo en el precio (€, $, £, R$) y, si no hay simbolo reconocible, del TLD del marketplace. Puede ser `None` si no se pudo determinar.
@@ -0,0 +1,425 @@
"""Scrape Amazon Best Sellers and Movers & Shakers ranking pages for product demand signals."""
from __future__ import annotations
import re
import time
from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup
# Accept-Language hint per marketplace TLD. Falls back to a generic value.
_ACCEPT_LANGUAGE = {
"amazon.es": "es-ES,es;q=0.9,en;q=0.6",
"amazon.com": "en-US,en;q=0.9",
"amazon.co.uk": "en-GB,en;q=0.9",
"amazon.de": "de-DE,de;q=0.9,en;q=0.6",
"amazon.fr": "fr-FR,fr;q=0.9,en;q=0.6",
"amazon.it": "it-IT,it;q=0.9,en;q=0.6",
"amazon.com.mx": "es-MX,es;q=0.9,en;q=0.6",
"amazon.com.br": "pt-BR,pt;q=0.9,en;q=0.6",
}
# Currency guessed from the marketplace TLD (used only as a fallback when the
# price string has no recognisable symbol).
_CURRENCY_BY_MARKET = {
"amazon.es": "EUR",
"amazon.com": "USD",
"amazon.co.uk": "GBP",
"amazon.de": "EUR",
"amazon.fr": "EUR",
"amazon.it": "EUR",
"amazon.com.mx": "MXN",
"amazon.com.br": "BRL",
}
# Map common currency symbols to ISO codes.
_SYMBOL_TO_CURRENCY = {
"": "EUR",
"$": "USD",
"£": "GBP",
"R$": "BRL",
"US$": "USD",
}
_USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)
# Signals that Amazon served an anti-bot / captcha / throttling page instead of
# the ranking content.
_BLOCK_MARKERS = (
"api-services-support@amazon",
"captcha",
"to discuss automated access",
"enter the characters you see below",
"robot check",
)
def _build_headers(marketplace: str) -> dict:
"""Realistic browser-ish headers for the given marketplace."""
return {
"User-Agent": _USER_AGENT,
"Accept": (
"text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,*/*;q=0.8"
),
"Accept-Language": _ACCEPT_LANGUAGE.get(marketplace, "en-US,en;q=0.9"),
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
}
def _build_url(marketplace: str, list_type: str, category: str | None) -> str:
"""Compose the ranking URL for a marketplace / list type / category slug."""
base = "movers-and-shakers" if list_type == "movers_shakers" else "bestsellers"
url = f"https://www.{marketplace}/gp/{base}"
if category:
url = f"{url}/{category.strip('/')}"
return url
def _looks_blocked(status_code: int, html: str) -> bool:
"""Heuristic: did Amazon serve an anti-bot / throttling page?"""
if status_code in (429, 503):
return True
lowered = html.lower()
return any(marker in lowered for marker in _BLOCK_MARKERS)
def _fetch(url: str, headers: dict, timeout: int, retries: int) -> requests.Response:
"""GET with small retry + backoff. Raises on persistent failure / block."""
last_exc: Exception | None = None
for attempt in range(retries + 1):
try:
resp = requests.get(url, headers=headers, timeout=timeout)
except requests.RequestException as exc: # network / timeout
last_exc = exc
if attempt < retries:
time.sleep(1.5 * (attempt + 1))
continue
raise RuntimeError(f"request to {url} failed: {exc}") from exc
if _looks_blocked(resp.status_code, resp.text):
if attempt < retries:
time.sleep(2.0 * (attempt + 1))
continue
raise RuntimeError(
f"Amazon anti-bot block on {url} (HTTP {resp.status_code}). "
"HTTP scraping is being throttled/captcha'd; fall back to the "
"browser MCP/CDP path of the ecosystem."
)
if resp.status_code != 200:
last_exc = RuntimeError(
f"unexpected HTTP {resp.status_code} for {url}"
)
if attempt < retries:
time.sleep(1.5 * (attempt + 1))
continue
raise last_exc
return resp
# Should not reach here, but be defensive.
raise RuntimeError(f"could not fetch {url}: {last_exc}")
_ASIN_RE = re.compile(r"/(?:dp|gp/product)/([A-Z0-9]{10})(?:[/?]|$)")
_RANK_RE = re.compile(r"#?\s*(\d+)")
_PRICE_NUM_RE = re.compile(r"[-+]?\d[\d.,]*")
_REVIEWS_RE = re.compile(r"[\d.,]+")
_RATING_RE = re.compile(r"([\d.,]+)\s*(?:out of|de|von|su|sur|de um total de)")
_PCT_RE = re.compile(r"([\d.,]+)\s*%")
def _text(node) -> str:
return node.get_text(" ", strip=True) if node is not None else ""
def _parse_asin(card) -> str | None:
"""ASIN from a data-asin attribute or any /dp/<ASIN>/ link inside the card."""
asin = card.get("data-asin")
if asin and re.fullmatch(r"[A-Z0-9]{10}", asin):
return asin
for a in card.find_all("a", href=True):
m = _ASIN_RE.search(a["href"])
if m:
return m.group(1)
return None
def _parse_url(card, marketplace: str) -> str | None:
"""Absolute product URL from the first /dp/ link in the card."""
base = f"https://www.{marketplace}"
for a in card.find_all("a", href=True):
if _ASIN_RE.search(a["href"]):
return urljoin(base, a["href"].split("?")[0])
# Fall back to the first link at all.
first = card.find("a", href=True)
if first is not None:
return urljoin(base, first["href"].split("?")[0])
return None
def _parse_rank(card) -> int | None:
"""Rank badge. Amazon renders it as '#1', '1', etc."""
badge = card.select_one(".zg-bdg-text, .zg-badge-text, [class*='badge']")
txt = _text(badge)
if not txt:
# Sometimes the rank is in a class like a11y .zg-bdg-text sibling.
for sel in (".a-badge-text", "[class*='rank']"):
node = card.select_one(sel)
txt = _text(node)
if txt:
break
m = _RANK_RE.search(txt)
return int(m.group(1)) if m else None
def _parse_title(card) -> str | None:
"""Product title — several templates over the years."""
for sel in (
"._cDEzb_p13n-sc-css-line-clamp-3_g3dy1",
"._cDEzb_p13n-sc-css-line-clamp-2_EWgCb",
"[class*='line-clamp']",
".p13n-sc-truncate",
".p13n-sc-truncated",
"a.a-link-normal[title]",
"img[alt]",
):
node = card.select_one(sel)
if node is None:
continue
if node.name == "img":
alt = node.get("alt")
if alt:
return alt.strip()
continue
if node.has_attr("title") and node["title"].strip():
return node["title"].strip()
txt = _text(node)
if txt:
return txt
return None
def _parse_price(card, marketplace: str) -> tuple[float | None, str | None]:
"""Price value (float) and ISO currency, best-effort across templates."""
for sel in (
"._cDEzb_p13n-sc-price_3mJ9Z",
".p13n-sc-price",
"span.a-price > span.a-offscreen",
".a-price .a-offscreen",
"[class*='price']",
):
node = card.select_one(sel)
txt = _text(node)
if not txt:
continue
currency = None
for sym, iso in _SYMBOL_TO_CURRENCY.items():
if sym in txt:
currency = iso
break
if currency is None:
currency = _CURRENCY_BY_MARKET.get(marketplace)
m = _PRICE_NUM_RE.search(txt)
if not m:
continue
raw = m.group(0)
value = _to_float(raw)
if value is not None:
return value, currency
return None, None
def _parse_rating(card) -> float | None:
"""Star rating, e.g. '4,5 de 5 estrellas' / '4.5 out of 5 stars'."""
for sel in ("[class*='review-stars']", ".a-icon-alt", "[title*='star']", "[aria-label*='star']"):
node = card.select_one(sel)
txt = _text(node) or (node.get("title", "") if node is not None else "") or (
node.get("aria-label", "") if node is not None else ""
)
if not txt:
continue
m = _RATING_RE.search(txt)
if m:
return _to_float(m.group(1))
# Some templates only render the number ('4,5').
m2 = _PRICE_NUM_RE.search(txt)
if m2 and ("star" in txt.lower() or "estrella" in txt.lower()):
return _to_float(m2.group(0))
return None
def _parse_reviews(card) -> int | None:
"""Number of ratings/reviews shown next to the stars."""
for sel in (
"a.a-size-small.a-link-normal",
".a-size-small.a-link-normal",
"[class*='review-count']",
"span.a-size-small",
):
for node in card.select(sel):
txt = _text(node)
if not txt:
continue
m = _REVIEWS_RE.search(txt)
if not m:
continue
digits = m.group(0).replace(".", "").replace(",", "")
if digits.isdigit() and len(digits) >= 1:
# Avoid catching rank/price by requiring a plausible count token.
return int(digits)
return None
def _parse_pct_change(card) -> float | None:
"""Movers & Shakers percentage change ('+150%')."""
for sel in (".zg-percent-change", "[class*='percent']", "[class*='sales-movement']"):
node = card.select_one(sel)
txt = _text(node)
if not txt:
continue
m = _PCT_RE.search(txt)
if m:
value = _to_float(m.group(1))
if value is None:
continue
return -value if txt.strip().startswith("-") else value
return None
def _to_float(raw: str) -> float | None:
"""Parse a numeric string with EU or US decimal/grouping conventions."""
if raw is None:
return None
s = raw.strip().replace("\xa0", "").replace(" ", "")
if not s:
return None
if "," in s and "." in s:
# The rightmost separator is the decimal one.
if s.rfind(",") > s.rfind("."):
s = s.replace(".", "").replace(",", ".")
else:
s = s.replace(",", "")
elif "," in s:
# Treat a single comma as decimal separator (EU markets).
s = s.replace(",", ".")
try:
return float(s)
except ValueError:
return None
def _select_cards(soup: BeautifulSoup) -> list:
"""Locate the list-item cards across known Amazon templates."""
selectors = (
"div.p13n-sc-uncoverable-faceout",
"div[id^='gridItemRoot']",
"div.zg-grid-general-faceout",
"li.zg-item-immersion",
"div.a-cardui[data-asin]",
"div[data-asin]",
)
for sel in selectors:
cards = soup.select(sel)
if cards:
return cards
return []
def scrape_amazon_bestsellers(
marketplace: str = "amazon.es",
categories: list[str] | None = None,
list_type: str = "bestsellers",
max_items: int = 50,
) -> list[dict]:
"""Scrape Amazon Best Sellers / Movers & Shakers ranking pages.
Captures demand signals (rank, title, price, rating, reviews and — for
Movers & Shakers — percentage change) from one or more category ranking
pages of a given Amazon marketplace.
Args:
marketplace: Amazon domain, e.g. ``"amazon.es"``, ``"amazon.com"``.
categories: Category slugs (e.g. ``"electronics"``, ``"videogames"``).
If ``None`` the general front page of the chosen list is scraped.
list_type: ``"bestsellers"`` (URL ``/gp/bestsellers/<cat>``) or
``"movers_shakers"`` (URL ``/gp/movers-and-shakers/<cat>``).
max_items: Maximum number of items collected per category.
Returns:
A list of dicts, one per product, with exactly these keys:
``marketplace, list_type, category, rank, asin, title, price,
currency, rating, reviews, pct_change, url``. Missing values are
``None``. ``price``/``rating``/``pct_change`` are floats,
``rank``/``reviews`` are ints.
Raises:
ValueError: If ``list_type`` is not one of the allowed values.
RuntimeError: On network failure or when Amazon serves an anti-bot /
captcha / throttling page.
"""
if list_type not in ("bestsellers", "movers_shakers"):
raise ValueError(
f"list_type must be 'bestsellers' or 'movers_shakers', got {list_type!r}"
)
cats: list[str | None] = list(categories) if categories else [None]
headers = _build_headers(marketplace)
results: list[dict] = []
for category in cats:
url = _build_url(marketplace, list_type, category)
resp = _fetch(url, headers, timeout=20, retries=2)
soup = BeautifulSoup(resp.text, "lxml")
cards = _select_cards(soup)
count = 0
for idx, card in enumerate(cards):
if count >= max_items:
break
asin = _parse_asin(card)
title = _parse_title(card)
# Skip empty / non-product wrappers.
if asin is None and title is None:
continue
rank = _parse_rank(card)
if rank is None:
rank = idx + 1 # positional fallback when no badge is rendered
price, currency = _parse_price(card, marketplace)
results.append(
{
"marketplace": marketplace,
"list_type": list_type,
"category": category,
"rank": rank,
"asin": asin,
"title": title,
"price": price,
"currency": currency,
"rating": _parse_rating(card),
"reviews": _parse_reviews(card),
"pct_change": _parse_pct_change(card)
if list_type == "movers_shakers"
else None,
"url": _parse_url(card, marketplace),
}
)
count += 1
return results
@@ -0,0 +1,73 @@
---
name: scrape_competitor_prices
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def scrape_competitor_prices(targets: list[dict]) -> list[dict]"
description: "Vigila precios de la competencia: dada una lista de objetivos (URL de producto + competidor), hace GET con headers realistas (timeout + 1 reintento) y extrae el precio actual de cada pagina con una cascada de estrategias (CSS selector, JSON-LD offers, meta tags, heuristica de clases). Normaliza a float (tolera coma/punto, simbolos, miles) y detecta in_stock. Devuelve una fila por target con claves 1:1 de la tabla Postgres competitor_prices; si falla un target devuelve price=None sin abortar los demas."
tags: [competitor, pricing, scraping, market-intel, datascience, recon]
params:
- name: targets
desc: "Lista de dicts, uno por producto a vigilar. Cada dict: competitor (str, nombre/id del competidor), product_key (str, clave interna estable), product_name (str, nombre legible), url (str, URL de la pagina del producto), price_selector (str, opcional, selector CSS que apunta al nodo del precio — lo mas robusto), currency (str, opcional, codigo de moneda a estampar, default 'EUR')."
output: "Lista de dicts, una fila por target, con EXACTAMENTE estas claves (casan 1:1 con la tabla Postgres competitor_prices, sin id/snapshot_date/scraped_at): competitor (str), product_key (str), product_name (str), url (str), price (float | None), currency (str), in_stock (bool | None). price=None si no se pudo extraer; in_stock=None si la pagina fallo."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [requests, beautifulsoup4, lxml]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/datascience/scrape_competitor_prices.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.scrape_competitor_prices import scrape_competitor_prices
targets = [
{
"competitor": "books-to-scrape",
"product_key": "light-in-the-attic",
"product_name": "A Light in the Attic",
"url": "http://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html",
"price_selector": "p.price_color", # el selector por target es lo mas fiable
"currency": "GBP",
},
{
"competitor": "competidor_b",
"product_key": "SKU-4242",
"product_name": "Filtro de aceite XYZ",
"url": "https://www.ejemplo-tienda.com/producto/4242",
# sin price_selector -> autodeteccion JSON-LD / meta / heuristica de clases
"currency": "EUR",
},
]
rows = scrape_competitor_prices(targets)
# rows[0] -> {"competitor": "books-to-scrape", "product_key": "light-in-the-attic",
# "product_name": "A Light in the Attic", "url": "...",
# "price": 51.77, "currency": "GBP", "in_stock": True}
# Listo para INSERT en la tabla competitor_prices (anade tu snapshot_date/scraped_at).
```
## Cuando usarla
Cuando necesites un snapshot puntual del precio de uno o varios productos de la competencia para alimentar una tabla de market intelligence (`competitor_prices`). Util en un cron/pipeline que lee una lista de objetivos, scrapea, y persiste una fila por producto. Pasa `price_selector` por target siempre que conozcas el sitio: es la via mas robusta. Si no lo pasas, la funcion intenta autodetectar (JSON-LD `offers.price`, meta tags de precio, clases comunes de e-commerce). Las filas salen con las claves exactas de la tabla destino, asi que el caller solo anade `snapshot_date`/`scraped_at` antes del INSERT.
## Gotchas
- **Funcion impura**: hace I/O de red (HTTP GET). Depende del HTML real de cada sitio en el momento de la llamada.
- **El scraping de precios es muy especifico por sitio.** Sin `price_selector`, la autodeteccion acierta en muchos e-commerce estandar (los que exponen JSON-LD `Product/Offer`, meta `og:price:amount`/`itemprop=price`, o clases tipicas `.price`), pero **falla en SPAs / paginas JS-rendered** (React/Vue/Angular que pintan el precio tras cargar) y en sitios con **anti-bot** (Cloudflare, captchas, fingerprinting). Para esos casos el GET devuelve un HTML sin el precio o un challenge, y la fila sale con `price=None`.
- **Para sitios JS-rendered o con anti-bot usa el navegador del ecosistema** (browser MCP / CDP: `page_perceive`, `cdp_get_text`, `cdp_perceive_outline`) para renderizar la pagina y extraer el precio del DOM ya pintado, en lugar de esta funcion de HTTP puro. Esta funcion es para HTML servidor-renderizado.
- **`price_selector` por target es lo mas fiable**: evita depender de la heuristica y sobrevive mejor a cambios de plantilla. Define uno por competidor en tu lista de objetivos.
- **Normalizacion de precio**: tolera `1.299,99 €` (europeo: punto miles, coma decimal), `$1,299.99` (US), `29,90`, `1299.99`. Heuristica: el separador mas a la derecha es el decimal cuando hay ambos; con solo coma, se trata como decimal si quedan 2 digitos detras, si no como miles. Casos exoticos (3 decimales, formatos regionales raros) pueden malinterpretarse — verifica con `price_selector` apuntando al nodo limpio.
- **`in_stock` es heuristico**: `True` salvo que el texto de la pagina contenga marcadores de agotado (`agotado`, `sin stock`, `out of stock`, `sold out`, etc.). Falsos positivos/negativos posibles si el sitio usa otra redaccion o muestra esos terminos en contexto no relacionado. `None` si la pagina fallo al cargar.
- **Tolerancia a fallos por target**: si un target peta (red, timeout, HTML invalido), su fila sale con `price=None`/`in_stock=None` y **el resto del batch continua**. Nunca aborta toda la lista por un fallo individual.
- **Reintento unico**: cada GET reintenta una vez ante error de transporte. No hay backoff exponencial ni rotacion de proxies/User-Agent; para scraping a escala o contra anti-bot fuerte, eso queda fuera del alcance de esta funcion.
@@ -0,0 +1,389 @@
"""Scrape current prices for a list of competitor product pages.
Watches competitor pricing: given a list of targets (product URL + competitor),
fetches each page and extracts the current price using a cascade of strategies
(CSS selector, JSON-LD offers, meta tags, common-class heuristics). Output rows
map 1:1 to the Postgres `competitor_prices` table (minus the autogenerated
id/snapshot_date/scraped_at columns).
"""
import json
import re
import urllib.request
import urllib.error
from bs4 import BeautifulSoup
_USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)
_REQUEST_HEADERS = {
"User-Agent": _USER_AGENT,
"Accept": (
"text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,*/*;q=0.8"
),
"Accept-Language": "es-ES,es;q=0.9,en;q=0.8",
"Accept-Encoding": "identity",
"Connection": "close",
}
# Substrings that, when present, signal the product is NOT available.
_OUT_OF_STOCK_MARKERS = (
"agotado",
"sin stock",
"sin existencias",
"no disponible",
"out of stock",
"sold out",
"unavailable",
"currently unavailable",
)
# Common class/attribute patterns used by mainstream e-commerce templates.
_PRICE_HEURISTIC_SELECTORS = (
"[itemprop=price]",
"[data-price]",
"[data-product-price]",
".price",
".product-price",
".price--current",
".current-price",
".sale-price",
".a-price .a-offscreen",
"[class*=price]",
)
# A token that looks like a price: optional currency symbol, digits with
# thousands/decimal separators. Captured group is the numeric part.
# First alternative requires >=1 explicit thousands group (e.g. 1.299,99);
# second alternative covers plain contiguous digits with optional decimals
# (e.g. 1299.99, 29,90). Ordering the thousands branch first avoids the
# plain-digit branch greedily eating "1299" out of "1299.99".
_PRICE_NUMBER_RE = re.compile(
r"(?:[€$£]|EUR|USD|GBP)?\s*"
r"(\d{1,3}(?:[.,\s]\d{3})+(?:[.,]\d{1,2})?|\d+(?:[.,]\d{1,2})?)"
r"\s*(?:[€$£]|EUR|USD|GBP)?",
re.IGNORECASE,
)
def _fetch_html(url: str, timeout: float = 15.0) -> str:
"""GET a URL with realistic headers, one retry on failure.
Raises the last urllib error if both attempts fail.
"""
last_err: Exception | None = None
for attempt in range(2):
try:
req = urllib.request.Request(url, headers=_REQUEST_HEADERS)
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
charset = resp.headers.get_content_charset() or "utf-8"
try:
return raw.decode(charset, errors="replace")
except (LookupError, UnicodeDecodeError):
return raw.decode("utf-8", errors="replace")
except Exception as err: # noqa: BLE001 - retry on any transport error
last_err = err
continue
raise last_err if last_err is not None else RuntimeError("fetch failed")
def _normalize_price(raw) -> float | None:
"""Normalize a price token to float, tolerating comma/dot and symbols.
Handles "1.299,99 €", "$1,299.99", "1299.99", "29,90" etc.
Returns None if no numeric value can be parsed.
"""
if raw is None:
return None
if isinstance(raw, (int, float)):
try:
return float(raw)
except (ValueError, TypeError):
return None
text = str(raw).strip()
if not text:
return None
match = _PRICE_NUMBER_RE.search(text)
if not match:
return None
num = match.group(1).strip().replace(" ", "")
last_comma = num.rfind(",")
last_dot = num.rfind(".")
if last_comma != -1 and last_dot != -1:
# The right-most separator is the decimal separator.
if last_comma > last_dot:
# European: 1.299,99 -> dots are thousands, comma is decimal.
num = num.replace(".", "").replace(",", ".")
else:
# US: 1,299.99 -> commas are thousands, dot is decimal.
num = num.replace(",", "")
elif last_comma != -1:
# Only commas present. Decimal if it looks like "29,90"; else thousands.
if len(num) - last_comma - 1 == 2:
num = num.replace(",", ".")
else:
num = num.replace(",", "")
# Only dots (or none): assume dot is already decimal / no separators.
try:
return float(num)
except ValueError:
return None
def _extract_from_selector(soup: BeautifulSoup, selector: str) -> float | None:
"""Try a single CSS selector and normalize the matched node."""
try:
node = soup.select_one(selector)
except Exception: # noqa: BLE001 - invalid selector should not abort
return None
if node is None:
return None
# Prefer common price-bearing attributes, fall back to text.
for attr in ("content", "data-price", "data-product-price", "value"):
if node.has_attr(attr):
price = _normalize_price(node.get(attr))
if price is not None:
return price
return _normalize_price(node.get_text(" ", strip=True))
def _iter_json_ld_prices(soup: BeautifulSoup):
"""Yield candidate prices found inside ld+json offers blocks."""
for tag in soup.find_all("script", attrs={"type": "application/ld+json"}):
payload = tag.string or tag.get_text()
if not payload:
continue
try:
data = json.loads(payload)
except (ValueError, TypeError):
continue
for node in _walk_json(data):
if not isinstance(node, dict):
continue
offers = node.get("offers")
for offer in _as_list(offers):
if isinstance(offer, dict) and "price" in offer:
yield offer.get("price")
# Some schemas place price directly on the node.
if "price" in node and not isinstance(node.get("offers"), (dict, list)):
yield node.get("price")
def _walk_json(node):
"""Depth-first walk over arbitrarily nested JSON structures."""
if isinstance(node, dict):
yield node
for value in node.values():
yield from _walk_json(value)
elif isinstance(node, list):
for item in node:
yield from _walk_json(item)
def _as_list(value):
"""Wrap a value in a list unless it already is one."""
if value is None:
return []
return value if isinstance(value, list) else [value]
def _extract_from_meta(soup: BeautifulSoup) -> float | None:
"""Try common price meta tags in priority order."""
candidates = (
{"itemprop": "price"},
{"property": "og:price:amount"},
{"property": "product:price:amount"},
{"name": "twitter:data1"},
)
for attrs in candidates:
tag = soup.find("meta", attrs=attrs)
if tag is not None:
price = _normalize_price(tag.get("content"))
if price is not None:
return price
return None
def _detect_in_stock(soup: BeautifulSoup) -> bool | None:
"""Heuristic stock detection: True unless an out-of-stock marker appears."""
text = soup.get_text(" ", strip=True).lower()
if not text:
return None
for marker in _OUT_OF_STOCK_MARKERS:
if marker in text:
return False
return True
def _extract_price(soup: BeautifulSoup, price_selector) -> float | None:
"""Run the extraction cascade and return the first price found."""
# 1. Caller-supplied CSS selector (most robust).
if price_selector:
price = _extract_from_selector(soup, str(price_selector))
if price is not None:
return price
# 2. JSON-LD offers.
for candidate in _iter_json_ld_prices(soup):
price = _normalize_price(candidate)
if price is not None:
return price
# 3. Meta tags.
price = _extract_from_meta(soup)
if price is not None:
return price
# 4. Common-class heuristics.
for selector in _PRICE_HEURISTIC_SELECTORS:
price = _extract_from_selector(soup, selector)
if price is not None:
return price
return None
def scrape_competitor_prices(targets: list[dict]) -> list[dict]:
"""Scrape current prices for a list of competitor product pages.
For each target performs a GET with realistic headers (timeout + 1 retry)
and extracts the price using a cascade of strategies. Extraction failures
of a single target never abort the others: that row is returned with
price=None (and in_stock=None) so the caller still gets one row per target.
Args:
targets: list of dicts, each with keys:
- competitor (str): competitor name/id.
- product_key (str): stable internal product key.
- product_name (str): human-readable product name.
- url (str): product page URL to scrape.
- price_selector (str, optional): CSS selector pinpointing the
price node. Most robust when provided.
- currency (str, optional): currency code to stamp on the row
(e.g. "EUR"). Defaults to "EUR".
Returns:
list of dicts, one per target, with EXACTLY these keys (1:1 with the
Postgres `competitor_prices` table, minus id/snapshot_date/scraped_at):
- competitor (str)
- product_key (str)
- product_name (str)
- url (str)
- price (float | None)
- currency (str)
- in_stock (bool | None)
"""
rows: list[dict] = []
for target in targets:
competitor = target.get("competitor")
product_key = target.get("product_key")
product_name = target.get("product_name")
url = target.get("url")
price_selector = target.get("price_selector")
currency = target.get("currency") or "EUR"
price: float | None = None
in_stock: bool | None = None
if url:
try:
html = _fetch_html(url)
soup = BeautifulSoup(html, "lxml")
price = _extract_price(soup, price_selector)
in_stock = _detect_in_stock(soup)
except Exception: # noqa: BLE001 - never abort the whole batch
price = None
in_stock = None
rows.append(
{
"competitor": competitor,
"product_key": product_key,
"product_name": product_name,
"url": url,
"price": price,
"currency": currency,
"in_stock": in_stock,
}
)
return rows
if __name__ == "__main__":
# Self-test: import is implicitly OK if we reach this point.
print("self-test: import OK")
# Pure-logic checks that need no network.
assert _normalize_price("1.299,99 €") == 1299.99, "EU thousands+decimal"
assert _normalize_price("$1,299.99") == 1299.99, "US thousands+decimal"
assert _normalize_price("29,90") == 29.90, "EU decimal only"
assert _normalize_price("1,299") == 1299.0, "US thousands only"
assert _normalize_price("1299.99") == 1299.99, "plain dot decimal"
assert _normalize_price("Precio: 49,95 EUR hoy") == 49.95, "embedded"
assert _normalize_price("no price here") is None, "no number"
assert _normalize_price(None) is None, "none in -> none out"
print("self-test: price normalization OK")
# Shape check: one row per target, exact keys, failed target -> price None.
sample = scrape_competitor_prices(
[
{
"competitor": "demo",
"product_key": "SKU-1",
"product_name": "Demo product",
"url": "http://invalid.localhost.invalid/nope",
"currency": "EUR",
}
]
)
expected_keys = {
"competitor",
"product_key",
"product_name",
"url",
"price",
"currency",
"in_stock",
}
assert len(sample) == 1, "one row per target"
assert set(sample[0].keys()) == expected_keys, "exact keys"
assert sample[0]["price"] is None, "failed target -> price None, no abort"
assert sample[0]["currency"] == "EUR", "currency default"
print("self-test: row shape + graceful-failure OK")
# Optional: best-effort real fetch against a public URL (never fails build).
try:
live = scrape_competitor_prices(
[
{
"competitor": "books-to-scrape",
"product_key": "light-in-the-attic",
"product_name": "A Light in the Attic",
"url": (
"http://books.toscrape.com/catalogue/"
"a-light-in-the-attic_1000/index.html"
),
"price_selector": "p.price_color",
"currency": "GBP",
}
]
)
print(f"self-test: live fetch -> price={live[0]['price']} "
f"in_stock={live[0]['in_stock']}")
except Exception as err: # noqa: BLE001 - network optional
print(f"self-test: live fetch skipped ({type(err).__name__})")
print("self-test: ALL OK")
@@ -0,0 +1,77 @@
---
name: scrape_google_trends
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def scrape_google_trends(keywords: list[str], geo: str = \"ES\", timeframe: str = \"now 7-d\", include_related: bool = True) -> list[dict]"
description: "Capta interes de busqueda de Google Trends por keyword/nicho via pytrends. El interes es relativo 0-100, NUNCA volumen absoluto. Aplana interest_over_time + related_queries (rising/top) en filas con schema fijo que casa 1:1 con la tabla Postgres google_trends. Backoff/retry ante 429."
tags: [google-trends, pytrends, trends, market-intel, datascience]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [pytrends, time]
params:
- name: keywords
desc: "lista de terminos/nichos a consultar (max 5 por payload, limite de Google Trends). Cada elemento es una keyword string."
- name: geo
desc: "codigo de pais ISO-3166 (ej. 'ES', 'US', '' para mundial). Default 'ES'."
- name: timeframe
desc: "ventana temporal en sintaxis pytrends (ej. 'now 7-d', 'today 3-m', 'today 12-m', '2024-01-01 2024-12-31'). Default 'now 7-d'."
- name: include_related
desc: "si True anade filas metric='rising' y metric='top' de related_queries por keyword. Si False solo interest_over_time. Default True."
output: "lista de dicts con claves EXACTAS {geo, timeframe, keyword, metric, point_date, value, related_query}. Tres tipos de fila segun metric: 'interest_over_time' (point_date=fecha ISO, value=0-100, related_query=None), 'rising' (related_query=query, value=valor rising o BREAKOUT_SENTINEL, point_date=None), 'top' (related_query=query, value=0-100, point_date=None). No incluye id/snapshot_date/scraped_at (los anade el ingest)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/datascience/scrape_google_trends.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.scrape_google_trends import scrape_google_trends
# Interes de busqueda en Espana, ultimos 7 dias, con related queries
rows = scrape_google_trends(
["coche electrico", "panel solar"],
geo="ES",
timeframe="now 7-d",
include_related=True,
)
# Cada fila tiene el mismo schema, listo para insertar en Postgres google_trends:
# {"geo": "ES", "timeframe": "now 7-d", "keyword": "coche electrico",
# "metric": "interest_over_time", "point_date": "2026-06-12", "value": 73,
# "related_query": None}
#
# {"geo": "ES", "timeframe": "now 7-d", "keyword": "coche electrico",
# "metric": "rising", "point_date": None, "value": 999999, # "Breakout"
# "related_query": "ayudas coche electrico 2026"}
#
# {"geo": "ES", "timeframe": "now 7-d", "keyword": "panel solar",
# "metric": "top", "point_date": None, "value": 100,
# "related_query": "placas solares precio"}
interes = [r for r in rows if r["metric"] == "interest_over_time"]
print(len(interes), "puntos de interes temporal")
```
## Cuando usarla
Cuando necesites medir el interes/momentum de un nicho o keyword en el tiempo (market intelligence, deteccion de tendencias, validacion de demanda de producto) y vayas a persistirlo en la tabla Postgres `google_trends`. Usala antes del ingest: devuelve filas crudas con el schema exacto de la tabla, sin los campos que pone el ingest (id, snapshot_date, scraped_at). Pon `include_related=False` si solo te interesa la serie temporal y quieres minimizar la superficie de rate-limit.
## Gotchas
- **API no oficial + rate-limit (429).** pytrends scrapea una API interna de Google que NO es publica. Google la limita agresivamente: rafagas de llamadas devuelven HTTP 429. La funcion reintenta con backoff incremental (5s, 15s, 30s) ante 429; si tras esos reintentos sigue limitada, lanza `RuntimeError` mencionando explicitamente el rate-limit. En entornos de CI/headless es habitual recibir 429 a la primera — no es un bug de la funcion.
- **Puede romperse sin aviso.** Al depender de un endpoint interno, Google puede cambiarlo y romper pytrends en cualquier momento. Trata los fallos como esperados y cachea resultados aguas arriba.
- **Interes relativo, NO volumen absoluto.** Los valores 0-100 estan normalizados DENTRO del payload consultado (mismo geo + timeframe + conjunto de keywords). 100 = el pico del conjunto, no "100 busquedas". No son comparables entre payloads distintos. Cambiar el set de keywords reescala todos los valores.
- **"Breakout" en rising.** Google marca como la cadena literal `"Breakout"` (en vez de un %) las related_queries rising cuyo crecimiento supera ~5000%. Para mantener la columna `value` numerica en Postgres se mapea al sentinel `BREAKOUT_SENTINEL = 999999`. Si necesitas distinguir un breakout real de un valor 999999 legitimo (imposible en la practica para %), filtra por ese sentinel.
- **Maximo 5 keywords por payload.** Limite de Google Trends. Pasar mas keywords hace que pytrends falle o ignore las extra. Trocea en lotes de <=5 y llama varias veces (espaciando para no disparar el 429).
- **DataFrames vacios.** `interest_over_time()` puede volver vacio (keyword sin datos en la ventana) y `related_queries()` devuelve un dict `{keyword: {'top': df|None, 'rising': df|None}}` con valores None. La funcion maneja ambos casos sin petar: simplemente no genera filas para esas combinaciones.
- **Columna `isPartial`.** `interest_over_time()` incluye una columna `isPartial` que marca el ultimo punto como provisional. Se ignora por completo (solo se leen las columnas que coinciden con las keywords).
@@ -0,0 +1,193 @@
"""Captación de interés de búsqueda de Google Trends vía pytrends.
Google Trends NUNCA devuelve volúmenes absolutos de búsqueda: todo el interés es
relativo y está normalizado en una escala 0-100 dentro del payload consultado
(keywords + geo + timeframe). Esta función aplana el resultado de pytrends en una
lista de dicts con un schema fijo que casa 1:1 con la tabla Postgres
`google_trends`.
"""
import time
# Sentinel numérico para related_queries "rising" que Google marca como "Breakout".
# pytrends entrega la cadena literal "Breakout" cuando el crecimiento es tan alto
# que no cabe en un porcentaje (>5000%). Lo representamos como este entero para
# mantener la columna `value` numérica en Postgres sin perder la señal.
BREAKOUT_SENTINEL = 999999
def _to_iso(value) -> str:
"""Convierte una fecha/timestamp de pandas a ISO YYYY-MM-DD."""
# pandas Timestamp y datetime.date/datetime exponen strftime.
if hasattr(value, "strftime"):
return value.strftime("%Y-%m-%d")
# Fallback: ya viene como string ISO o similar; recorta a 10 chars (fecha).
return str(value)[:10]
def _coerce_value(raw):
"""Normaliza el valor de una related_query rising/top a int o sentinel.
pytrends devuelve enteros para top y la mayoría de rising, pero rising puede
traer la cadena "Breakout". Cualquier valor no numérico se mapea al sentinel.
"""
if isinstance(raw, str):
if raw.strip().lower() == "breakout":
return BREAKOUT_SENTINEL
try:
return int(float(raw))
except (ValueError, TypeError):
return BREAKOUT_SENTINEL
try:
return int(raw)
except (ValueError, TypeError):
return None
def scrape_google_trends(
keywords: list[str],
geo: str = "ES",
timeframe: str = "now 7-d",
include_related: bool = True,
) -> list[dict]:
"""Capta interés de búsqueda de Google Trends para una lista de keywords.
Construye un único payload de pytrends (keywords + geo + timeframe) y aplana
interest_over_time y, opcionalmente, related_queries (rising + top) en filas
homogéneas. El interés es relativo 0-100, nunca volumen absoluto.
Args:
keywords: lista de términos/nichos a consultar (máx. 5 por payload — límite
de Google Trends). Cada elemento es una keyword.
geo: código de país ISO-3166 (ej. "ES", "US", "" para mundial).
timeframe: ventana temporal en sintaxis pytrends (ej. "now 7-d",
"today 3-m", "today 12-m", "2024-01-01 2024-12-31").
include_related: si True, añade filas metric="rising" y metric="top" de
related_queries por keyword. Si False, solo interest_over_time.
Returns:
Lista de dicts con EXACTAMENTE estas claves (sin id/snapshot_date/scraped_at,
que los añade el ingest):
geo, timeframe, keyword, metric, point_date, value, related_query
Tres familias de fila según `metric`:
- "interest_over_time": una por (keyword, punto temporal). point_date=fecha
ISO, value=interés 0-100, related_query=None.
- "rising": related_queries rising (si include_related). related_query=query,
value=valor rising (Breakout→BREAKOUT_SENTINEL), point_date=None.
- "top": related_queries top (si include_related). related_query=query,
value=valor 0-100, point_date=None.
Raises:
RuntimeError: si Google rate-limitea (429) tras agotar los reintentos, o si
pytrends falla de forma no recuperable.
"""
# Import dentro de la función: pytrends es dependencia impura/externa.
from pytrends.request import TrendReq
if not keywords:
return []
pytrends = TrendReq(hl="es-ES", tz=60)
# ---- build_payload con backoff ante 429 ----
backoff = [5, 15, 30]
last_err = None
for attempt in range(len(backoff) + 1):
try:
pytrends.build_payload(keywords, geo=geo, timeframe=timeframe)
last_err = None
break
except Exception as exc: # pragma: no cover - depende de la red
last_err = exc
msg = str(exc).lower()
is_rate_limit = "429" in msg or "too many requests" in msg or "rate" in msg
if attempt < len(backoff) and is_rate_limit:
time.sleep(backoff[attempt])
continue
if is_rate_limit:
raise RuntimeError(
"Google Trends rate-limited (429): se agotaron los reintentos "
f"({len(backoff)} backoffs {backoff}s). pytrends usa una API no "
"oficial y Google la limita agresivamente. Reintenta más tarde."
) from exc
raise RuntimeError(
f"build_payload falló de forma no recuperable: {exc}"
) from exc
if last_err is not None:
raise RuntimeError(f"build_payload no completó: {last_err}")
rows: list[dict] = []
# ---- interest_over_time ----
try:
iot = pytrends.interest_over_time()
except Exception as exc: # pragma: no cover - depende de la red
raise RuntimeError(f"interest_over_time falló: {exc}") from exc
if iot is not None and not iot.empty:
# El índice es la fecha; cada columna es una keyword + 'isPartial' (ignorar).
for idx, record in iot.iterrows():
point_date = _to_iso(idx)
for kw in keywords:
if kw not in record:
continue
rows.append(
{
"geo": geo,
"timeframe": timeframe,
"keyword": kw,
"metric": "interest_over_time",
"point_date": point_date,
"value": int(record[kw]),
"related_query": None,
}
)
# ---- related_queries (rising + top) ----
if include_related:
try:
related = pytrends.related_queries()
except Exception as exc: # pragma: no cover - depende de la red
raise RuntimeError(f"related_queries falló: {exc}") from exc
related = related or {}
for kw in keywords:
entry = related.get(kw) or {}
for metric in ("rising", "top"):
df = entry.get(metric)
if df is None or getattr(df, "empty", True):
continue
for _, qrow in df.iterrows():
rows.append(
{
"geo": geo,
"timeframe": timeframe,
"keyword": kw,
"metric": metric,
"point_date": None,
"value": _coerce_value(qrow.get("value")),
"related_query": qrow.get("query"),
}
)
return rows
if __name__ == "__main__":
# Self-test: el import siempre debe funcionar. Una llamada real a Google puede
# dar 429 en este entorno; la capturamos y reportamos sin fallar.
print("import OK")
try:
out = scrape_google_trends(["python", "rust"], geo="ES", timeframe="now 7-d")
n_iot = sum(1 for r in out if r["metric"] == "interest_over_time")
n_rising = sum(1 for r in out if r["metric"] == "rising")
n_top = sum(1 for r in out if r["metric"] == "top")
print(
f"ok: {len(out)} filas "
f"(interest_over_time={n_iot}, rising={n_rising}, top={n_top})"
)
if out:
print("muestra:", out[0])
except RuntimeError as exc:
print(f"rate-limited o error de red (esperado en este entorno): {exc}")
@@ -0,0 +1,99 @@
---
name: scrape_tiktok_creative
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def scrape_tiktok_creative(country: str = 'ES', kind: str = 'hashtag', limit: int = 50, period: int = 7) -> list[dict]"
description: "Capta tendencias del TikTok Creative Center (hashtags, canciones, creadores y videos virales con metricas reales) via su API JSON interna creative_radar_api. Headers realistas con requests, paginacion, parseo tolerante a cambios de schema. Devuelve filas 1:1 con la tabla Postgres tiktok_trends. Impure: hace HTTP a un endpoint interno no publico que puede romperse o exigir anti-bot."
tags: [tiktok, social, trends, market-intel, datascience]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [requests]
params:
- name: country
desc: "Codigo ISO de pais del ranking (ej. 'ES', 'US', 'MX'). El Creative Center segmenta las tendencias por mercado. Default 'ES'."
- name: kind
desc: "Tipo de tendencia: 'hashtag' (default, el mas estable), 'song', 'creator' o 'video'. Cada uno usa un endpoint interno distinto. Empieza por hashtag si no estas seguro."
- name: limit
desc: "Numero maximo de filas a devolver. El endpoint pagina de 50 en 50; la funcion concatena paginas hasta alcanzar limit o agotar resultados. Default 50."
- name: period
desc: "Ventana temporal en dias. Solo acepta 7 (default), 30 o 120 — el endpoint rechaza otros valores con error de validacion."
output: "Lista de dicts con EXACTAMENTE las claves: country (str), kind (str), name (str|None), rank (int|None), views (int|None, BIGINT), growth_pct (float|None), industry (str|None), url (str|None). Mapea 1:1 con la tabla Postgres tiktok_trends (sin id/snapshot_date/scraped_at). Devuelve [] si el endpoint responde OK pero sin items para el segmento. Lanza ValueError (kind/period invalidos) o RuntimeError (403 anti-bot, HTTP de error, JSON invalido, code de error logico)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/datascience/scrape_tiktok_creative.py"
notes: |
ESTRATEGIA: el Creative Center (ads.tiktok.com/business/creativecenter) es una
SPA JS-rendered, pero alimenta sus rankings desde una API interna de facto bajo
https://ads.tiktok.com/creative_radar_api/v1/popular_trend/... Esta funcion habla
directamente con ese endpoint con requests (mucho mas barato que un navegador
headless CUANDO responde). El parseo tolera variaciones del schema (data.list,
data.hashtags, data.items...) y nombres de campo distintos por kind.
REALISMO: en pruebas reales desde un entorno headless/datacenter el endpoint
respondio con code=40101 ("no permission") — rechazo anti-bot por falta de los
tokens de sesion firmados (anonymous-user-id, user-sign, timestamp) que la SPA
genera en cliente y que no se pueden falsear fuera del navegador. La funcion NO
inventa datos: en ese caso lanza RuntimeError con un mensaje claro. Se considera
el comportamiento esperado, no un bug de la funcion.
---
## Ejemplo
```python
from datascience.scrape_tiktok_creative import scrape_tiktok_creative
# Top 50 hashtags virales en Espana, ultimos 7 dias.
rows = scrape_tiktok_creative(country="ES", kind="hashtag", limit=50, period=7)
# rows[0] -> {
# "country": "ES", "kind": "hashtag", "name": "fyp", "rank": 1,
# "views": 12450000, "growth_pct": 42.0, "industry": "Entertainment",
# "url": "https://ads.tiktok.com/business/creativecenter/hashtag/fyp/pc/en"
# }
# Canciones en tendencia en US, ventana de 30 dias.
songs = scrape_tiktok_creative(country="US", kind="song", limit=20, period=30)
# Las filas casan 1:1 con un INSERT en la tabla Postgres tiktok_trends
# (sin id/snapshot_date/scraped_at, que los pone la BD).
```
## Cuando usarla
Usala cuando necesites market intelligence de TikTok: detectar hashtags, canciones,
creadores o productos virales por pais con metricas reales (views, ranking,
crecimiento) para alimentar la tabla `tiktok_trends`, un dashboard de tendencias o
un analisis de oportunidad de contenido. Empieza por `kind="hashtag"` (el endpoint
mas estable) antes de probar song/creator/video. Si el fetch HTTP devuelve
RuntimeError por anti-bot, baja al browser MCP/CDP del ecosistema.
## Gotchas
- **El endpoint interno NO es una API publica versionada.** `creative_radar_api/v1/popular_trend`
es un contrato de facto que TikTok cambia sin aviso: ruta, parametros, schema del
JSON y claves de campo pueden romperse en cualquier deploy. El parseo es tolerante
pero no inmune; si TikTok mueve la lista a otra ruta, la funcion devuelve [] o
lanza RuntimeError.
- **Anti-bot real y frecuente.** Desde IPs de datacenter o entornos headless el
endpoint suele responder `403` o `code=40101 (no permission)`. Los rankings se
sirven solo a clientes con los tokens de sesion firmados que la SPA genera en
navegador (`anonymous-user-id`, `user-sign`, `timestamp`). Esos tokens NO se
pueden falsear con requests. **Verificado en self-test: respondio code=40101.**
- **Alternativa robusta cuando el HTTP esta bloqueado:** usar el browser MCP/CDP del
ecosistema (regla `flow_replay.md`) navegando el Creative Center con una sesion de
chrome real, dejando que el cliente genere los tokens, y leyendo el JSON de la
respuesta XHR o el DOM renderizado. Es mas caro pero pasa el anti-bot.
- **No inventa datos.** Si no puede extraer de verdad, lanza una excepcion clara con
el codigo HTTP / code logico para diagnostico, en vez de devolver filas falsas.
- **growth_pct heuristico:** el Creative Center expresa el crecimiento como ratio
(0.42) o como porcentaje (42) segun campo/version; la funcion normaliza ratios en
[-1, 1] a porcentaje (*100). Si TikTok cambia la convencion, revisar `_row_from_item`.
- **Rate limiting:** la paginacion hace una request por pagina de 50. Para `limit`
altos puedes encadenar varias requests rapidas — anade backoff propio si scrapeas
muchos paises seguidos para no acelerar el bloqueo.
@@ -0,0 +1,287 @@
"""Scrape de tendencias del TikTok Creative Center via su API JSON interna.
El TikTok Creative Center (https://ads.tiktok.com/business/creativecenter/) es una
SPA JS-rendered, pero alimenta sus rankings desde una API interna documentada de
facto bajo `https://ads.tiktok.com/creative_radar_api/v1/popular_trend/...`.
Esta funcion habla DIRECTAMENTE con ese endpoint usando `requests` con headers
realistas, evitando el coste de un navegador headless cuando el endpoint responde.
ADVERTENCIA: el endpoint interno cambia sin aviso, puede exigir token anti-bot y
desde IPs de datacenter/headless suele devolver 403 o listas vacias. La funcion
falla con una excepcion clara cuando el endpoint no responde como se espera. La
alternativa robusta para entornos bloqueados es el browser MCP/CDP del ecosistema
navegando el Creative Center con una sesion real (ver `## Gotchas` del .md).
"""
from __future__ import annotations
import requests
# Endpoints internos del Creative Center por tipo de tendencia. Son APIs de facto
# (no publicas ni versionadas como contrato) y pueden romperse en cualquier deploy
# de TikTok. Se mantienen aqui en un solo sitio para facilitar el parcheo.
_BASE = "https://ads.tiktok.com/creative_radar_api/v1/popular_trend"
_ENDPOINTS: dict[str, str] = {
"hashtag": f"{_BASE}/hashtag/list",
"song": f"{_BASE}/song/list",
"creator": f"{_BASE}/creator/list",
"video": f"{_BASE}/list",
}
# Periodos validos del Creative Center (en dias). El endpoint rechaza otros valores.
_VALID_PERIODS = {7, 30, 120}
_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Accept-Language": "en-US,en;q=0.9,es;q=0.8",
"Referer": "https://ads.tiktok.com/business/creativecenter/inspiration/popular/hashtag/pc/en",
"Origin": "https://ads.tiktok.com",
# El Creative Center exige este header para servir JSON; sin el devuelve HTML.
"anonymous-user-id": "",
"timestamp": "",
"user-sign": "",
}
def _to_int(value: object) -> int | None:
"""Convierte un valor numerico del payload a int, o None si no es parseable."""
if value is None:
return None
try:
# Algunos campos vienen como string ("1234567") o float (1234567.0).
return int(float(value))
except (TypeError, ValueError):
return None
def _to_float(value: object) -> float | None:
"""Convierte un valor numerico del payload a float, o None si no es parseable."""
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _extract_items(payload: dict) -> list[dict]:
"""Localiza la lista de items dentro del JSON, tolerando variaciones del schema.
El Creative Center ha servido la lista bajo distintas rutas a lo largo del
tiempo (`data.list`, `data.hashtags`, `data.items`, ...). Se prueban en orden.
"""
data = payload.get("data")
if not isinstance(data, dict):
return []
for key in ("list", "hashtags", "songs", "creators", "videos", "items"):
candidate = data.get(key)
if isinstance(candidate, list):
return candidate
# Fallback: la primera lista no vacia que aparezca dentro de data.
for value in data.values():
if isinstance(value, list) and value:
return value
return []
def _row_from_item(item: dict, country: str, kind: str, fallback_rank: int) -> dict:
"""Normaliza un item crudo del payload a la fila canonica de `tiktok_trends`.
Claves de salida (1:1 con la tabla Postgres): country, kind, name, rank, views,
growth_pct, industry, url. Tolera nombres de campo distintos por tipo de kind.
"""
name = (
item.get("hashtag_name")
or item.get("title")
or item.get("name")
or item.get("nickname")
or item.get("song_title")
or item.get("music_name")
or item.get("keyword")
)
rank = _to_int(item.get("rank")) or _to_int(item.get("trend_rank"))
if rank is None:
rank = fallback_rank
# Volumen de visualizaciones / publicaciones segun el tipo de tendencia.
views = (
_to_int(item.get("video_views"))
or _to_int(item.get("views"))
or _to_int(item.get("publish_cnt"))
or _to_int(item.get("post_count"))
or _to_int(item.get("play_count"))
)
# El Creative Center expresa el crecimiento como ratio (0.42) o porcentaje (42).
growth_raw = item.get("trend") or item.get("rank_diff") or item.get("growth")
growth_pct = _to_float(growth_raw)
if growth_pct is not None and -1.0 <= growth_pct <= 1.0:
# Heuristica: si viene como ratio en [-1,1], normalizar a porcentaje.
growth_pct = round(growth_pct * 100.0, 2)
industry = None
industries = item.get("industry_info") or item.get("industry")
if isinstance(industries, dict):
industry = industries.get("value") or industries.get("label")
elif isinstance(industries, list) and industries:
first = industries[0]
industry = first.get("value") if isinstance(first, dict) else str(first)
elif isinstance(industries, str):
industry = industries
url = item.get("url") or item.get("link")
if not url and kind == "hashtag" and name:
slug = str(name).lstrip("#")
url = (
"https://ads.tiktok.com/business/creativecenter/hashtag/"
f"{slug}/pc/en"
)
return {
"country": country,
"kind": kind,
"name": str(name) if name is not None else None,
"rank": rank,
"views": views,
"growth_pct": growth_pct,
"industry": industry,
"url": url,
}
def scrape_tiktok_creative(
country: str = "ES",
kind: str = "hashtag",
limit: int = 50,
period: int = 7,
) -> list[dict]:
"""Capta tendencias del TikTok Creative Center via su API JSON interna.
Args:
country: codigo ISO de pais del ranking (ej. "ES", "US", "MX"). El Creative
Center segmenta las tendencias por mercado.
kind: tipo de tendencia. Uno de: "hashtag" (default, el mas estable),
"song", "creator", "video".
limit: numero maximo de filas a devolver (el endpoint pagina de 50 en 50).
period: ventana temporal en dias. Validos: 7 (default), 30, 120.
Returns:
Lista de dicts con EXACTAMENTE las claves: country, kind, name, rank, views,
growth_pct, industry, url. Mapea 1:1 con la tabla Postgres `tiktok_trends`
(sin id/snapshot_date/scraped_at). `views` es int|None, `growth_pct` es
float|None, `rank` es int|None. Devuelve [] si el endpoint responde OK pero
sin items para el segmento solicitado.
Raises:
ValueError: si `kind` o `period` no son validos.
RuntimeError: si el endpoint interno no responde como JSON util (HTTP de
error, anti-bot, cambio de schema, bloqueo desde datacenter/headless).
El mensaje indica el codigo HTTP o la causa para diagnostico.
"""
if kind not in _ENDPOINTS:
raise ValueError(
f"kind invalido: {kind!r}. Validos: {sorted(_ENDPOINTS)}"
)
if period not in _VALID_PERIODS:
raise ValueError(
f"period invalido: {period}. Validos: {sorted(_VALID_PERIODS)}"
)
endpoint = _ENDPOINTS[kind]
rows: list[dict] = []
page = 1
page_size = 50
session = requests.Session()
session.headers.update(_HEADERS)
while len(rows) < limit:
params = {
"page": page,
"limit": page_size,
"period": period,
"country_code": country,
"sort_by": "popular",
}
try:
resp = session.get(endpoint, params=params, timeout=15)
except requests.RequestException as exc:
raise RuntimeError(
"TikTok Creative Center: fallo de red contactando el endpoint "
f"interno {endpoint!r}: {exc}. Alternativa: usar el browser "
"MCP/CDP del ecosistema con sesion real (ver .md ## Gotchas)."
) from exc
if resp.status_code == 403:
raise RuntimeError(
"TikTok Creative Center devolvio 403 (anti-bot / IP de "
"datacenter bloqueada). El endpoint JSON interno requiere "
"tokens de sesion (anonymous-user-id/user-sign) que no se "
"pueden falsear desde headless. Alternativa robusta: browser "
"MCP/CDP navegando el Creative Center con sesion real."
)
if resp.status_code != 200:
raise RuntimeError(
f"TikTok Creative Center devolvio HTTP {resp.status_code} para "
f"{endpoint!r}. El endpoint interno pudo cambiar de ruta o de "
"contrato (no es una API publica versionada)."
)
try:
payload = resp.json()
except ValueError as exc:
raise RuntimeError(
"TikTok Creative Center no devolvio JSON (probable HTML de "
"challenge o pagina de login). El endpoint interno cambio o "
"exige sesion real. Alternativa: browser MCP/CDP."
) from exc
# TikTok envuelve la respuesta en {code, msg, data}. code != 0 = error logico.
code = payload.get("code")
if code not in (0, None):
raise RuntimeError(
f"TikTok Creative Center respondio code={code} "
f"({payload.get('msg', 'sin mensaje')}). El endpoint interno "
"rechazo la peticion (parametros o anti-bot)."
)
items = _extract_items(payload)
if not items:
break
for offset, item in enumerate(items):
if not isinstance(item, dict):
continue
rank_fallback = (page - 1) * page_size + offset + 1
rows.append(_row_from_item(item, country, kind, rank_fallback))
if len(rows) >= limit:
break
# Si la pagina vino incompleta, no hay mas resultados.
if len(items) < page_size:
break
page += 1
return rows[:limit]
if __name__ == "__main__":
# Self-test honesto: import OK obligatorio + UN intento de fetch real que NO
# falla la build por la red. Reporta si TikTok respondio o bloqueo/cambio.
print("import OK: scrape_tiktok_creative cargado")
try:
sample = scrape_tiktok_creative(country="ES", kind="hashtag", limit=10, period=7)
if sample:
print(f"FETCH REAL OK: {len(sample)} filas. Primera: {sample[0]}")
else:
print(
"FETCH REAL: el endpoint respondio pero sin items "
"(segmento vacio o anti-bot silencioso)."
)
except Exception as exc: # noqa: BLE001 -- self-test honesto, no propaga
print(f"FETCH REAL FALLO (esperable desde headless/datacenter): {exc}")
+4
View File
@@ -18,8 +18,12 @@ from .caldav_put_event import caldav_put_event
from .dav_list_resources import dav_list_resources
from .dav_get_resource import dav_get_resource
from .dav_delete_resource import dav_delete_resource
from .pg_insert_rows import pg_insert_rows
from .pg_apply_sql import pg_apply_sql
__all__ = [
"pg_insert_rows",
"pg_apply_sql",
"setup_logger",
"get_logger",
"generate_app_icon",
+59
View File
@@ -0,0 +1,59 @@
---
name: pg_apply_sql
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def pg_apply_sql(dsn: str, sql_path: str) -> int"
description: "Lee un archivo .sql y ejecuta su contenido completo contra PostgreSQL en un solo cursor.execute via psycopg2. Multi-statement en una transaccion (sin parametros). Pensado para migraciones idempotentes (el SQL usa IF NOT EXISTS). Commit al exito. Retorna el numero de statements aplicados (split por ;), minimo 1 si el script no esta vacio."
tags: [postgres, market-intel, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [psycopg2]
params:
- name: dsn
desc: "Cadena de conexion PostgreSQL en formato postgresql://user:pass@host:port/dbname."
- name: sql_path
desc: "Ruta al archivo .sql a aplicar (ej. db/migrations/001_init.sql)."
output: "Numero entero de statements no vacios aplicados (split por ;), minimo 1 si el script no esta vacio; 0 si el archivo esta vacio."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/infra/pg_apply_sql.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
from infra.pg_apply_sql import pg_apply_sql
dsn = "postgresql://scraper:secret@localhost:5432/captacion"
# db/migrations/001_init.sql con:
# CREATE TABLE IF NOT EXISTS leads_raw (
# id SERIAL PRIMARY KEY, name TEXT, city TEXT, score INT,
# snapshot_date DATE
# );
n = pg_apply_sql(dsn, "db/migrations/001_init.sql")
print(f"aplicados {n} statements") # aplicados 1 statements
```
## Cuando usarla
Cuando necesitas aplicar un archivo de migracion `.sql` (crear tablas, indices, columnas)
a Postgres antes de escribir datos. Usala al arrancar el pipeline de captacion_clientes
para garantizar el schema, y para iterar sobre `db/migrations/*.sql` en orden.
## Gotchas
- Idempotencia depende del SQL: el archivo DEBE usar `IF NOT EXISTS` / `ON CONFLICT` para poder re-aplicarse sin error. Esta funcion no lleva control de versiones de migracion — el caller decide que archivos aplica y en que orden.
- Todo el script va en UNA transaccion: si cualquier statement falla, se hace rollback de todo el archivo y se lanza RuntimeError.
- El conteo de statements (`split(";")`) es informativo y aproximado: un `;` dentro de un string literal o de un cuerpo de funcion PL/pgSQL infla la cuenta. No lo uses como verdad exacta, solo como indicador.
- NO pasa parametros: el contenido del `.sql` se ejecuta tal cual. No metas datos no confiables en el archivo — es para DDL/migraciones controladas, no para input de usuario.
- Requiere `psycopg2` instalado en el venv (import perezoso: el modulo importa sin la dependencia, pero la llamada falla con RuntimeError claro si falta).
- Archivo inexistente o ilegible lanza RuntimeError con la ruta.
+68
View File
@@ -0,0 +1,68 @@
"""Apply a .sql script file against a PostgreSQL database via psycopg2."""
from __future__ import annotations
from pathlib import Path
def pg_apply_sql(dsn: str, sql_path: str) -> int:
"""Read a .sql file and execute its full contents against PostgreSQL.
The whole script is sent in a single cursor.execute call. psycopg2 runs
multi-statement scripts in one execute when there are no bound parameters,
so DDL files with several statements separated by ";" apply atomically in
one transaction. Designed for idempotent migrations (the SQL itself uses
"IF NOT EXISTS"). Commits on success.
Args:
dsn: Connection string, e.g. "postgresql://user:pass@host:port/dbname".
sql_path: Path to the .sql file to apply (e.g. db/migrations/001_init.sql).
Returns:
Number of non-empty statements applied (counted by splitting on ";").
At minimum 1 when the script is non-empty.
Raises:
RuntimeError: If the file cannot be read, or the connection / execution
fails. The original exception is chained for debugging.
"""
path = Path(sql_path)
try:
script = path.read_text(encoding="utf-8")
except OSError as exc:
raise RuntimeError(
f"pg_apply_sql could not read {sql_path!r}: {exc}"
) from exc
if not script.strip():
return 0
# Lazy import so the module loads even without psycopg2 installed.
try:
import psycopg2
except ImportError as exc: # pragma: no cover - exercised only without dep
raise RuntimeError(
"psycopg2 is required for pg_apply_sql; install psycopg2-binary"
) from exc
# Best-effort statement count (informational return value only). Strip
# blank fragments produced by a trailing semicolon.
statement_count = sum(1 for part in script.split(";") if part.strip())
statement_count = max(statement_count, 1)
conn = None
try:
conn = psycopg2.connect(dsn)
with conn.cursor() as cur:
cur.execute(script)
conn.commit()
return statement_count
except Exception as exc:
if conn is not None:
conn.rollback()
raise RuntimeError(
f"pg_apply_sql failed applying {sql_path!r}: {exc}"
) from exc
finally:
if conn is not None:
conn.close()
+63
View File
@@ -0,0 +1,63 @@
---
name: pg_insert_rows
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def pg_insert_rows(dsn: str, table: str, rows: list[dict], add_snapshot_date: bool = True) -> int"
description: "Inserta filas (lista de dicts) en una tabla PostgreSQL de forma append-only via psycopg2.extras.execute_values. Deriva columnas de las claves del dict (union si difieren, rellena con None). Opcionalmente inyecta snapshot_date = date.today(). Insercion parametrizada (sin format de strings, evita inyeccion SQL). Commit y cierre de conexion. Retorna el numero de filas insertadas."
tags: [postgres, market-intel, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [psycopg2]
params:
- name: dsn
desc: "Cadena de conexion PostgreSQL en formato postgresql://user:pass@host:port/dbname."
- name: table
desc: "Nombre de la tabla destino (debe existir previamente)."
- name: rows
desc: "Lista de dicts; cada dict es una fila, sus claves son nombres de columna. Si los esquemas difieren se usa la union de claves y se rellena con None."
- name: add_snapshot_date
desc: "Si True y una fila no trae snapshot_date, inyecta snapshot_date = date.today() antes de insertar. Default True."
output: "Numero entero de filas insertadas (0 si rows esta vacio)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/infra/pg_insert_rows.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
from infra.pg_insert_rows import pg_insert_rows
dsn = "postgresql://scraper:secret@localhost:5432/captacion"
rows = [
{"name": "Cliente A", "city": "Madrid", "score": 87},
{"name": "Cliente B", "city": "Sevilla"}, # sin score -> NULL
]
# snapshot_date = hoy se inyecta en cada fila automaticamente
n = pg_insert_rows(dsn, "leads_raw", rows)
print(f"insertadas {n} filas") # insertadas 2 filas
```
## Cuando usarla
Cuando escribes datos scrapeados a Postgres en lote append-only y quieres la columna
`snapshot_date` poblada sin codigo extra. Usala antes de cualquier dashboard/consulta de
market-intel sobre el dato bruto. Cada llamada acumula una nueva foto historica.
## Gotchas
- La tabla debe existir antes de llamar — esta funcion NO crea schema (usa `pg_apply_sql` para eso).
- Es append-only: NO hace upsert ni deduplica. Llamadas repetidas duplican filas (por diseno, para historico).
- El esquema efectivo es la UNION de las claves de todas las filas; columnas ausentes en una fila se insertan como NULL. Si una clave no existe como columna en la tabla, Postgres lanza error y la transaccion entera hace rollback.
- `add_snapshot_date=True` solo rellena filas que NO traen ya `snapshot_date`; si tu dict ya la incluye, se respeta.
- Requiere `psycopg2` instalado en el venv (import perezoso: el modulo se importa sin la dependencia, pero la llamada falla con RuntimeError claro si falta).
- Conexion nueva por llamada (sin pool). Para muchas inserciones pequenas en bucle, agrupa las filas en una sola llamada.
+92
View File
@@ -0,0 +1,92 @@
"""Append-only batch insert of dict rows into a PostgreSQL table via psycopg2."""
from __future__ import annotations
from datetime import date
def pg_insert_rows(
dsn: str,
table: str,
rows: list[dict],
add_snapshot_date: bool = True,
) -> int:
"""Insert rows (list of dicts) into a PostgreSQL table, append-only.
Columns are derived from the dict keys. If rows have heterogeneous schemas,
the union of all keys is used and missing values are filled with None, so a
single parameterized statement covers every row. Insertion uses
psycopg2.extras.execute_values (no string formatting) to avoid SQL injection.
Args:
dsn: Connection string, e.g. "postgresql://user:pass@host:port/dbname".
table: Target table name (must already exist).
rows: List of dicts; each dict is one row, keys are column names.
add_snapshot_date: If True and a row lacks "snapshot_date", inject
snapshot_date = date.today() before inserting.
Returns:
Number of rows inserted.
Raises:
RuntimeError: If the connection or the insert fails. The original
psycopg2 exception is chained for debugging.
"""
if not rows:
return 0
# psycopg2 is imported lazily so the module imports without the dependency
# present (self-test / introspection) and fails clearly only when invoked.
try:
import psycopg2
from psycopg2 import extras as pg_extras
from psycopg2 import sql as pg_sql
except ImportError as exc: # pragma: no cover - exercised only without dep
raise RuntimeError(
"psycopg2 is required for pg_insert_rows; install psycopg2-binary"
) from exc
# Work on copies so we never mutate the caller's dicts.
prepared: list[dict] = [dict(row) for row in rows]
if add_snapshot_date:
today = date.today()
for row in prepared:
row.setdefault("snapshot_date", today)
# Stable union of columns across all rows (first-seen order).
columns: list[str] = []
seen: set[str] = set()
for row in prepared:
for key in row:
if key not in seen:
seen.add(key)
columns.append(key)
if not columns:
return 0
# Build the value tuples in column order, filling absent keys with None.
values = [tuple(row.get(col) for col in columns) for row in prepared]
insert_stmt = pg_sql.SQL("INSERT INTO {table} ({cols}) VALUES %s").format(
table=pg_sql.Identifier(table),
cols=pg_sql.SQL(", ").join(pg_sql.Identifier(c) for c in columns),
)
conn = None
try:
conn = psycopg2.connect(dsn)
with conn.cursor() as cur:
pg_extras.execute_values(cur, insert_stmt, values)
conn.commit()
return len(values)
except Exception as exc:
if conn is not None:
conn.rollback()
raise RuntimeError(
f"pg_insert_rows failed inserting into {table!r}: {exc}"
) from exc
finally:
if conn is not None:
conn.close()
@@ -0,0 +1,72 @@
---
name: ingest_market_trends
kind: pipeline
lang: py
domain: pipelines
version: 1.0.0
purity: impure
signature: "ingest_market_trends(source)"
error_type: error_go_core
description: "Scrapea una fuente de tendencias de mercado (Amazon, Google Trends, TikTok, AliExpress o precios de competencia) y aterriza la foto del día en su tabla de la base de datos Postgres `trends`. Dispatcher one-shot pensado para invocarse desde dag_engine (un step por fuente). Proyecto captacion_clientes."
tags: [market-intel, scraping, trends, postgres, ingest, launcher]
uses_functions:
- scrape_amazon_bestsellers_py_datascience
- scrape_google_trends_py_datascience
- scrape_tiktok_creative_py_datascience
- scrape_aliexpress_trending_py_datascience
- scrape_competitor_prices_py_datascience
- pg_insert_rows_py_infra
uses_types: []
returns: []
returns_optional: false
file_path: python/functions/pipelines/ingest_market_trends.py
params:
- name: source
desc: "Fuente a scrapear: amazon | google_trends | tiktok | aliexpress | competitor. Una por invocación."
- name: config
desc: "Ruta del JSON de configuración (keywords, categorías, países). Default: projects/captacion_clientes/config/sources.json."
- name: dsn
desc: "DSN Postgres override. Si se omite, se resuelve por CAPTACION_DSN env -> .env del proyecto -> pass captacion/postgres."
output: "JSON por stdout con {source, scraped, inserted} (filas scrapeadas e insertadas en Postgres)."
---
Pipeline dispatcher que compone un scraper del registry con `pg_insert_rows` para
insertar la foto diaria de una fuente de tendencias en la base de datos `trends`.
Resuelve el DSN de Postgres por precedencia: `--dsn` → env `CAPTACION_DSN`
`projects/captacion_clientes/.env``pass captacion/postgres`. La configuración de cada
fuente (keywords, categorías, países) vive en `config/sources.json` del proyecto, sin
secretos.
## Ejemplo
```bash
# Amazon Best Sellers + Movers & Shakers de las categorías del config
fn run ingest_market_trends_py_pipelines --source amazon
# -> {"source": "amazon", "scraped": 420, "inserted": 420}
# Google Trends de las keywords del config
fn run ingest_market_trends_py_pipelines --source google_trends
# Precios de la competencia (lee competitor_targets de la propia DB)
fn run ingest_market_trends_py_pipelines --source competitor
```
## Cuando usarla
Cuando quieras capturar la foto del día de una fuente de tendencias en Postgres para
analizarla en Metabase. Es el step canónico que invoca dag_engine (un `function:` por
fuente) para el scraping programado diario/horario del proyecto captacion_clientes.
## Gotchas
- **TikTok y AliExpress** bloquean el scraping HTTP-directo desde datacenter/headless
(anti-bot, captcha). Esos `--source` lanzarán error (visible en el run de dag_engine)
hasta que se reimplementen vía browser MCP/CDP con sesión real (doctrina `flow_replay.md`).
Amazon y Google Trends sí funcionan por HTTP.
- **`--source competitor`** no hace nada si `competitor_targets` está vacía: hay que
insertar primero los objetivos (competidor + URL + product_key) a vigilar.
- Append-only: cada corrida inserta una foto nueva (no actualiza), de modo que Metabase
puede graficar la evolución temporal. No correr en bucle apretado o inflarás la tabla.
- Google Trends (pytrends) se rate-limitea (429); el scraper reintenta con backoff pero
con muchas keywords puede tardar o fallar.
@@ -0,0 +1,185 @@
"""ingest_market_trends — scrapea una fuente de tendencias y la aterriza en Postgres `trends`.
Pipeline dispatcher del proyecto captacion_clientes. Compone un scraper del registry
(según `--source`) con `pg_insert_rows` para insertar la foto (snapshot) del día en la
tabla correspondiente de la base de datos `trends`.
Pensado para invocarse desde dag_engine con un step `function:` (un step por fuente),
o a mano: `fn run ingest_market_trends_py_pipelines --source amazon`.
Resolución del DSN de Postgres (en este orden):
1. --dsn <dsn>
2. env CAPTACION_DSN
3. projects/captacion_clientes/.env (clave CAPTACION_DSN, gitignored)
4. pass captacion/postgres (construye el DSN; requiere gpg-agent desbloqueado)
Configuración de fuentes (keywords, categorías, ...) en
projects/captacion_clientes/config/sources.json (sin secretos).
"""
import argparse
import json
import os
import subprocess
import sys
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
sys.path.insert(0, os.path.join(ROOT, "python", "functions"))
from datascience import ( # noqa: E402
scrape_amazon_bestsellers,
scrape_google_trends,
scrape_tiktok_creative,
scrape_aliexpress_trending,
scrape_competitor_prices,
)
from infra import pg_insert_rows # noqa: E402
PROJECT_DIR = os.path.join(ROOT, "projects", "captacion_clientes")
DEFAULT_CONFIG = os.path.join(PROJECT_DIR, "config", "sources.json")
DEFAULT_ENV = os.path.join(PROJECT_DIR, ".env")
SOURCES = ("amazon", "google_trends", "tiktok", "aliexpress", "competitor")
def resolve_dsn(cli_dsn: str | None) -> str:
"""Resuelve el DSN de Postgres según la precedencia documentada."""
if cli_dsn:
return cli_dsn
if os.environ.get("CAPTACION_DSN"):
return os.environ["CAPTACION_DSN"]
if os.path.exists(DEFAULT_ENV):
with open(DEFAULT_ENV) as fh:
for line in fh:
line = line.strip()
if line.startswith("CAPTACION_DSN="):
return line.split("=", 1)[1].strip()
# Fallback: construir desde pass
try:
pw = subprocess.check_output(
["pass", "show", "captacion/postgres"], text=True
).splitlines()[0].strip()
return f"postgresql://captacion:{pw}@localhost:5433/trends"
except Exception as exc: # noqa: BLE001
raise RuntimeError(
"No se pudo resolver el DSN de Postgres (--dsn / CAPTACION_DSN / .env / pass)."
) from exc
def load_config(path: str) -> dict:
with open(path) as fh:
return json.load(fh)
def _read_competitor_targets(dsn: str) -> list[dict]:
"""Lee los objetivos activos de la tabla competitor_targets."""
import psycopg2
cols = ["competitor", "product_key", "product_name", "url", "price_selector", "currency"]
conn = psycopg2.connect(dsn)
try:
cur = conn.cursor()
cur.execute(
"SELECT competitor, product_key, product_name, url, price_selector, currency "
"FROM competitor_targets WHERE active = TRUE"
)
return [dict(zip(cols, row)) for row in cur.fetchall()]
finally:
conn.close()
def _dispatch(source: str, config: dict, dsn: str) -> dict:
"""Scrapea la fuente indicada y aterriza las filas en su tabla. Devuelve un resumen."""
if source == "amazon":
cfg = config.get("amazon", {})
rows: list[dict] = []
for category in cfg.get("categories", [None]):
for list_type in cfg.get("list_types", ["bestsellers"]):
batch = scrape_amazon_bestsellers(
marketplace=cfg.get("marketplace", "amazon.es"),
categories=[category] if category else None,
list_type=list_type,
max_items=cfg.get("max_items", 50),
)
for r in batch:
if not r.get("category"):
r["category"] = category or "general"
rows += batch
inserted = pg_insert_rows(dsn, "amazon_bestsellers", rows)
return {"source": source, "scraped": len(rows), "inserted": inserted}
if source == "google_trends":
cfg = config.get("google_trends", {})
rows = scrape_google_trends(
keywords=cfg.get("keywords", []),
geo=cfg.get("geo", "ES"),
timeframe=cfg.get("timeframe", "now 7-d"),
include_related=cfg.get("include_related", True),
)
inserted = pg_insert_rows(dsn, "google_trends", rows)
return {"source": source, "scraped": len(rows), "inserted": inserted}
if source == "tiktok":
cfg = config.get("tiktok", {})
rows = []
for kind in cfg.get("kinds", ["hashtag"]):
rows += scrape_tiktok_creative(
country=cfg.get("country", "ES"),
kind=kind,
limit=cfg.get("limit", 50),
period=cfg.get("period", 7),
)
inserted = pg_insert_rows(dsn, "tiktok_trends", rows)
return {"source": source, "scraped": len(rows), "inserted": inserted}
if source == "aliexpress":
cfg = config.get("aliexpress", {})
rows = []
for query in cfg.get("queries", [None]):
rows += scrape_aliexpress_trending(
query=query,
category=cfg.get("category"),
limit=cfg.get("limit", 40),
ship_to=cfg.get("ship_to", "ES"),
)
inserted = pg_insert_rows(dsn, "aliexpress_trends", rows)
return {"source": source, "scraped": len(rows), "inserted": inserted}
if source == "competitor":
targets = _read_competitor_targets(dsn)
if not targets:
return {"source": source, "scraped": 0, "inserted": 0,
"note": "competitor_targets vacía — inserta objetivos para vigilar precios"}
rows = scrape_competitor_prices(targets)
inserted = pg_insert_rows(dsn, "competitor_prices", rows)
return {"source": source, "scraped": len(rows), "inserted": inserted}
raise ValueError(f"source desconocida: {source!r}. Válidas: {', '.join(SOURCES)}")
def ingest_market_trends(source: str, config: str | None = None, dsn: str | None = None) -> dict:
"""Punto de entrada del pipeline (lo invoca `fn run` y dag_engine con `source` posicional).
Resuelve la configuración y el DSN internamente, scrapea la fuente y aterriza la foto
en Postgres. Imprime el resumen JSON por stdout y lo devuelve.
"""
config_data = load_config(config or DEFAULT_CONFIG)
resolved_dsn = resolve_dsn(dsn)
summary = _dispatch(source, config_data, resolved_dsn)
print(json.dumps(summary, ensure_ascii=False))
return summary
def main() -> int:
ap = argparse.ArgumentParser(description="Ingest de tendencias de mercado a Postgres trends")
ap.add_argument("--source", required=True, choices=SOURCES, help="Fuente a scrapear")
ap.add_argument("--config", default=DEFAULT_CONFIG, help="Ruta del JSON de configuración")
ap.add_argument("--dsn", default=None, help="DSN Postgres (override)")
args = ap.parse_args()
ingest_market_trends(args.source, config=args.config, dsn=args.dsn)
return 0
if __name__ == "__main__":
sys.exit(main())