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,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