feat(shell): auto-commit con 31 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 23:55:16 +02:00
parent 1430039688
commit e1e9bb7499
31 changed files with 3917 additions and 0 deletions
@@ -0,0 +1,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())