Saleor Store
우리 컬렉션을 쇼핑하세요
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Monitorea publicaciones de autos usados en Argentina, detecta las que coinciden con tus búsquedas, las prioriza con un Opportunity Score y te avisa por la web, Telegram o email.
El plan del MVP (web + Supabase) está en docs/TECHNICAL_PLAN.md;
cómo publicarlo para el piloto, en docs/PILOT_SETUP.md.
Estado: F7 en curso (puesta en producción del piloto; por ahora corre en local) — la web (web/, Next.js + Supabase) es donde se
crean y editan las búsquedas, se ven los resultados, el detalle de cada
publicación (¿por qué apareció?, análisis de precio, histórico, red flags,
preguntas al vendedor), los estados, la watchlist y el inbox con Realtime. El
bot de Telegram ya no tiene wizard: vincula la cuenta (/start <código>) y
resuelve los botones de las alertas. Debajo siguen F1–F3: scraping por crawl
target, matching con razones y Opportunity Score, y el motor de
notificaciones. F5 suma el modo asistido con LLM, F6 el backoffice
(/admin), las métricas del piloto y el experimento de monetización, y F7
lo necesario para abrirlo a usuarios reales: páginas de privacidad y términos,
baja del email y borrado de cuenta, retención de datos y una base de tests
separada de la del piloto.
worker/ bot de Telegram, collectors, pipeline (Python, raíz de imports)
collectors/ scrapers por fuente: search() y fetch_detail()
normalization/ vehicle (catálogo + rapidfuzz), drafts (borradores del LLM), transmission, listing,
price_check, geo, fx
intelligence/ matching, comparables, scoring, levels, red_flags, seller_questions (puros)
pipeline/ crawl (targets + cadencia), ingest (upsert/snapshots/eventos),
enrich, watchlist, rematch, scoring, rescore, llm_jobs, scheduler (loop + alertas de Telegram)
llm/ capa LLM (modo asistido): LLMProvider, openai_api (Luna), claude_cli, anthropic_api, local (stub), schemas, prompts
bot/ Telegram: /start <código> (vinculación) y botones de las alertas
db/ psycopg 3 (pool async) + repos por tabla
tools/ scraper_cli, migrate_sqlite, explain_match, rematch, simulate_alert, llm_jobs, record_llm,
test_db, watchdog, supabase_keys
tests/
web/ Next.js (App Router) + @supabase/ssr + Tailwind/shadcn: landing, app, admin, /r/<id>, legales y /baja
ops/ el piloto en esta PC (F7): tareas programadas, supervisor, backup y actualización (docs/PILOT_SETUP.md)
supabase/ migraciones, seed.sql, templates de email y tests pgTAP (supabase CLI)
docs/ PRD y plan técnico
| Fuente | Tipo | Notas |
|---|---|---|
| MercadoLibre | HTTP sobre las páginas públicas (autos. y auto.mercadolibre.com.ar) | Mayor volumen. Sin sesión: lee «Publicados hoy» y la ficha para la fecha |
| Facebook Marketplace | Headless browser + sesión | Particulares — donde aparecen las gangas. Requiere login una vez |
| V6 | Headless browser | Particulares + concesionarias, fotos directas |
| Kavak | Headless browser | Inventario certificado, precios estables — buen anclaje para la mediana |
| Autocosmos | HTTP | Mayormente concesionarias; los avisos con solo "Anticipo" quedan como precio parcial |
La cadencia de cada fuente es sources.crawl_interval_seconds (ML 10 min,
Kavak/V6/Autocosmos 30 min, Facebook 60 min) y el ritmo de las fichas,
sources.detail_interval_seconds. Se editan en la base, sin deploy.
La antigüedad usa la fecha de publicación que informa la fuente (published_at).
Si falta, usa la primera detección (first_seen_at), que no cambia al volver a
ver el aviso. La web y las alertas distinguen «Publicado hace…» de «Detectado
hace…»; el orden por recientes y el componente de antigüedad usan el mismo respaldo.
/app/searches/new elegís
marca, modelo y versión del catálogo, años, precio máximo (USD o ARS), km,
transmisión, combustible, zona + radio (o el preset AMBA), fuentes,
preferencias blandas, frecuencia y nivel mínimo de alerta. Mientras la
escribís ves cuántas publicaciones actuales coinciden. Un modelo por
búsqueda (§13).TICK_INTERVAL_SECONDS, 5 min) las búsquedas habilitadas se agrupan por
(fuente, marca, modelo) con el rango más amplio de años y km y sin precio.
Dos usuarios que buscan el mismo modelo comparten un solo scrapeo. Un target
corre cuando vence su next_run_at; las fuentes van en paralelo y los
targets de una fuente en serie, con jitter (CRAWL_JITTER_SECONDS).
Mercado Libre y Facebook cargan publicaciones de los últimos 30 días
inicialmente; luego buscan publicadas hoy (Mercado Libre, hora argentina)
o en las últimas 24 horas (Facebook). Una interrupción amplía la ventana
hasta el último recorrido completo, con tope de 30 días. Se verifica la fecha
informada por la fuente; los avisos sin fecha verificable se excluyen de estas
búsquedas. Plan, configuración y validación.vehicle_catalog (alias + rapidfuzz), no del filtro de búsqueda; si
no se resuelven se usa el del target con normalization_confidence baja.
También transmisión, combustible, tipo de vendedor y price_usd con la
cotización del día, que queda congelada en fx_rates.first_seen_at fijo; listing_snapshots solo cuando cambia
precio, km, descripción, imágenes o atributos clave, y eventos
/ / (baja ≥ ).
Un aviso nuevo con el mismo que otro de los últimos 60 días y
precio ±10% se marca y sale de los comparables.cd C:\Users\Juan\Desktop\AutoMotive
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r worker\requirements.txt
python -m playwright install chromium
copy .env.example .env
# editá .env: TELEGRAM_TOKEN y DATABASE_URL
.env, fb_state.json y ml_state.json siguen en la raíz del repo. Los
comandos de Python se corren desde worker/.
Local, con Docker y la CLI de Supabase
(npx supabase ... funciona sin instalarla):
copy supabase\.env.example supabase\.env # URLs y SMTP (desarrollo: Mailpit)
cd worker; python -m tools.supabase_keys generate; cd .. # claves propias en vez de las de demo
npx supabase start # levanta Postgres + Auth y aplica migraciones + seed
cd worker; python -m tools.supabase_keys sync; cd .. # copia las claves a web\.env.local
npx supabase db reset # recrea la base desde cero
npx supabase test db # tests de RLS (pgTAP)
DATABASE_URL local: postgresql://postgres:[email protected]:54322/postgres.
Contra el proyecto hosteado usá la conexión directa o el session pooler; con
el transaction pooler (puerto 6543) el worker desactiva los prepared statements.
cd worker
python -m tools.migrate_sqlite ..\automotive.db --dry-run # muestra qué haría, no escribe
python -m tools.migrate_sqlite ..\automotive.db --email [email protected]
Copia listings (con su snapshot inicial), alertas → search_profiles (una por
marca+modelo del catálogo), seen_listings → matches de backfill (no se
re-notifica nada) y el cache de geocoding. Es idempotente. Con --email crea
el usuario vía Admin API (SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY); sin
email, el usuario de Telegram queda como usuario anónimo, igual que los que
crea el bot.
Facebook requiere sesión.
cd worker
python -m collectors.facebook
Se abre Chromium → entrás a tu cuenta → volvés a la consola → Enter. Genera
fb_state.json. Recomendación: usá una cuenta secundaria, FB es agresivo
detectando automatización en cuentas con poco historial.
La sesión del worker en el VPS no se puede crear en la PC: Facebook la revoca en su primer pedido desde otra IP. Se crea en el VPS mismo, viendo su navegador desde la PC con noVNC por un túnel SSH (detalle en ops/vps/fb-login.sh):
ssh -t -L 6080:127.0.0.1:6080 automotive-vps "sudo bash /opt/automotive/ops/vps/fb-login.sh"
Después abrí http://localhost:6080/vnc.html?autoconnect=1&resize=scale,
iniciá sesión y esperá: se guarda sola al llegar a la home y el script se
cierra. No hace falta reiniciar el worker.
cd worker
python -m tools.scraper_cli mercadolibre marca=Toyota modelo=Corolla anio_min=2018
python -m tools.scraper_cli v6 marca=Volkswagen modelo=Gol
python -m tools.scraper_cli kavak marca=Ford modelo=Ranger
python -m tools.scraper_cli facebook marca=Renault modelo=Duster
python -m tools.scraper_cli mercadolibre marca=Toyota modelo=Corolla origin_lat=-34.6037 origin_lon=-58.3816 radio_km=75
Imprime una tabla con los resultados — sirve para verificar que los selectores siguen vivos y para probar nuevos filtros antes de cargarlos como alerta.
Desde el 14/10/2026 el scraper de MercadoLibre lee las páginas públicas con pedidos HTTP comunes, sin navegador ni sesión:
autos.mercadolibre.com.ar/<marca>/<modelo>/;auto.mercadolibre.com.ar/MLA-….Antes usaba un Chrome visible con un perfil propio, con sesión iniciada en listado.mercadolibre.com.ar. Esa ruta pide cuenta, y MercadoLibre empezó a mostrarle un desafío de seguridad al Chrome automatizado.
docs/RECENT_PUBLICATIONS.md). Una ventana más larga se recorre entera, y la fecha de cada aviso sale de las publicaciones guardadas o de su ficha.cd worker
python main.py
Con WEB_BASE_URL apuntando a la web, los links de las alertas pasan por
/r/<id> (clics trackeados) y el bot manda a la web para crear búsquedas.
Sin esperar al loop:
cd worker
python -m tools.rematch # backfill de las búsquedas nuevas o editadas
python -m tools.simulate_alert <listing_id> # una publicación "recién llegada": match, score y alerta al inbox web
python -m tools.llm_jobs # procesa los pedidos del modo asistido en cola (--replay: respuestas grabadas)
/admin es solo para profiles.role = 'admin' (proxy + chequeo en el
servidor; lee con la service role). Para dar acceso:
update public.profiles set role = 'admin' where email = '[email protected]';
v_validation_criteria) contra sus
umbrales (app_config.validation_criteria), North Star, activación, ruido y
estado de las fuentes.model = true… score = 87), las razones campo por campo, el score_breakdown, los
comparables con el nivel de la cascada y los red flags. También por match
(/admin/matches/<id>).v_north_star_weekly,
v_activation, v_first_value, v_alert_funnel,
v_high_score_engagement, v_alerts_per_user_day, v_outcomes), el embudo
del CTA Pro y la latencia del LLM. No cuentan a los admins.app_config se editan desde ahí.TELEGRAM_ADMIN_CHAT_ID en .env, una
fuente que falla collector_failure_alert_after veces seguidas (3) manda un
Telegram al admin, y otro cuando vuelve a funcionar.pro_cta_viewed, pro_cta_clicked, waitlist_joined. No hay cobro.app_config.plan_limits): con enforced = false (el
piloto) todo está habilitado y cada vez que un usuario free pasa un límite
(búsquedas, alertas inmediatas, resultados visibles) se registra
plan_limit_hit. Con true se aplican: la segunda búsqueda se rechaza, las
alertas inmediatas pasan a diarias y los resultados se cortan en 50.La pestaña Asistido, predeterminada en /app/searches/new, escribe el texto en llm_jobs;
el worker lo interpreta con GPT-6 Luna (LLM_PROVIDER=openai, ver
.env.example), normaliza los borradores contra vehicle_catalog y la web
muestra un formulario editable por vehículo. Si el LLM falla o tarda más de
60 s, la web cae al formulario estructurado vacío. Para activarlo, completar
OPENAI_API_KEY en el .env de la raíz y reiniciar el worker. El modelo es
OPENAI_MODEL=gpt-6-luna; usa Responses con JSON Schema estricto y reasoning
none. La clave sólo la lee el worker. Ver configuración de Luna.
Con claude_cli el host del
worker necesita Claude Code instalado y logueado (en Windows, CLAUDE_CLI_PATH
con la ruta a claude.exe) y usa esa suscripción; para los usuarios del piloto,
LLM_PROVIDER=anthropic usa la API de Claude con ANTHROPIC_API_KEY (Haiku 4.5
por defecto, ANTHROPIC_MODEL), mismo prompt, mismo esquema y mismos tests de contrato.
cd worker
python -m tools.check_llm # comprobar configuración, sin llamadas API ni base de datos
Ver web/README.md: npx supabase start en la raíz y
npm run dev -- --hostname 127.0.0.1 en web/.
pytest # desde la raíz o desde worker/
cd worker; python -m tools.test_db; cd .. # crea/actualiza la base automotive_test (después de cada migración)
$env:TEST_DATABASE_URL = "postgresql://postgres:[email protected]:54322/automotive_test"
pytest # suma los tests contra Postgres
$env:LLM_SMOKE = "1"; pytest worker/tests/test_llm_contract.py -k smoke # las 20 frases contra claude -p real
npx supabase test db # pgTAP: RLS, las funciones SQL de la web y las vistas de métricas
cd web; npm test; npm run e2e # vitest y Playwright (desktop + 375 px)
Los tests de Postgres vacían las tablas de datos (listings, matches,
notifications…), así que nunca corren sobre la base del piloto: solo
aceptan una base local cuyo nombre termina en _test y que no sea la de
DATABASE_URL; si no, se saltean con el motivo. tools.test_db la clona del
stack local (esquema + sources, app_config y vehicle_catalog, sin datos
de usuarios). pgTAP corre cada archivo en una transacción que se descarta, y el
e2e solo toca sus propios datos con prefijo e2e.
| Comando | Acción |
|---|---|
/start <código> | vincula Telegram con tu cuenta de la web (el link de Ajustes → Telegram) |
/start, /help | qué hace el bot y links a la web |
/nuevaalerta, /editar, /alertas, /pausar, /activar, /borrar, /cancelar | el wizard se retiró en F4: responden con el link a la web |
Las alertas traen ⭐ Me interesa · ✖ Descartar · 🔎 Ver en Automotive. Si ya tenías alertas creadas con el wizard, al vincular la cuenta se pasan a tu usuario de la web.
Todo es determinístico y vive en funciones puras en worker/intelligence/ (plan técnico, sección 6). Para cada publicación nueva o actualizada de un crawl target, y para cada search profile de ese modelo:
ok, fail o unknown. Un fail descarta; un
unknown (la publicación no lo informa) deja pasar pero baja el score. Los
precios se comparan convirtiendo de moneda (el price_usd congelado del
día en que se vio el aviso): un aviso en ARS se compara contra un tope en USD.public.comparables(listing_id)): misma marca y
modelo, año ±1, km ±25%, vistos en 30 días, sin precios parciales ni
re-publicaciones. Cascada: misma versión y transmisión → misma transmisión →
solo el modelo; se usa el primer nivel con n ≥ comparables.min_n.app_config (score_weights, score_curves): se ajustan sin deploy.
Guardas de price_check.py: ≥65% bajo
la mediana se trata como anticipo y se excluye; 50–65% queda como máximo en
🟢 y con red flag; price_partial se excluye.app_config.level_thresholds): 🔥 ≥85 · 🟢 ≥70 · 🟡 ≥50 · ⚪.Todo se guarda en matches (score, level, score_breakdown, match_reasons,
price_ref, red_flags). El motor de notificaciones avisa los matches nuevos
que no son backfill según el nivel mínimo de cada búsqueda. Los matches se
re-scorean después del enrichment, todas las noches (app_config.rescore,
últimos 14 días) y al arrancar si cambió SCORING_VERSION.
cd worker
python -m tools.explain_match 123 # por id de match
python -m tools.explain_match --profile 7 --listing 4521
python -m tools.explain_match 123 --json
Muestra cada razón, los comparables usados, el breakdown del score, los red flags y las preguntas, y lo compara con lo guardado.
La primera corrida de cada target es silenciosa (backfill).
/sites/MLA/search está bloqueada para apps no-Partner, así que el flujo
operativo es sesión web guardada en ml_state.json.geocode_cache. Si un aviso no
tiene ubicación o no se puede geocodificar, la razón location queda
unknown: no se descarta, pero baja el score.comparables.min_n en la
tabla app_config o usá el techo duro precio_max_oportunidad por alerta.worker/collectors/<nombre>.py con una clase que herede BaseScraper, un
método async search(filters) -> list[Listing] y fetch_detail(url) -> ListingDetail.
Conviene que el parseo sean funciones puras sobre HTML (parse_search,
parse_detail) con un fixture en worker/tests/fixtures/html/.REGISTRY.SOURCES en config.py y a la tabla sources
(en supabase/seed.sql y con una migración para bases existentes).python -m tools.scraper_cli <nombre> marca=... modelo=....listing_newlisting_updatedprice_dropprice_drop_min_pctprobable_repost_ofRECOMMENDED_MAX_AGE_DAYS cuando la fuente expone la fecha) y se
puntúa (ver Matching y Opportunity Score). Los matches nuevos y las bajas
de precio pasan al motor de notificaciones
(notifications/), que decide según el nivel mínimo,
la frecuencia, el tope diario y el dedupe, y envía por Telegram, email y el
inbox web (o los junta en el digest diario). Lo ya visto no se re-notifica.
Aparte, el worker manda los mails del plan: prueba sin coincidencias y fin de
la prueba (mails del plan).fetch_detail() (descripción,
versión, transmisión, vendedor, todas las fotos), con una cola por fuente.gone.collector_runs (encontrados,
nuevos, actualizados, error) y cada falla de normalización, ingesta,
enrichment o notificación en pipeline_errors. Al arrancar, el worker
loguea en WARNING cada canal o métrica que el .env deja apagado.app_config.retention.listing_days (180) y que ningún usuario tocó (sin
interacción, alerta ni compra), con sus snapshots y matches.