feat(shell): auto-commit con 31 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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,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}®ion={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}")
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user