feat: n8n initial commit — JavaScript (Node.js) n8n workflow automation + axios/cheerio/puppeteer/xlsx
This commit is contained in:
+33
@@ -0,0 +1,33 @@
|
|||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Environment / secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
scrape_log.txt
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Temporary / backup files
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
|
*_output.xlsx
|
||||||
|
|
||||||
|
# Excel working files (may contain PII)
|
||||||
|
*.xlsx
|
||||||
|
|
||||||
|
# Claude local settings (contains session info)
|
||||||
|
.claude/settings.local.json
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
# Automatización Bot Prisa Yachts
|
||||||
|
**Fecha:** 2 julio 2026
|
||||||
|
**Responsable:** Álvaro
|
||||||
|
**n8n:** https://n8n.crewinghunters.com
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ¿Qué es esto?
|
||||||
|
|
||||||
|
Sistema de automatización de captación de clientes para **Prisa Yachts LLC**, empresa de servicios náuticos en Florida. El proyecto tiene dos pilares:
|
||||||
|
|
||||||
|
1. **Bot de WhatsApp** — atiende consultas 24/7, clasifica el servicio solicitado y notifica al especialista correcto
|
||||||
|
2. **Directorio marino de Florida** — base de datos de marinas, astilleros, almacenamiento y tiendas de suministros, con emails y websites, para campañas de captación
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empresa: Prisa Yachts LLC
|
||||||
|
|
||||||
|
```
|
||||||
|
📞 (786) 396-3315
|
||||||
|
📧 info@prisayachts.com
|
||||||
|
🌐 prisayachts.com
|
||||||
|
📸 @prisayachts
|
||||||
|
|
||||||
|
Slogan: "Safe Command ♦ Luxury Maintenance and Care"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Servicios y responsables
|
||||||
|
|
||||||
|
| # | Servicio | Especialista | WhatsApp |
|
||||||
|
|---|----------|-------------|----------|
|
||||||
|
| 1 | Engines & Mechanical | Álvaro | +1 (954) 655-4084 |
|
||||||
|
| 2 | Electrical & Electronics / NMEA | Álvaro | +1 (954) 655-4084 |
|
||||||
|
| 3 | Teak Deck Recovery | Federico | +1 (754) 209-3375 |
|
||||||
|
| 4 | Captaining & Crewing | Federico | +1 (754) 209-3375 |
|
||||||
|
| 5 | Yacht Care & Detailing | Federico | +1 (754) 209-3375 |
|
||||||
|
| 6 | Crew Placement & Staffing | Federico | +1 (754) 209-3375 |
|
||||||
|
| 7 | Other / Otro | Ambos | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pilar 1 — Bot de WhatsApp
|
||||||
|
|
||||||
|
### Arquitectura
|
||||||
|
|
||||||
|
```
|
||||||
|
Cliente WhatsApp
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Meta Webhook ──► n8n Webhook (POST /prisa-whatsapp)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
State Machine (Code node)
|
||||||
|
│
|
||||||
|
┌───────────┴───────────┐
|
||||||
|
▼ ▼
|
||||||
|
Is Verify? Is Send to Client?
|
||||||
|
(GET challenge) │
|
||||||
|
│ ▼
|
||||||
|
Respond 200 Enviar mensaje al cliente
|
||||||
|
│
|
||||||
|
Notify Specialist?
|
||||||
|
┌────────┴────────┐
|
||||||
|
▼ ▼
|
||||||
|
Is Both? Is Alvaro?
|
||||||
|
┌──┴──┐ ┌──┴──┐
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
Álvaro Federico Álvaro Federico
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flujo del bot (state machine)
|
||||||
|
|
||||||
|
```
|
||||||
|
PASO 1 — "new" / reset words (hola, hello, hi, menu, start)
|
||||||
|
→ Enviar MENÚ PRINCIPAL (opciones 1-7)
|
||||||
|
→ Estado: waiting_category
|
||||||
|
|
||||||
|
PASO 2 — "waiting_category"
|
||||||
|
→ Cliente responde 1-7
|
||||||
|
→ Enviar formulario del servicio específico
|
||||||
|
→ Estado: collecting_info
|
||||||
|
|
||||||
|
PASO 3 — "collecting_info"
|
||||||
|
→ Cliente envía sus datos
|
||||||
|
→ Enviar CONFIRMACIÓN al cliente
|
||||||
|
→ Notificar especialista con resumen del caso
|
||||||
|
→ Estado: complete
|
||||||
|
|
||||||
|
PASO 4 — "complete"
|
||||||
|
→ Siguiente mensaje reinicia el flujo desde el menú
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estado actual del bot
|
||||||
|
|
||||||
|
| Ítem | Estado |
|
||||||
|
|------|--------|
|
||||||
|
| Workflow creado en n8n | ✅ Desplegado |
|
||||||
|
| Credential WhatsApp_Prisa | ✅ Creada (placeholder) |
|
||||||
|
| Workflow activo | ✅ Activo |
|
||||||
|
| Webhook URL | `https://n8n.crewinghunters.com/webhook/prisa-whatsapp` |
|
||||||
|
| Token de Meta | ⏳ **Pendiente aprobación Meta** |
|
||||||
|
| Phone Number ID | ⏳ **Pendiente aprobación Meta** |
|
||||||
|
|
||||||
|
### Pasos pendientes — Bot WhatsApp
|
||||||
|
|
||||||
|
- [ ] **Meta aprueba** la cuenta de WhatsApp Business
|
||||||
|
- [ ] Actualizar credencial `WhatsApp_Prisa` en n8n:
|
||||||
|
- Cambiar `PLACEHOLDER_PENDING_META_APPROVAL` → `Bearer <ACCESS_TOKEN>`
|
||||||
|
- [ ] En los 3 nodos HTTP del workflow, cambiar URL:
|
||||||
|
- Reemplazar `PLACEHOLDER_PHONE_NUMBER_ID` → `<YOUR_PHONE_NUMBER_ID>`
|
||||||
|
- [ ] Configurar webhook en Meta Developer Portal:
|
||||||
|
- URL: `https://n8n.crewinghunters.com/webhook/prisa-whatsapp`
|
||||||
|
- Verify Token: (cualquier string, ej. `prisayachts2025`)
|
||||||
|
- Suscribir a: `messages`
|
||||||
|
- [ ] Prueba final: enviar "hola" al número del negocio desde WhatsApp
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pilar 2 — Directorio Marino de Florida
|
||||||
|
|
||||||
|
### Objetivo
|
||||||
|
|
||||||
|
Construir la base de datos más completa posible de negocios náuticos en Florida para usar en campañas de captación (email + WhatsApp). Cada registro incluye:
|
||||||
|
|
||||||
|
`Nombre | Dirección | Teléfono | Email | Ciudad | Condado | Website`
|
||||||
|
|
||||||
|
### Categorías del directorio
|
||||||
|
|
||||||
|
| Hoja | Descripción |
|
||||||
|
|------|-------------|
|
||||||
|
| `MARINAS` | Puertos deportivos y marinas |
|
||||||
|
| `ASTILLEROS` | Boatyards y talleres náuticos |
|
||||||
|
| `ALMACENAMIENTO` | Boat storage / dry stack |
|
||||||
|
| `SUMINISTROS` | Tiendas de suministros marinos |
|
||||||
|
|
||||||
|
### Cobertura geográfica actual
|
||||||
|
|
||||||
|
El archivo principal es `Directorio_Marino_FL_Acumulativo_4.xlsx`.
|
||||||
|
|
||||||
|
| Sesión | Zona | Condados principales |
|
||||||
|
|--------|------|---------------------|
|
||||||
|
| 1-3 | Florida Sur | Miami-Dade, Broward, Palm Beach |
|
||||||
|
| 4 | Florida Centro | Martin, St. Lucie, Okeechobee, Sarasota, Charlotte, Lee, Collier, Monroe (Keys) |
|
||||||
|
| 5 | Florida Oeste-Centro | Pinellas, Hillsborough, Manatee, Pasco, Hernando |
|
||||||
|
| 6 *(add_north_florida.js)* | Florida Norte | Volusia, Flagler, Indian River, Brevard, Citrus, Levy, Dixie, Taylor, Wakulla, Franklin, Gulf, Bay, Walton, Okaloosa, Santa Rosa, Escambia, Duval, St. Johns, Nassau |
|
||||||
|
|
||||||
|
**Registros Florida Norte (Sesión 6):**
|
||||||
|
- Marinas: 128
|
||||||
|
- Astilleros: 24
|
||||||
|
- Almacenamiento: 16
|
||||||
|
- Suministros: 37
|
||||||
|
- **Total sesión 6: 205 registros**
|
||||||
|
|
||||||
|
### Scraper de emails (`scrape_marine_emails.js`)
|
||||||
|
|
||||||
|
Automatiza la búsqueda de emails y websites para los registros que no los tienen:
|
||||||
|
|
||||||
|
```
|
||||||
|
Para cada negocio en el Excel:
|
||||||
|
1. Buscar en DuckDuckGo: "nombre" ciudad Florida marina contact email
|
||||||
|
2. Filtrar dominios basura (Yelp, Google, Facebook, etc.)
|
||||||
|
3. Scrapear homepage → página /contact → página /about
|
||||||
|
4. Extraer emails con regex, puntuar por relevancia
|
||||||
|
5. Guardar resultado en el Excel (checkpoint cada 25 filas)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Última ejecución del scraper:**
|
||||||
|
- Procesó hoja MARINAS (174 filas)
|
||||||
|
- Guardado de emergencia a `_output.xlsx` (archivo bloqueado por Excel)
|
||||||
|
- Muchos registros de Miami/Broward sin email (negocios grandes, emails corporativos difíciles de scrapear)
|
||||||
|
|
||||||
|
### Pasos pendientes — Directorio
|
||||||
|
|
||||||
|
- [ ] Verificar completitud del Excel actual (contar registros por zona y categoria)
|
||||||
|
- [ ] Correr scraper en `MARINAS_NORTE`, `ASTILLEROS_NORTE`, `ALMACENAMIENTO_NORTE`, `SUMINISTROS_NORTE`
|
||||||
|
- [ ] Completar emails faltantes de Florida Sur manualmente (prioridad: marinas grandes)
|
||||||
|
- [ ] Decidir si expandir a otros estados (Georgia, Carolina del Sur, Texas...)
|
||||||
|
- [ ] Consolidar todos los acumulativos en un único archivo maestro limpio
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pilar 3 — Campañas de Captación (próximo paso)
|
||||||
|
|
||||||
|
Una vez el directorio esté completo con emails, lanzar campañas automáticas:
|
||||||
|
|
||||||
|
### Email marketing
|
||||||
|
- Plataforma a definir: Mailchimp / Brevo / n8n con SMTP
|
||||||
|
- Segmentación por tipo de negocio y zona geográfica
|
||||||
|
- Template con brochure de Prisa Yachts (ya existe `Prisa_Yachts_Brochure.pdf`)
|
||||||
|
- Medir aperturas y clicks
|
||||||
|
|
||||||
|
### WhatsApp marketing (futuro)
|
||||||
|
- Usar la API aprobada de Meta para enviar mensajes masivos (templates aprobados)
|
||||||
|
- Segmentar por servicio relevante para cada tipo de marina
|
||||||
|
- Flujo: contacto frío → si responde, entra al bot automáticamente
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Archivos del proyecto
|
||||||
|
|
||||||
|
| Archivo | Descripción |
|
||||||
|
|---------|-------------|
|
||||||
|
| `create_whatsapp_workflow.js` | Despliega el bot en n8n (ejecutar una vez) |
|
||||||
|
| `scrape_marine_emails.js` | Scraper de emails del directorio |
|
||||||
|
| `add_north_florida.js` | Agrega datos de Florida Norte al Excel |
|
||||||
|
| `convert_to_pdf.js` | Genera `Prisa_Yachts_Brochure.pdf` desde HTML |
|
||||||
|
| `Directorio_Marino_FL_Acumulativo_4.xlsx` | Base de datos principal |
|
||||||
|
| `Directorio_Marino_FL_Acumulativo_4_output.xlsx` | Copia de emergencia del scraper |
|
||||||
|
| `Directorio_Marino_FL_Acumulativo_5.xlsx` | Datos adicionales (pendiente integrar) |
|
||||||
|
| `Prisa_Yachts_Brochure.html` | Brochure en HTML |
|
||||||
|
| `Prisa_Yachts_Brochure.pdf` | Brochure generado |
|
||||||
|
| `scrape_log.txt` | Log de la última ejecución del scraper |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencias técnicas
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"axios": "^1.18.1", // HTTP requests
|
||||||
|
"cheerio": "^1.2.0", // HTML parsing / scraping
|
||||||
|
"puppeteer": "^25.1.0", // HTML → PDF
|
||||||
|
"xlsx": "^0.18.5" // Lectura/escritura Excel
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**n8n:** Self-hosted en `https://n8n.crewinghunters.com`
|
||||||
|
**API Key n8n:** En `create_whatsapp_workflow.js` (hardcoded — mover a variable de entorno)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resumen de prioridades
|
||||||
|
|
||||||
|
### 🔴 Bloqueado (esperando tercero)
|
||||||
|
1. Aprobación de Meta para WhatsApp Business API
|
||||||
|
|
||||||
|
### 🟡 En progreso
|
||||||
|
2. Completar directorio marino (scraper + revisión manual)
|
||||||
|
3. Integrar `Directorio_Marino_FL_Acumulativo_5.xlsx` al archivo principal
|
||||||
|
|
||||||
|
### 🟢 Listo para ejecutar cuando Meta apruebe
|
||||||
|
4. Actualizar credenciales en n8n y probar bot en producción
|
||||||
|
5. Lanzar primera campaña de email a marinas de Florida Sur
|
||||||
|
|
||||||
|
### 🔵 Próximos pasos estratégicos
|
||||||
|
6. Diseñar templates de WhatsApp aprobados por Meta
|
||||||
|
7. Construir workflow n8n para envío masivo de emails
|
||||||
|
8. Expandir directorio a otras costas (Gulf Coast, Carolinas)
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -0,0 +1,307 @@
|
|||||||
|
// add_north_florida.js
|
||||||
|
// Agrega directorio marino de Florida Norte al Excel existente
|
||||||
|
const xlsx = require('xlsx');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const EXCEL_PATH = path.resolve(__dirname, 'Directorio_Marino_FL_Acumulativo_4.xlsx');
|
||||||
|
|
||||||
|
// Encabezados de columnas
|
||||||
|
const HEADERS = ['#', 'NOMBRE', 'DIRECCION', 'TELEFONO', 'EMAIL', 'CIUDAD', 'CONDADO', 'WEBSITE'];
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// MARINAS — FLORIDA NORTE
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
const MARINAS = [
|
||||||
|
// VOLUSIA
|
||||||
|
['Halifax Harbor Marina','450 Basin St, Daytona Beach, FL 32114','(386) 671-3601','halifaxharbormarina@gmail.com','halifaxharbormarina.com','Daytona Beach','Volusia'],
|
||||||
|
['Loggerhead Daytona Beach Marina','721 Ballough Rd, Daytona Beach, FL 32114','(386) 523-3100','daytona@equitylifestyle.com','loggerheadmarinas.com','Daytona Beach','Volusia'],
|
||||||
|
['Coquina Marina Daytona Beach','841 Ballough Rd, Daytona Beach, FL 32114','(386) 317-0555','coquinamarinas@gmail.com','coquinamarinadaytona.com','Daytona Beach','Volusia'],
|
||||||
|
['Halifax River Yacht Club','331 S Beach St, Daytona Beach, FL 32114','(386) 255-7459','hryc@hryc.com','hryc.com','Daytona Beach','Volusia'],
|
||||||
|
['Daytona Marina & Boat Works','645 S Beach St, Daytona Beach, FL 32114','(386) 252-6421','dawn645@thedaytonamarina.com','thedaytonamarina.com','Daytona Beach','Volusia'],
|
||||||
|
['Loggerhead Inlet Harbor Marina','133 Inlet Harbor Rd, Ponce Inlet, FL 32127','(386) 767-3266','','loggerheadmarinas.com','Ponce Inlet','Volusia'],
|
||||||
|
['Sea Love Boat Works & Marina','4877 Front St, Ponce Inlet, FL 32127','(386) 761-5434','sealoveboatworks@gmail.com','sealoveboatworks.com','Ponce Inlet','Volusia'],
|
||||||
|
['Lighthouse Boatyard & Marina','4958 S Peninsula Dr, Ponce Inlet, FL 32127','(386) 767-0683','','','Ponce Inlet','Volusia'],
|
||||||
|
['Seven Seas Marina & Boatyard','3300 S Peninsula Dr, Port Orange, FL 32127','(386) 761-3221','sevenseasmarina@bellsouth.net','sevenseasmarina.com','Port Orange','Volusia'],
|
||||||
|
['Adventure Yacht Harbor','3948 S Peninsula Dr, Port Orange, FL 32127','(386) 756-2180','','adventureyachtharbor.com','Port Orange','Volusia'],
|
||||||
|
['New Smyrna Marina','200 Boatyard St, New Smyrna Beach, FL 32169','(386) 427-4514','','newsmyrnamarina.com','New Smyrna Beach','Volusia'],
|
||||||
|
['City Marina New Smyrna Beach','201 N Riverside Dr, New Smyrna Beach, FL 32168','(386) 409-2042','','cityofnsb.com','New Smyrna Beach','Volusia'],
|
||||||
|
['River Deck Marina','111 N Riverside Dr, New Smyrna Beach, FL 32168','(386) 428-7827','','riverdeckmarina.com','New Smyrna Beach','Volusia'],
|
||||||
|
["Gerry's Marina",'179 N Causeway, New Smyrna Beach, FL 32169','(386) 428-2341','','gerrysmarina.net','New Smyrna Beach','Volusia'],
|
||||||
|
['Smyrna Yacht Club','1201 S Riverside Dr, New Smyrna Beach, FL 32168','(386) 427-4040','','smyrnayachtclub.com','New Smyrna Beach','Volusia'],
|
||||||
|
["Cameron's Marina",'2001 S Riverside Dr, Edgewater, FL 32132','(904) 428-3063','','','Edgewater','Volusia'],
|
||||||
|
// FLAGLER
|
||||||
|
['Palm Coast Marina','15 Palm Coast Resort Blvd, Palm Coast, FL 32137','(386) 446-6370','','palmcoastmarina.net','Palm Coast','Flagler'],
|
||||||
|
['Hammock Beach Marina','102 Yacht Harbor Dr, Palm Coast, FL 32137','(386) 348-3114','info@hammockbeachmarina.com','hammockbeachmarina.com','Palm Coast','Flagler'],
|
||||||
|
// INDIAN RIVER
|
||||||
|
['Loggerhead Marina Vero Beach','1221 Marina Village Cir, Vero Beach, FL 32967','(772) 770-4470','','loggerheadmarinas.com','Vero Beach','Indian River'],
|
||||||
|
['Vero Beach City Marina','3611 Rio Vista Blvd, Vero Beach, FL 32963','(772) 978-4960','','covb.org','Vero Beach','Indian River'],
|
||||||
|
['Vero Marine Center','12 Royal Palm Pointe, Vero Beach, FL 32960','(772) 562-7922','','veromarine.com','Vero Beach','Indian River'],
|
||||||
|
['Fins Marina','1660 Indian River Dr, Sebastian, FL 32958','(772) 589-4843','info@finsmarina.com','finsmarina.com','Sebastian','Indian River'],
|
||||||
|
['Sebastian River Marina & Boatyard','8525 US Hwy 1, Sebastian, FL 32976','(772) 664-3029','d.hillman@sebastian-river-marina.com','marinaharborinmiccofl.com','Sebastian','Indian River'],
|
||||||
|
['Sebastian Inlet Marina','8685 US-1, Micco, FL 32976','(772) 664-8500','SIMoffice@mywindward.com','sebastianinletmarina.com','Micco','Indian River'],
|
||||||
|
['Treasure Coast Marina','5185 S US-1, Grant, FL 32949','','','','Grant','Indian River'],
|
||||||
|
['Vero Beach Yacht Club','3601 Rio Vista Blvd, Vero Beach, FL 32963','','','','Vero Beach','Indian River'],
|
||||||
|
// BREVARD - NORTE
|
||||||
|
['Titusville Municipal Marina','451 Marina Rd, Titusville, FL 32796','(321) 383-5600','tlawson@f3marina.com','titusvillemarina.com','Titusville','Brevard'],
|
||||||
|
['Westland Boatyard and Marina','419 N Washington Ave, Titusville, FL 32796','(321) 267-1667','','westlandmarina.com','Titusville','Brevard'],
|
||||||
|
['Kennedy Point Marina & Yacht Club','4749 S Washington Ave, Titusville, FL 32780','(321) 383-0280','','kennedypointmarina.com','Titusville','Brevard'],
|
||||||
|
// BREVARD - CENTRO
|
||||||
|
['Cape Marina','800 Scallop Dr, Cape Canaveral, FL 32920','(321) 783-8410','dockmaster@capemarina.com','capemarina.com','Cape Canaveral','Brevard'],
|
||||||
|
['Sunrise Marina','505 Glen Cheek Dr, Cape Canaveral, FL 32920','(321) 783-9535','','sunrisemarina.com','Cape Canaveral','Brevard'],
|
||||||
|
['Ocean Club Marina at Port Canaveral','930 Mullet Rd, Cape Canaveral, FL 32920','(321) 783-9001','info@oceanclubmarina-pc.com','oceanclubmarina-pc.com','Cape Canaveral','Brevard'],
|
||||||
|
['Harbortown Marina Canaveral','2700 Harbortown Dr, Merritt Island, FL 32952','(321) 453-0160','info@harbortownmarina.com','harbortownmarina.com','Merritt Island','Brevard'],
|
||||||
|
['Marker 24 Marina','1360 S Banana River Dr, Merritt Island, FL 32952','(321) 453-7888','','marker24marina.com','Merritt Island','Brevard'],
|
||||||
|
['Cape Crossing Resort & Marina','201 Ivory Coral Ln, Merritt Island, FL 32953','(321) 576-4967','info@capecrossing.com','capecrossing.com','Merritt Island','Brevard'],
|
||||||
|
['Cocoa Village Marina','90 Delannoy Ave, Cocoa, FL 32922','(321) 632-5445','cvmarina@cfl.rr.com','cocoavillagemarina.com','Cocoa','Brevard'],
|
||||||
|
['Island Time Marina','400 W Cocoa Beach Cswy, Cocoa Beach, FL 32931','(321) 613-4852','','islandtimemarina.com','Cocoa Beach','Brevard'],
|
||||||
|
// BREVARD - SUR
|
||||||
|
['Anchorage Yacht Basin','96 E Eau Gallie Cswy, Melbourne, FL 32937','(321) 773-3620','','anchorageyachtbasin.com','Melbourne','Brevard'],
|
||||||
|
['Melbourne Harbor Marina','2210 Front St #101, Melbourne, FL 32901','(321) 725-9054','info@melbourneharbor.com','melbourneharbor.com','Melbourne','Brevard'],
|
||||||
|
['Palm Bay Marina','4350 Dixie Hwy NE, Palm Bay, FL 32905','(321) 723-0851','','palmbaymarina.com','Palm Bay','Brevard'],
|
||||||
|
// CITRUS
|
||||||
|
['Twin Rivers Marina','2880 N Seabreeze Pt, Crystal River, FL 34429','(352) 795-3552','','twinriversmarina.com','Crystal River','Citrus'],
|
||||||
|
["Pete's Pier Marina",'1 SW 1st Pl, Crystal River, FL 34429','(352) 795-3302','','petespier.com','Crystal River','Citrus'],
|
||||||
|
['Ozello Keys Marina','2355 S Ripple Path, Crystal River, FL 34429','(352) 228-8095','','ozellokeysmarina.com','Crystal River','Citrus'],
|
||||||
|
['Riverhaven Marina','5296 S Riverview Cir, Homosassa, FL 34448','(352) 628-5545','','riverhavenmarina.com','Homosassa','Citrus'],
|
||||||
|
['Homosassa Springs Marina','10806 W Halls River Rd, Homosassa, FL 34448','(352) 628-7334','','homosassaspringsmarina.com','Homosassa','Citrus'],
|
||||||
|
// LEVY
|
||||||
|
['Cedar Key Marina II','12780 State Route 24, Cedar Key, FL 32625','(352) 543-6148','','cedarkeymarina.net','Cedar Key','Levy'],
|
||||||
|
['Cedar Key City Marina','Dock St, Cedar Key, FL 32625','(352) 543-5132','cityhall@cedarkeyfl.us','','Cedar Key','Levy'],
|
||||||
|
// DIXIE
|
||||||
|
['Horseshoe Beach Marina','262 E 3rd St, Horseshoe Beach, FL 32648','(352) 498-5405','','horseshoebeachmarina.com','Horseshoe Beach','Dixie'],
|
||||||
|
// TAYLOR
|
||||||
|
['Sea Hag Marina','322 S Riverside Dr, Steinhatchee, FL 32359','(352) 498-3008','','seahag.com','Steinhatchee','Taylor'],
|
||||||
|
['Steinhatchee River Inn & Marina','1111 Riverside Dr SE, Steinhatchee, FL 32359','(352) 498-4049','','riverinnandmarina.com','Steinhatchee','Taylor'],
|
||||||
|
['Steinhatchee River Haven Marina','1110 Riverside Drive, Steinhatchee, FL 32359','(352) 498-0709','riverhaven@bellsouth.net','riverhavenmarinaandmotel.com','Steinhatchee','Taylor'],
|
||||||
|
['Shelter Cove Marina','412 S Riverside Dr, Steinhatchee, FL 32359','(352) 210-1781','','sheltercovemarina.net','Steinhatchee','Taylor'],
|
||||||
|
['Good Times Motel and Marina','7022 SW Hwy 358, Steinhatchee, FL 32359','(352) 498-8088','','goodtimesmotelandmarina.com','Steinhatchee','Taylor'],
|
||||||
|
['Steinhatchee Landing Resort','203 Ryland Circle, Steinhatchee, FL 32359','(352) 498-0696','','steinhatcheelanding.com','Steinhatchee','Taylor'],
|
||||||
|
['Keaton Beach Marina & Motel','20650 Keaton Beach Dr, Perry, FL 32348','(850) 578-2897','','','Perry','Taylor'],
|
||||||
|
['Steinhatchee Marina at Deadman Bay','104 First Ave SW, Steinhatchee, FL 32359','(352) 578-1900','','shrmarina.com','Steinhatchee','Taylor'],
|
||||||
|
// WAKULLA
|
||||||
|
['Shell Point Harbor Marina','85 Harbour Point Dr, Crawfordville, FL 32327','(850) 544-5658','Accounting@shellpointharbormarina.com','shellpointharbormarina.com','Crawfordville','Wakulla'],
|
||||||
|
['Shields Marina','95 Riverside Dr, St. Marks, FL 32355','(850) 925-6158','info@shieldsmarina.com','shieldsmarina.com','St. Marks','Wakulla'],
|
||||||
|
['Rock Landing Marina','91 Rock Landing Rd, Panacea, FL 32346','(850) 984-5844','','rocklandingmarina.com','Panacea','Wakulla'],
|
||||||
|
// FRANKLIN
|
||||||
|
['Scipio Creek Marina','301 Market St, Apalachicola, FL 32320','(850) 653-8030','','scipiocreekmarina.com','Apalachicola','Franklin'],
|
||||||
|
['Water Street Hotel & Marina','329 Water St, Apalachicola, FL 32320','(850) 502-5134','','waterstreethotel.com','Apalachicola','Franklin'],
|
||||||
|
['Battery Park Marina','1 Bay Avenue, Apalachicola, FL 32320','(850) 653-9319','','','Apalachicola','Franklin'],
|
||||||
|
['Deep Water Marina','329 Water St, Apalachicola, FL 32320','(850) 653-8801','','','Apalachicola','Franklin'],
|
||||||
|
['Rainbow Inn & Marina','123 Water Street, Apalachicola, FL 32320','(850) 653-8139','','','Apalachicola','Franklin'],
|
||||||
|
['Sportsmans Lodge Marina','99 North Bayshore Dr, Eastpoint, FL 32328','(850) 670-8423','','','Eastpoint','Franklin'],
|
||||||
|
['The Moorings of Carrabelle','1000 Avenue A North, Carrabelle, FL 32322','','','themooringsofcarrabelle.com','Carrabelle','Franklin'],
|
||||||
|
['C-Quarters Marina','501 St James Ave Hwy 98, Carrabelle, FL 32322','(850) 697-8400','','c-quartersmarina.com','Carrabelle','Franklin'],
|
||||||
|
["Johnson's Carrabelle Marina",'803 NW Avenue A, Carrabelle, FL 32322','(850) 510-4196','','carrabelle-marina.com','Carrabelle','Franklin'],
|
||||||
|
['Carrabelle Boat Club','1570 Hwy 98 W, Carrabelle, FL 32322','(850) 697-5500','info@carrabelleboatclub.com','carrabelleboatclub.com','Carrabelle','Franklin'],
|
||||||
|
["Pirate's Cove Carrabelle Marina",'275 Timber Island Rd, Carrabelle, FL 32322','(850) 756-8144','','','Carrabelle','Franklin'],
|
||||||
|
['MS Dockside Marina & Boatyard','292 Graham Dr, Carrabelle, FL 32322','(850) 697-3337','msdocksidemarina@gmail.com','msdockside.com','Carrabelle','Franklin'],
|
||||||
|
// GULF
|
||||||
|
['Point South Marina - Port St. Joe','340 Marina Drive, Port St Joe, FL 32456','(850) 460-9780','info.portstjoe@pointsouthmarina.com','pointsouthmarinaportstjoe.com','Port St. Joe','Gulf'],
|
||||||
|
["Captain's Cove Marina",'1646 Captain Carl Raffield Way, Port St Joe, FL 32456','(850) 227-3357','captaincove@yahoo.com','captainscovefl.com','Port St. Joe','Gulf'],
|
||||||
|
// BAY
|
||||||
|
['Point South Marina - Bay Point','3824 Hatteras Ln, Panama City Beach, FL 32408','(850) 235-6911','BayPointMarina@StJoe.com','pointsouthmarinabaypoint.com','Panama City Beach','Bay'],
|
||||||
|
['Bayside Marina','6325 Big Daddy Drive, Panama City Beach, FL 32407','(850) 234-7650','','bluegatemarinas.com','Panama City Beach','Bay'],
|
||||||
|
['MarineMax Panama City Beach','3605 Thomas Dr, Panama City Beach, FL 32408','(850) 708-1317','','marinemax.com','Panama City Beach','Bay'],
|
||||||
|
["Capt. Anderson's Marina",'5550 N Lagoon Dr, Panama City, FL 32408','(850) 234-3435','captandersonsmktg@aol.com','captandersonsmarina.com','Panama City','Bay'],
|
||||||
|
['Treasure Island Marina','3605 Thomas Dr, Panama City Beach, FL 32408','(850) 234-6533','','treasureislandmarina.net','Panama City Beach','Bay'],
|
||||||
|
['St. Andrews Marina','3151 W 10th St, Panama City, FL 32401','(850) 872-7240','','','Panama City','Bay'],
|
||||||
|
["Pirate's Cove Marina PCB",'3901 Thomas Dr, Panama City Beach, FL 32408','(850) 234-3939','','piratescovemarinapcb.com','Panama City Beach','Bay'],
|
||||||
|
// WALTON
|
||||||
|
["Tom's Landing Marina",'78 Ricker Ave, Santa Rosa Beach, FL','(850) 496-2741','tomslanding@gmail.com','tomslandingsrb.com','Santa Rosa Beach','Walton'],
|
||||||
|
['Shipyard Marina Freeport','116 Shipyard Rd, Freeport, FL','(850) 880-2324','info@shipyardmarinafreeport.com','shipyardmarinafreeport.com','Freeport','Walton'],
|
||||||
|
// OKALOOSA
|
||||||
|
['HarborWalk Marina','66 Harbor Blvd, Destin, FL','(850) 650-2400','','harborwalkmarina.net','Destin','Okaloosa'],
|
||||||
|
['Fort Walton Beach Yacht Basin','104 Miracle Strip Pkwy SW, Fort Walton Beach, FL','(850) 244-5725','info@fwbyachtbasin.com','fwbyachtbasin.com','Fort Walton Beach','Okaloosa'],
|
||||||
|
['Adventure Marina','1201-B Miracle Strip Pkwy SE, Fort Walton Beach, FL','(850) 581-2628','','adventuremarina.com','Fort Walton Beach','Okaloosa'],
|
||||||
|
['Sunset Isle Yacht Club','1350 Miracle Strip Pkwy SE, Fort Walton Beach, FL','(850) 500-2628','','sunsetisle.com','Fort Walton Beach','Okaloosa'],
|
||||||
|
// SANTA ROSA
|
||||||
|
['Santa Rosa Yacht & Boat Club','300 Pensacola Beach Rd, Gulf Breeze, FL','(850) 934-1005','','santarosayachtclub.com','Gulf Breeze','Santa Rosa'],
|
||||||
|
['Milton Marina','Quinn Street, Milton, FL 32570','(850) 983-5466','parksandrec@miltonfl.org','miltonfl.org','Milton','Santa Rosa'],
|
||||||
|
['Marquis Bayou Marina','7001 Old Spanish Trail, Milton, FL 32583','(850) 266-7728','','marquisbayou.com','Milton','Santa Rosa'],
|
||||||
|
['East River Smokehouse Marina','8491 Navarre Pkwy, Navarre, FL 32566','(850) 939-2802','','eastriversmokehouse.com','Navarre','Santa Rosa'],
|
||||||
|
['Bluewater Bay Marina','290 Yacht Club Drive, Niceville, FL 32578','','','bluewaterbaymarina.com','Niceville','Santa Rosa'],
|
||||||
|
// ESCAMBIA
|
||||||
|
['Southwind Marina','10121 Sinton Dr, Pensacola, FL 32502','(850) 619-6348','gmsouthwind@gmail.com','southwindmarina.com','Pensacola','Escambia'],
|
||||||
|
['Palafox Pier Yacht Harbor Marina','997 S Palafox St, Pensacola, FL 32502','(850) 432-9620','','','Pensacola','Escambia'],
|
||||||
|
['Day Break Marina','811 South R Street, Pensacola, FL 32502','(850) 434-9022','','daybreakmarina.com','Pensacola','Escambia'],
|
||||||
|
['Chico Marina','3009 Barrancas Ave, Pensacola, FL 32507','(850) 453-8863','','','Pensacola','Escambia'],
|
||||||
|
["Mac's Marina",'31 Newman Avenue, Pensacola, FL 32507','(850) 453-3775','','macsmarinafl.com','Pensacola','Escambia'],
|
||||||
|
['MarineMax Pensacola','1901 Cypress St, Pensacola, FL 32502','(850) 477-1112','','marinemax.com','Pensacola','Escambia'],
|
||||||
|
['Island Cove Marina','806 Lakewood Rd, Pensacola, FL 32507','(850) 455-4552','office@segersmarine.com','segersmarine.com','Pensacola','Escambia'],
|
||||||
|
['Rod & Reel Marina','10045 Sinton Dr, Pensacola, FL 32502','(850) 492-0100','','','Pensacola','Escambia'],
|
||||||
|
['Pensacola Beach Marina','655 Pensacola Beach Blvd, Pensacola Beach, FL 32561','(850) 932-8466','','pensacolabeachmarina.com','Pensacola Beach','Escambia'],
|
||||||
|
['Sabine Marina','715 Pensacola Beach Blvd, Pensacola Beach, FL 32561','(850) 932-1904','','sabinemarina.com','Pensacola Beach','Escambia'],
|
||||||
|
['Pier One Marina','655 Pensacola Beach Blvd, Pensacola Beach, FL 32561','(850) 781-4180','','pieronemarina.com','Pensacola Beach','Escambia'],
|
||||||
|
['Holiday Harbor Marina','14050 Canal-A-Way, Pensacola, FL 32507','(850) 492-0555','office@myholidayharbor.com','myholidayharbor.com','Pensacola','Escambia'],
|
||||||
|
// DUVAL
|
||||||
|
['Port 32 Jacksonville','4234 Lakeside Dr, Jacksonville, FL 32210','(904) 387-5538','','port32marinas.com','Jacksonville','Duval'],
|
||||||
|
['Ortega River Marina','4585 Lakeside Dr, Jacksonville, FL 32210','(904) 389-1199','ortegarivermarina@gmail.com','ortegarivermarinajax.com','Jacksonville','Duval'],
|
||||||
|
['Sadler Point Marina','4599 Lakeside Dr, Jacksonville, FL 32210','(904) 384-1383','brooks@sadlerpoint.com','sadlerpointmarina.com','Jacksonville','Duval'],
|
||||||
|
['Lakeshore Marine Center','3326 Lakeshore Blvd, Jacksonville, FL 32210','(904) 384-6447','info@lakeshoremarinecenter.com','lakeshoremarinecenter.com','Jacksonville','Duval'],
|
||||||
|
['Arlington Marina','5137 Arlington Rd, Jacksonville, FL 32211','(904) 743-2628','info@arlingtonmarina.com','arlingtonmarina.com','Jacksonville','Duval'],
|
||||||
|
['Seafarers Marina','455 Trout River Dr, Jacksonville, FL 32208','(904) 765-8152','seafarersmarina@aol.com','seafarersmarina.com','Jacksonville','Duval'],
|
||||||
|
['HarborTown Marina Jacksonville','13846 Atlantic Blvd, Jacksonville, FL 32225','(904) 220-3600','','jaxharbortownmarina.com','Jacksonville','Duval'],
|
||||||
|
['Julington Creek Marina','12807 San Jose Blvd, Jacksonville, FL 32223','(904) 268-5117','julingtoncreekmarina@outlook.com','marinaatjulingtoncreek.com','Jacksonville','Duval'],
|
||||||
|
['Palm Cove Marina','14603 Beach Blvd Ste 100, Jacksonville, FL 32250','(904) 223-4757','','palmcovemarina.com','Jacksonville','Duval'],
|
||||||
|
['Fort George Island Marina','9954 Heckscher Dr, Jacksonville, FL 32226','(904) 251-0050','','fortgeorgeislandmarina.com','Jacksonville','Duval'],
|
||||||
|
["Lamb's Yacht Center",'3376 Lake Shore Blvd, Jacksonville, FL 32210','(904) 327-2285','','lambsyachtcenter.com','Jacksonville','Duval'],
|
||||||
|
['Beach Marine','2315 Beach Blvd, Jacksonville Beach, FL 32250','(904) 249-8200','','jaxbeachmarine.com','Jacksonville Beach','Duval'],
|
||||||
|
['Morningstar Marinas Mayport','4852 Ocean St, Atlantic Beach, FL 32233','(904) 236-6210','','morningstarmarinas.com','Atlantic Beach','Duval'],
|
||||||
|
// ST. JOHNS
|
||||||
|
['St. Augustine Municipal Marina','111 Avenida Menendez, St. Augustine, FL 32084','(904) 825-1026','marina@citystaug.com','citystaug.com','St. Augustine','St. Johns'],
|
||||||
|
['Camachee Cove Yacht Harbor','3070 Harbor Dr, St. Augustine, FL 32084','(904) 829-5676','CCYHoffice@MyWindward.com','camacheeisland.com','St. Augustine','St. Johns'],
|
||||||
|
['The Conch House Marina Resort','57 Comares Ave, St. Augustine, FL 32080','(904) 824-4347','info@conchhousemarinaresort.com','conchhousemarinaresort.com','St. Augustine','St. Johns'],
|
||||||
|
['English Landing Marina','509 S Ponce de Leon Blvd, St. Augustine, FL 32084','(904) 669-7363','info@englishlandingmarina.com','englishlandingmarina.com','St. Augustine','St. Johns'],
|
||||||
|
["Cat's Paw Marina",'220 Nix Boat Yard Rd, St. Augustine, FL 32084','(904) 829-8040','Info@CatsPawMarina.com','catspawmarina.com','St. Augustine','St. Johns'],
|
||||||
|
['Oasis Boatyard & Marina','256 Riberia St, St. Augustine, FL 32084','(904) 824-2520','','oasisboatyardandmarina.com','St. Augustine','St. Johns'],
|
||||||
|
// NASSAU
|
||||||
|
['Fernandina Harbor Marina','3 S Front St, Fernandina Beach, FL 32034','(904) 310-3300','fernandinaharbor@alliancemarine.co','fernandinaharbormarina.com','Fernandina Beach','Nassau'],
|
||||||
|
['Amelia Island Marina','251 Creekside Dr, Fernandina Beach, FL 32034','(904) 277-4615','ameliaisland@suntex.com','ameliaislandmarina.com','Fernandina Beach','Nassau'],
|
||||||
|
['Olde Towne Marina','1420 N 14th St, Fernandina Beach, FL 32034','(904) 277-8511','oldetownemarina@bellsouth.net','oldetownemarina.com','Fernandina Beach','Nassau'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// ASTILLEROS — FLORIDA NORTE
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
const ASTILLEROS = [
|
||||||
|
['Daytona Marina & Boat Works','645 S Beach St, Daytona Beach, FL 32114','(386) 252-6421','dawn645@thedaytonamarina.com','thedaytonamarina.com','Daytona Beach','Volusia'],
|
||||||
|
['Sea Love Boat Works','4877 Front St, Ponce Inlet, FL 32127','(386) 761-5434','sealoveboatworks@gmail.com','sealoveboatworks.com','Ponce Inlet','Volusia'],
|
||||||
|
['Lighthouse Boatyard','4958 S Peninsula Dr, Ponce Inlet, FL 32127','(386) 767-0683','','','Ponce Inlet','Volusia'],
|
||||||
|
['Seven Seas Marina & Boatyard','3300 S Peninsula Dr, Port Orange, FL 32127','(386) 761-3221','sevenseasmarina@bellsouth.net','sevenseasmarina.com','Port Orange','Volusia'],
|
||||||
|
['Westland Boatyard and Marina','419 N Washington Ave, Titusville, FL 32796','(321) 267-1667','','westlandmarina.com','Titusville','Brevard'],
|
||||||
|
['Port Canaveral Marine','960 Mullet Rd, Cape Canaveral, FL 32920','(321) 799-9444','','portcanaveralmarine.com','Cape Canaveral','Brevard'],
|
||||||
|
['Anchorage Yacht Basin','96 E Eau Gallie Cswy, Melbourne, FL 32937','(321) 773-3620','','anchorageyachtbasin.com','Melbourne','Brevard'],
|
||||||
|
['Sebastian River Marina & Boatyard','8525 US Hwy 1, Sebastian, FL 32976','(772) 664-3029','d.hillman@sebastian-river-marina.com','','Sebastian','Indian River'],
|
||||||
|
['Crystal River Marine Inc.','990 N Suncoast Blvd, Crystal River, FL 34429','(352) 795-2597','','crystalrivermarine.com','Crystal River','Citrus'],
|
||||||
|
['Three Rivers Marine','1038 N Suncoast Blvd, Crystal River, FL 34429','(352) 563-5510','sales@threeriversmarineinc.com','threeriversmarineinc.com','Crystal River','Citrus'],
|
||||||
|
['MS Dockside Marina & Boatyard','292 Graham Dr, Carrabelle, FL 32322','(850) 697-3337','msdocksidemarina@gmail.com','msdockside.com','Carrabelle','Franklin'],
|
||||||
|
['Eastern Shipbuilding Group','432 Howard Road, Port St. Joe, FL 32456','(850) 763-1900','','easternshipbuilding.com','Port St. Joe','Gulf'],
|
||||||
|
['Bay County Boatyard','101 N Church Ave, Panama City, FL 32401','(850) 215-9283','','baycountyboatyard.com','Panama City','Bay'],
|
||||||
|
['Panhandle Marina & Boatyard','101 N Church Ave, Panama City, FL 32401','(850) 481-0556','info@panboatyard.com','panboatyard.com','Panama City','Bay'],
|
||||||
|
['Marker 21 Boatyard & Marina','36 Miracle Strip Pkwy SW, Fort Walton Beach, FL','(850) 244-2722','markertwentyonemarina@gmail.com','marker21boatyardandmarina.com','Fort Walton Beach','Okaloosa'],
|
||||||
|
['Coastal Marine Works','2816 Gulf Breeze Pkwy, Gulf Breeze, FL','(850) 637-9585','','coastalmarineworks.com','Gulf Breeze','Santa Rosa'],
|
||||||
|
['Pensacola Marine Center','700 S Myrick St, Pensacola, FL 32501','(850) 439-1451','bob@pensacolamarinecenter.com','pensacolamarinecenter.com','Pensacola','Escambia'],
|
||||||
|
["Pelican's Perch Marina and Boatyard",'40 Audusson Ave, Pensacola, FL 32507','(850) 453-3471','','pelicansperchmarina.com','Pensacola','Escambia'],
|
||||||
|
['Huckins Yacht Corporation','3482 Lake Shore Blvd, Jacksonville, FL 32210','(904) 389-1125','service@huckinsyacht.com','huckinsyacht.com','Jacksonville','Duval'],
|
||||||
|
['Sadler Point Marine Center','4599 Lakeside Dr, Jacksonville, FL 32210','(904) 384-1383','brooks@sadlerpoint.com','sadlerpointmarina.com','Jacksonville','Duval'],
|
||||||
|
['Atlantic Coast Marine','13748 Atlantic Blvd, Jacksonville, FL 32225','(904) 221-0793','','atlanticcoastmarine.com','Jacksonville','Duval'],
|
||||||
|
['St. Augustine Marine Center','404 S Riberia St, St. Augustine, FL 32084','(904) 824-4394','','staugustinemarine.com','St. Augustine','St. Johns'],
|
||||||
|
['St. Augustine Shipyard','117 Dockside Dr, St. Augustine, FL 32084','','','camacheeyachtyard.com','St. Augustine','St. Johns'],
|
||||||
|
['Tiger Point Marina & Boat Works','997 Egans Creek Ln, Fernandina Beach, FL 32034','(904) 277-2720','tigerpointmarina@comcast.net','tigerpointmarina.biz','Fernandina Beach','Nassau'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// ALMACENAMIENTO — FLORIDA NORTE
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
const ALMACENAMIENTO = [
|
||||||
|
['All Aboard Storage - Ormond Beach','305 W Granada Blvd, Ormond Beach, FL','(386) 333-6801','','allaboardstorage.com','Ormond Beach','Volusia'],
|
||||||
|
['Stor-It Boat & RV - Ormond Beach','99 Portland St, Ormond Beach, FL','(386) 676-5018','','storitboatandrv.com','Ormond Beach','Volusia'],
|
||||||
|
['Barracuda Boat & RV Storage','412 W Park Ave, Edgewater, FL','(386) 847-8112','info@boatrvstorage.com','boatrvstorage.com','Edgewater','Volusia'],
|
||||||
|
['RecNation RV & Boat Storage - Edgewater','403 Timaquan Trail, Edgewater, FL','(386) 433-8698','','recnationstorage.com','Edgewater','Volusia'],
|
||||||
|
['All Aboard Storage - Daytona Beach','1325 S Nova Road, Daytona Beach, FL','(386) 200-9442','','allaboardstorage.com','Daytona Beach','Volusia'],
|
||||||
|
['All Aboard Storage - Palm Coast','6372 SR 100 East, Palm Coast, FL','(386) 388-7739','','allaboardstorage.com','Palm Coast','Flagler'],
|
||||||
|
['Stor-It of Flagler County','3700 E Moody Blvd, Bunnell, FL','(386) 263-3067','','storitofflaglercounty.com','Bunnell','Flagler'],
|
||||||
|
['Sebastian Inlet Marina Dry Storage','8685 US-1, Micco, FL 32976','(772) 664-8500','SIMoffice@mywindward.com','sebastianinletmarina.com','Micco','Indian River'],
|
||||||
|
['Mr. Stor-It Merritt Island','500 Cone Rd, Merritt Island, FL 32952','(321) 453-3400','','mrstorit.com','Merritt Island','Brevard'],
|
||||||
|
['Crystal River Boat and RV Storage','S Suncoast Blvd, Crystal River, FL','','','crystalriverboatrvstorage.com','Crystal River','Citrus'],
|
||||||
|
['Wakulla Station RV & Boat Storage','Crawfordville, FL','','','wakullastationrvandboatstorage.com','Crawfordville','Wakulla'],
|
||||||
|
['Storage by the Sea','Navarre, FL','(850) 374-1648','','','Navarre','Santa Rosa'],
|
||||||
|
['East Bay Boat & RV Storage','Navarre, FL','(850) 939-9040','','','Navarre','Santa Rosa'],
|
||||||
|
['Old River Boat Storage','4837 Ocean St, Atlantic Beach, FL 32233','(904) 874-4132','oldriverboatstorage@gmail.com','oldriverboatstorage.com','Atlantic Beach','Duval'],
|
||||||
|
["Solomon's RV & Boat Storage",'14255 Beach Blvd, Jacksonville, FL 32250','(904) 223-0888','','solomons.net','Jacksonville','Duval'],
|
||||||
|
["Captain's Cove Dry Stack Storage",'1646 Captain Carl Raffield Way, Port St Joe, FL','(850) 227-3357','info@captainscovefl.com','captainscovefl.com','Port St. Joe','Gulf'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// SUMINISTROS — FLORIDA NORTE
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
const SUMINISTROS = [
|
||||||
|
['West Marine - Daytona Beach','1300 W International Speedway Blvd, Daytona Beach, FL','(386) 255-2013','','westmarine.com','Daytona Beach','Volusia'],
|
||||||
|
['Aloha Marine Center','1700 N Nova Rd, Holly Hill, FL','(386) 255-2345','','alohamarine.com','Holly Hill','Volusia'],
|
||||||
|
['Bluewater Marine Daytona','730 Ridgewood Ave, Holly Hill, FL','(386) 255-7790','','bluewatermarine.com','Holly Hill','Volusia'],
|
||||||
|
['Atlantic Marine Port Orange','520 Dunlawton Ave, Port Orange, FL','(386) 788-1644','robert@atlanticmarinefl.com','atlanticmarinefl.com','Port Orange','Volusia'],
|
||||||
|
['North Causeway Marine','4 N Causeway, New Smyrna Beach, FL','(386) 427-5267','','northcausewaymarine.com','New Smyrna Beach','Volusia'],
|
||||||
|
['West Marine Palm Coast','250 Palm Coast Pkwy NE, Palm Coast, FL','(386) 276-7001','','westmarine.com','Palm Coast','Flagler'],
|
||||||
|
['Carp Coastal Marine & RV','4550 S US Hwy 1, Grant, FL','(321) 952-1303','carpcoastalmarine@carpindustries.com','carpcoastalmarine.com','Grant','Indian River'],
|
||||||
|
['Vero Marine Center','12 Royal Palm Pointe, Vero Beach, FL','(772) 562-7922','','veromarine.com','Vero Beach','Indian River'],
|
||||||
|
['West Marine West Melbourne','1001 W New Haven Ave, West Melbourne, FL','(321) 837-1113','','westmarine.com','West Melbourne','Brevard'],
|
||||||
|
['MarineMax Cocoa','1410 King St, Cocoa, FL 32922','(321) 636-3142','','marinemax.com','Cocoa','Brevard'],
|
||||||
|
['Boaters Exchange','2145 US-1, Rockledge, FL','(321) 638-0090','','boatersexchange.com','Rockledge','Brevard'],
|
||||||
|
['West Marine Crystal River','160 SE Hwy 19, Crystal River, FL','(352) 563-0003','','westmarine.com','Crystal River','Citrus'],
|
||||||
|
["Nobles' Marine Crystal River",'1931 NW Highway 19, Crystal River, FL','(352) 329-3739','','noblesmarine.com','Crystal River','Citrus'],
|
||||||
|
['Homosassa Marine','3120 S Suncoast Blvd, Homosassa, FL 34448','(352) 628-2991','','homosassamarine.com','Homosassa','Citrus'],
|
||||||
|
['Marina Hardware at Cedar Key','409 1st St, Cedar Key, FL','(352) 543-5804','','marinahardwarecedarkey.com','Cedar Key','Levy'],
|
||||||
|
['RMS Marine Supply','3026 Coastal Hwy, Crawfordville, FL','(850) 926-3114','','rmsmarinesupply.com','Crawfordville','Wakulla'],
|
||||||
|
['West Marine Panama City','427 E 23rd St, Panama City, FL','(850) 763-6104','','westmarine.com','Panama City','Bay'],
|
||||||
|
['West Marine Panama City Beach','2225 Thomas Dr, Panama City Beach, FL','(850) 234-7699','','westmarine.com','Panama City Beach','Bay'],
|
||||||
|
['Miller Marine Inc','7141 Grassy Point Rd, Panama City, FL','(850) 265-6768','','millermarineinc.net','Panama City','Bay'],
|
||||||
|
["Howell Marine & Tackle Supply",'3100 W Highway 98, Panama City, FL','(850) 785-8548','','howelltacklesupply.com','Panama City','Bay'],
|
||||||
|
['West Marine Destin','862-B Hwy 98 East, Destin, FL','','','westmarine.com','Destin','Okaloosa'],
|
||||||
|
['The Ships Chandler - Destin','646 Harbor Blvd, Destin, FL','(850) 837-2262','','theshipschandler.com','Destin','Okaloosa'],
|
||||||
|
['The Ships Chandler - Freeport','17309 Highway 331, Freeport, FL','(850) 880-6259','','theshipschandler.com','Freeport','Walton'],
|
||||||
|
['Emerald Coast Marine Group','115 W John Sims Pkwy, Niceville, FL','(850) 389-8318','','emeraldcoastmarine.com','Niceville','Okaloosa'],
|
||||||
|
['Gulf Breeze Marine','2933 Gulf Breeze Pkwy, Gulf Breeze, FL','(850) 932-1556','gbmarineshop@outlook.com','gulfbreezemarine.com','Gulf Breeze','Santa Rosa'],
|
||||||
|
['West Marine Pensacola','5303 N Davis Hwy, Pensacola, FL','(850) 483-5523','','westmarine.com','Pensacola','Escambia'],
|
||||||
|
['BGS Marine Sales & Service','3910 W Navy Blvd, Pensacola, FL','(850) 438-5934','sales@bgsmarinesales.com','bgsmarinesales.com','Pensacola','Escambia'],
|
||||||
|
['Perdido Marine Supply','12490 Gulf Beach Hwy, Pensacola, FL','(850) 361-1191','','perdidotradingcompany.com','Pensacola','Escambia'],
|
||||||
|
['Gulf Coast Marine Supply','7885 Pensacola Blvd, Pensacola, FL','(850) 476-4910','','gulfcoastmarine.com','Pensacola','Escambia'],
|
||||||
|
['West Marine Jacksonville','4874 Big Island Dr, Jacksonville, FL','(904) 520-4650','','westmarine.com','Jacksonville','Duval'],
|
||||||
|
['West Marine Jacksonville Beach','14180 Beach Blvd, Jacksonville, FL','(904) 821-5033','','westmarine.com','Jacksonville Beach','Duval'],
|
||||||
|
['Specialty Marine & Industrial Supplies','1420 Mayport Rd, Atlantic Beach, FL','(904) 247-3303','','specialtymarinefl.com','Atlantic Beach','Duval'],
|
||||||
|
['Main Street Marine','9211 N Main St, Jacksonville, FL','(904) 757-3100','','mainstmarine.com','Jacksonville','Duval'],
|
||||||
|
['MarineMax Jacksonville','2079 Beach Blvd, Jacksonville Beach, FL','(904) 338-9970','','marinemax.com','Jacksonville Beach','Duval'],
|
||||||
|
['Ocean Outboard Marine','1619 N 14th St, Fernandina Beach, FL','(904) 321-1422','','oceanoutboardmarine.com','Fernandina Beach','Nassau'],
|
||||||
|
['Chelsea Boat Center','96114 David Hallman Pkwy, Yulee, FL','(904) 261-8884','','chelseamarinesupply.com','Yulee','Nassau'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildSheet(data, sheetTitle) {
|
||||||
|
const rows = [[sheetTitle]];
|
||||||
|
rows.push(HEADERS);
|
||||||
|
data.forEach((row, i) => {
|
||||||
|
rows.push([i + 1, ...row]);
|
||||||
|
});
|
||||||
|
return xlsx.utils.aoa_to_sheet(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
console.log('Leyendo Excel...');
|
||||||
|
const wb = xlsx.readFile(EXCEL_PATH);
|
||||||
|
|
||||||
|
// Crear hojas nuevas para Florida Norte
|
||||||
|
const sheets = [
|
||||||
|
{ name: 'MARINAS_NORTE', data: MARINAS, title: 'Marinas — Florida Norte (Sesión 6)' },
|
||||||
|
{ name: 'ASTILLEROS_NORTE', data: ASTILLEROS, title: 'Astilleros — Florida Norte (Sesión 6)' },
|
||||||
|
{ name: 'ALMACENAMIENTO_NORTE', data: ALMACENAMIENTO, title: 'Almacenamiento — Florida Norte (Sesión 6)' },
|
||||||
|
{ name: 'SUMINISTROS_NORTE', data: SUMINISTROS, title: 'Suministros — Florida Norte (Sesión 6)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const s of sheets) {
|
||||||
|
if (wb.SheetNames.includes(s.name)) {
|
||||||
|
wb.SheetNames.splice(wb.SheetNames.indexOf(s.name), 1);
|
||||||
|
}
|
||||||
|
wb.Sheets[s.name] = buildSheet(s.data, s.title);
|
||||||
|
wb.SheetNames.push(s.name);
|
||||||
|
console.log(` ✅ ${s.name}: ${s.data.length} registros`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar RESUMEN
|
||||||
|
const resWs = wb.Sheets['RESUMEN'];
|
||||||
|
const resData = xlsx.utils.sheet_to_json(resWs, { header: 1 });
|
||||||
|
// Agregar fila de totales norte FL
|
||||||
|
resData.push(['Florida Norte', MARINAS.length, '-', '-', '-', '-', MARINAS.length, 'MARINAS_NORTE']);
|
||||||
|
resData.push(['FL Norte Astilleros', '-', '-', '-', '-', '-', ASTILLEROS.length, 'ASTILLEROS_NORTE']);
|
||||||
|
resData.push(['FL Norte Almacenamiento', '-', '-', '-', '-', '-', ALMACENAMIENTO.length, 'ALMACENAMIENTO_NORTE']);
|
||||||
|
resData.push(['FL Norte Suministros', '-', '-', '-', '-', '-', SUMINISTROS.length, 'SUMINISTROS_NORTE']);
|
||||||
|
wb.Sheets['RESUMEN'] = xlsx.utils.aoa_to_sheet(resData);
|
||||||
|
|
||||||
|
xlsx.writeFile(wb, EXCEL_PATH);
|
||||||
|
console.log(`\n🎉 Guardado exitosamente en: ${EXCEL_PATH}`);
|
||||||
|
console.log(`\n📊 RESUMEN TOTAL:`);
|
||||||
|
console.log(` Marinas Norte FL: ${MARINAS.length}`);
|
||||||
|
console.log(` Astilleros Norte FL: ${ASTILLEROS.length}`);
|
||||||
|
console.log(` Almacenamiento Norte FL: ${ALMACENAMIENTO.length}`);
|
||||||
|
console.log(` Suministros Norte FL: ${SUMINISTROS.length}`);
|
||||||
|
console.log(` TOTAL NORTE FL: ${MARINAS.length + ASTILLEROS.length + ALMACENAMIENTO.length + SUMINISTROS.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const puppeteer = require('puppeteer');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await puppeteer.launch({ headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
const htmlPath = path.resolve(__dirname, 'Prisa_Yachts_Brochure.html');
|
||||||
|
await page.goto(`file:///${htmlPath.replace(/\\/g, '/')}`, { waitUntil: 'networkidle0' });
|
||||||
|
|
||||||
|
await page.pdf({
|
||||||
|
path: path.resolve(__dirname, 'Prisa_Yachts_Brochure.pdf'),
|
||||||
|
format: 'Letter',
|
||||||
|
margin: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||||
|
printBackground: true,
|
||||||
|
preferCSSPageSize: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log('PDF generated: Prisa_Yachts_Brochure.pdf');
|
||||||
|
})();
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
// create_whatsapp_workflow.js — Prisa Yachts WhatsApp Bot for n8n
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const N8N_BASE = process.env.N8N_BASE_URL || 'https://n8n.crewinghunters.com';
|
||||||
|
const API_KEY = process.env.N8N_API_KEY;
|
||||||
|
if (!API_KEY) { console.error('ERROR: N8N_API_KEY environment variable is required'); process.exit(1); }
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────── helpers */
|
||||||
|
function apiCall(method, path, body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const data = body ? JSON.stringify(body) : null;
|
||||||
|
const opts = {
|
||||||
|
hostname: 'n8n.crewinghunters.com',
|
||||||
|
path: '/api/v1' + path,
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'X-N8N-API-KEY': API_KEY,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
...(data ? { 'Content-Length': Buffer.byteLength(data) } : {})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const req = https.request(opts, res => {
|
||||||
|
let buf = '';
|
||||||
|
res.on('data', c => buf += c);
|
||||||
|
res.on('end', () => {
|
||||||
|
try { resolve({ status: res.statusCode, data: JSON.parse(buf) }); }
|
||||||
|
catch { resolve({ status: res.statusCode, data: buf }); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
if (data) req.write(data);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────── state-machine code (runs inside n8n) */
|
||||||
|
const STATE_MACHINE_CODE = `
|
||||||
|
const WELCOME = \`👋 Welcome to Prisa Yachts LLC!
|
||||||
|
Bienvenido a Prisa Yachts LLC
|
||||||
|
|
||||||
|
Safe Command ♦ Luxury Maintenance and Care
|
||||||
|
|
||||||
|
Select your service / Selecciona tu servicio:
|
||||||
|
|
||||||
|
1️⃣ Engines & Mechanical
|
||||||
|
2️⃣ Electrical & Electronics / NMEA
|
||||||
|
3️⃣ Teak Deck Recovery
|
||||||
|
4️⃣ Captaining & Crewing
|
||||||
|
5️⃣ Yacht Care & Detailing
|
||||||
|
6️⃣ Crew Placement & Staffing
|
||||||
|
7️⃣ Other / Otro
|
||||||
|
|
||||||
|
Reply 1–7 / Responde 1–7\`;
|
||||||
|
|
||||||
|
const CAT_MSGS = {
|
||||||
|
1:\`🔧 Engines & Mechanical
|
||||||
|
|
||||||
|
1. Vessel name & type?
|
||||||
|
2. Engine brand & approx. year?
|
||||||
|
3. Briefly describe the issue:
|
||||||
|
(noise, leak, won't start, overheating,
|
||||||
|
routine service, fuel system, etc.)
|
||||||
|
4. Current location / Marina?
|
||||||
|
5. When do you need service?
|
||||||
|
· Flexible · This week · ASAP
|
||||||
|
|
||||||
|
─────────────────
|
||||||
|
Your contact info:
|
||||||
|
- Full name:
|
||||||
|
- Phone:
|
||||||
|
- Email:
|
||||||
|
─────────────────
|
||||||
|
Thank you! Our specialist will
|
||||||
|
contact you within 2 hours. 🛥️\`,
|
||||||
|
|
||||||
|
2:\`⚡ Electrical & Electronics / NMEA
|
||||||
|
|
||||||
|
1. Vessel name & type?
|
||||||
|
2. Briefly describe the issue or installation:
|
||||||
|
(wiring, shore power, panel, NMEA 2000,
|
||||||
|
AIS, chartplotter, VHF, autopilot, etc.)
|
||||||
|
3. Is it a new installation or a repair?
|
||||||
|
· New install · Repair · Diagnosis
|
||||||
|
4. Current location / Marina?
|
||||||
|
5. When do you need service?
|
||||||
|
· Flexible · This week · ASAP
|
||||||
|
|
||||||
|
─────────────────
|
||||||
|
Your contact info:
|
||||||
|
- Full name:
|
||||||
|
- Phone:
|
||||||
|
- Email:
|
||||||
|
─────────────────
|
||||||
|
Thank you! Our specialist will
|
||||||
|
contact you within 2 hours. 🛥️\`,
|
||||||
|
|
||||||
|
3:\`🪵 Teak Deck Recovery
|
||||||
|
|
||||||
|
1. Vessel name & LOA (feet)?
|
||||||
|
2. Teak condition:
|
||||||
|
· Cleaning needed · Caulking damaged
|
||||||
|
· Planks cracked · Full replacement
|
||||||
|
3. Which deck areas?
|
||||||
|
(cockpit, flybridge, side decks, all)
|
||||||
|
4. Briefly describe the current condition:
|
||||||
|
5. Current location / Marina?
|
||||||
|
6. When do you need service?
|
||||||
|
· Flexible · This week · ASAP
|
||||||
|
|
||||||
|
─────────────────
|
||||||
|
Your contact info:
|
||||||
|
- Full name:
|
||||||
|
- Phone:
|
||||||
|
- Email:
|
||||||
|
─────────────────
|
||||||
|
Thank you! Our specialist will
|
||||||
|
contact you within 2 hours. 🛥️\`,
|
||||||
|
|
||||||
|
4:\`⚓ Captaining & Crewing
|
||||||
|
|
||||||
|
1. Vessel name, type & LOA (feet)?
|
||||||
|
2. Service needed:
|
||||||
|
· Day trip · Charter · Vessel delivery
|
||||||
|
· Offshore passage · Event crew
|
||||||
|
3. Departure & destination?
|
||||||
|
4. Date & duration?
|
||||||
|
5. Number of crew needed?
|
||||||
|
6. Any special requirements?
|
||||||
|
|
||||||
|
─────────────────
|
||||||
|
Your contact info:
|
||||||
|
- Full name:
|
||||||
|
- Phone:
|
||||||
|
- Email:
|
||||||
|
─────────────────
|
||||||
|
Thank you! Our captain will
|
||||||
|
contact you within 2 hours. 🛥️\`,
|
||||||
|
|
||||||
|
5:\`🛥️ Yacht Care & Detailing
|
||||||
|
|
||||||
|
1. Vessel name, type & LOA (feet)?
|
||||||
|
2. Service needed:
|
||||||
|
· Full wash & wax · Hull polish
|
||||||
|
· Interior cleaning · Brightwork
|
||||||
|
· Antifouling prep · Full detail
|
||||||
|
3. Briefly describe current condition:
|
||||||
|
4. Current location / Marina?
|
||||||
|
5. When do you need service?
|
||||||
|
· Flexible · This week · ASAP
|
||||||
|
|
||||||
|
─────────────────
|
||||||
|
Your contact info:
|
||||||
|
- Full name:
|
||||||
|
- Phone:
|
||||||
|
- Email:
|
||||||
|
─────────────────
|
||||||
|
Thank you! Our team will
|
||||||
|
contact you within 2 hours. 🛥️\`,
|
||||||
|
|
||||||
|
6:\`👥 Crew Placement & Staffing
|
||||||
|
|
||||||
|
1. Vessel name, type & LOA (feet)?
|
||||||
|
2. Position(s) needed:
|
||||||
|
· Captain · Chief Mate / Deck Officer
|
||||||
|
· Deckhand · Chief Engineer
|
||||||
|
· Stewardess / Interior crew
|
||||||
|
· Chef · Other
|
||||||
|
3. Contract type:
|
||||||
|
· Permanent · Seasonal · One-way delivery
|
||||||
|
· Day work · Relief / Temporary
|
||||||
|
4. Start date & duration?
|
||||||
|
5. Vessel flag & trading area?
|
||||||
|
6. Any certifications required?
|
||||||
|
(STCW, license type, experience level)
|
||||||
|
|
||||||
|
─────────────────
|
||||||
|
Your contact info:
|
||||||
|
- Full name:
|
||||||
|
- Phone:
|
||||||
|
- Email:
|
||||||
|
- Company (if applicable):
|
||||||
|
─────────────────
|
||||||
|
Thank you! Our crewing team will
|
||||||
|
contact you within 2 hours. 🛥️\`,
|
||||||
|
|
||||||
|
7:\`📋 Other / Otro
|
||||||
|
|
||||||
|
1. Vessel name & type?
|
||||||
|
2. Briefly describe what you need:
|
||||||
|
3. Current location / Marina?
|
||||||
|
4. When do you need service?
|
||||||
|
· Flexible · This week · ASAP
|
||||||
|
|
||||||
|
─────────────────
|
||||||
|
Your contact info:
|
||||||
|
- Full name:
|
||||||
|
- Phone:
|
||||||
|
- Email:
|
||||||
|
─────────────────
|
||||||
|
Thank you! Our team will
|
||||||
|
contact you within 2 hours. 🛥️\`
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONFIRMATION = \`✅ Request received! / ¡Solicitud recibida!
|
||||||
|
|
||||||
|
We have all your information and a
|
||||||
|
Prisa Yachts specialist will contact
|
||||||
|
you within 2 hours.
|
||||||
|
|
||||||
|
📞 (786) 396-3315
|
||||||
|
📧 info@prisayachts.com
|
||||||
|
🌐 prisayachts.com
|
||||||
|
📸 @prisayachts
|
||||||
|
|
||||||
|
Safe Command ♦ Luxury Maintenance and Care\`;
|
||||||
|
|
||||||
|
const CAT_NAMES = {
|
||||||
|
1:'Engines & Mechanical', 2:'Electrical & Electronics / NMEA',
|
||||||
|
3:'Teak Deck Recovery', 4:'Captaining & Crewing',
|
||||||
|
5:'Yacht Care & Detailing', 6:'Crew Placement & Staffing', 7:'Other / Otro'
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROUTING = {1:'alvaro',2:'alvaro',3:'federico',4:'federico',5:'federico',6:'federico',7:'both'};
|
||||||
|
const ALVARO = '19546554084';
|
||||||
|
const FEDERICO = '17542093375';
|
||||||
|
const RESET_WORDS = ['menu','start','hola','hello','hi'];
|
||||||
|
const TIMEOUT_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// ── parse webhook payload ──────────────────────────────────────────
|
||||||
|
const raw = $input.first().json;
|
||||||
|
|
||||||
|
// Meta GET verification
|
||||||
|
const q = raw.query || {};
|
||||||
|
if (q['hub.challenge']) {
|
||||||
|
return [{ json: { action: 'webhook_verify', challenge: String(q['hub.challenge']) } }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = raw.body || raw;
|
||||||
|
const msg = body?.entry?.[0]?.changes?.[0]?.value?.messages?.[0];
|
||||||
|
if (!msg || msg.type !== 'text') {
|
||||||
|
return [{ json: { action: 'ignore' } }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = msg.from;
|
||||||
|
const msgText = msg.text.body.trim();
|
||||||
|
const msgLow = msgText.toLowerCase();
|
||||||
|
|
||||||
|
// ── state machine ─────────────────────────────────────────────────
|
||||||
|
const sd = $getWorkflowStaticData('global');
|
||||||
|
if (!sd.states) sd.states = {};
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
let state = sd.states[from] || { step: 'new', lastActivity: 0, data: {} };
|
||||||
|
if (now - state.lastActivity > TIMEOUT_MS) state = { step: 'new', lastActivity: now, data: {} };
|
||||||
|
state.lastActivity = now;
|
||||||
|
|
||||||
|
const reply = (messageText, extra={}) => {
|
||||||
|
sd.states[from] = state;
|
||||||
|
return [{ json: { action: 'send_to_client', to: from, messageText, notifySpecialist: false, ...extra } }];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (RESET_WORDS.includes(msgLow) || state.step === 'new') {
|
||||||
|
state.step = 'waiting_category'; state.data = {};
|
||||||
|
return reply(WELCOME);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.step === 'waiting_category') {
|
||||||
|
const cat = parseInt(msgText, 10);
|
||||||
|
if (cat >= 1 && cat <= 7) {
|
||||||
|
state.step = 'collecting_info'; state.data.category = cat;
|
||||||
|
return reply(CAT_MSGS[cat]);
|
||||||
|
}
|
||||||
|
return reply(WELCOME);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.step === 'collecting_info') {
|
||||||
|
const cat = state.data.category;
|
||||||
|
const ts = new Date().toLocaleString('en-US', { timeZone: 'America/New_York' });
|
||||||
|
const specMsg = \`🔔 NEW SERVICE REQUEST — Prisa Yachts
|
||||||
|
|
||||||
|
📋 Category: \${CAT_NAMES[cat]}
|
||||||
|
👤 Client WhatsApp: +\${from}
|
||||||
|
|
||||||
|
📝 Info received:
|
||||||
|
\${msgText}
|
||||||
|
|
||||||
|
🕐 Received: \${ts} ET\`;
|
||||||
|
|
||||||
|
state.step = 'complete';
|
||||||
|
return reply(CONFIRMATION, {
|
||||||
|
notifySpecialist: true,
|
||||||
|
routing: ROUTING[cat],
|
||||||
|
specialistMsg: specMsg,
|
||||||
|
alvaroPhone: ALVARO,
|
||||||
|
federicoPhone: FEDERICO
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// complete → restart
|
||||||
|
state.step = 'waiting_category'; state.data = {};
|
||||||
|
return reply(WELCOME);
|
||||||
|
`;
|
||||||
|
|
||||||
|
/* ────────────────────────────────────────── build workflow JSON */
|
||||||
|
function buildWorkflow(credId) {
|
||||||
|
const credential = { httpHeaderAuth: { id: String(credId), name: 'WhatsApp_Prisa' } };
|
||||||
|
const waUrl = 'https://graph.facebook.com/v18.0/PLACEHOLDER_PHONE_NUMBER_ID/messages';
|
||||||
|
|
||||||
|
const ifStrNode = (id, name, pos, left, right) => ({
|
||||||
|
id, name,
|
||||||
|
type: 'n8n-nodes-base.if', typeVersion: 2,
|
||||||
|
position: pos,
|
||||||
|
parameters: {
|
||||||
|
conditions: { combinator:'and', conditions:[{
|
||||||
|
leftValue: left, rightValue: right,
|
||||||
|
operator: { type:'string', operation:'equals' }
|
||||||
|
}]},
|
||||||
|
options: {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const httpNode = (id, name, pos, jsonBody) => ({
|
||||||
|
id, name,
|
||||||
|
type: 'n8n-nodes-base.httpRequest', typeVersion: 4.2,
|
||||||
|
position: pos,
|
||||||
|
credentials: credential,
|
||||||
|
parameters: {
|
||||||
|
method: 'POST', url: waUrl,
|
||||||
|
authentication: 'genericCredentialType',
|
||||||
|
genericAuthType: 'httpHeaderAuth',
|
||||||
|
sendBody: true, specifyBody: 'json',
|
||||||
|
jsonBody, options: {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const clientBody = `={{ JSON.stringify({messaging_product:"whatsapp",to:$json.to,type:"text",text:{body:$json.messageText}}) }}`;
|
||||||
|
const alvaroBody = `={{ JSON.stringify({messaging_product:"whatsapp",to:$json.alvaroPhone,type:"text",text:{body:$json.specialistMsg}}) }}`;
|
||||||
|
const fedBody = `={{ JSON.stringify({messaging_product:"whatsapp",to:$json.federicoPhone,type:"text",text:{body:$json.specialistMsg}}) }}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'Prisa Yachts — WhatsApp Bot',
|
||||||
|
nodes: [
|
||||||
|
// 1 Webhook
|
||||||
|
{
|
||||||
|
id: 'n-wh', name: 'WhatsApp Webhook',
|
||||||
|
type: 'n8n-nodes-base.webhook', typeVersion: 2,
|
||||||
|
position: [200, 300],
|
||||||
|
parameters: { httpMethod:'POST', path:'prisa-whatsapp', responseMode:'onReceived', options:{} }
|
||||||
|
},
|
||||||
|
// 2 State machine
|
||||||
|
{
|
||||||
|
id: 'n-sm', name: 'State Machine',
|
||||||
|
type: 'n8n-nodes-base.code', typeVersion: 2,
|
||||||
|
position: [450, 300],
|
||||||
|
parameters: { mode:'runOnceForAllItems', jsCode: STATE_MACHINE_CODE }
|
||||||
|
},
|
||||||
|
// 3 IF: is webhook_verify?
|
||||||
|
ifStrNode('n-ifv', 'Is Verify?', [700, 300], '={{ $json.action }}', 'webhook_verify'),
|
||||||
|
// 4 Respond with challenge
|
||||||
|
{
|
||||||
|
id: 'n-respond', name: 'Respond — Verify',
|
||||||
|
type: 'n8n-nodes-base.respondToWebhook', typeVersion: 1.1,
|
||||||
|
position: [950, 140],
|
||||||
|
parameters: {
|
||||||
|
respondWith: 'text',
|
||||||
|
responseBody: '={{ $json.challenge }}',
|
||||||
|
options: { responseCode: 200 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 5 IF: is send_to_client?
|
||||||
|
ifStrNode('n-ifs', 'Is Send?', [950, 380], '={{ $json.action }}', 'send_to_client'),
|
||||||
|
// 6 Send to client
|
||||||
|
httpNode('n-hsc', 'Send WhatsApp — Client', [1200, 280], clientBody),
|
||||||
|
// 7 Ignore (no-op)
|
||||||
|
{ id:'n-nop', name:'Ignore', type:'n8n-nodes-base.noOp', typeVersion:1, position:[1200,520], parameters:{} },
|
||||||
|
// 8 IF notifySpecialist
|
||||||
|
{
|
||||||
|
id: 'n-ifn', name: 'Notify Specialist?',
|
||||||
|
type: 'n8n-nodes-base.if', typeVersion: 2,
|
||||||
|
position: [1450, 280],
|
||||||
|
parameters: {
|
||||||
|
conditions: { combinator:'and', conditions:[{
|
||||||
|
leftValue:'={{ $json.notifySpecialist }}', rightValue: true,
|
||||||
|
operator:{ type:'boolean', operation:'equals' }
|
||||||
|
}]},
|
||||||
|
options: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 9 IF routing === 'both' → true: both, false: check alvaro/fed
|
||||||
|
ifStrNode('n-ifb', 'Is Both?', [1700, 200], '={{ $json.routing }}', 'both'),
|
||||||
|
// 10 IF routing === 'alvaro' → true: alvaro only, false: federico only
|
||||||
|
ifStrNode('n-ifa', 'Is Alvaro?', [1700, 420], '={{ $json.routing }}', 'alvaro'),
|
||||||
|
// 11,12 HTTP nodes
|
||||||
|
httpNode('n-hal', 'Send WhatsApp — Alvaro', [1950, 180], alvaroBody),
|
||||||
|
httpNode('n-hfe', 'Send WhatsApp — Federico', [1950, 420], fedBody),
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'WhatsApp Webhook': { main: [[{ node:'State Machine', type:'main', index:0 }]] },
|
||||||
|
'State Machine': { main: [[{ node:'Is Verify?', type:'main', index:0 }]] },
|
||||||
|
'Is Verify?': { main: [
|
||||||
|
[{ node:'Respond — Verify', type:'main', index:0 }], // true
|
||||||
|
[{ node:'Is Send?', type:'main', index:0 }] // false
|
||||||
|
]},
|
||||||
|
'Is Send?': { main: [
|
||||||
|
[{ node:'Send WhatsApp — Client', type:'main', index:0 }], // true
|
||||||
|
[{ node:'Ignore', type:'main', index:0 }] // false
|
||||||
|
]},
|
||||||
|
'Send WhatsApp — Client': { main: [[{ node:'Notify Specialist?', type:'main', index:0 }]] },
|
||||||
|
'Notify Specialist?': { main: [
|
||||||
|
[{ node:'Is Both?', type:'main', index:0 }], // true → route
|
||||||
|
[] // false → end
|
||||||
|
]},
|
||||||
|
'Is Both?': { main: [
|
||||||
|
// true → send to BOTH simultaneously
|
||||||
|
[{ node:'Send WhatsApp — Alvaro', type:'main', index:0 }, { node:'Send WhatsApp — Federico', type:'main', index:0 }],
|
||||||
|
[{ node:'Is Alvaro?', type:'main', index:0 }] // false → check which one
|
||||||
|
]},
|
||||||
|
'Is Alvaro?': { main: [
|
||||||
|
[{ node:'Send WhatsApp — Alvaro', type:'main', index:0 }], // true
|
||||||
|
[{ node:'Send WhatsApp — Federico', type:'main', index:0 }] // false (must be federico)
|
||||||
|
]}
|
||||||
|
},
|
||||||
|
settings: { executionOrder: 'v1' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────────────────────────────────────────────────────────── main */
|
||||||
|
async function cleanup() {
|
||||||
|
const wfs = await apiCall('GET', '/workflows?limit=50');
|
||||||
|
for (const wf of (wfs.data?.data || [])) {
|
||||||
|
if (wf.name && wf.name.includes('Prisa Yachts')) {
|
||||||
|
await apiCall('DELETE', `/workflows/${wf.id}`);
|
||||||
|
console.log(` 🗑 Deleted workflow: ${wf.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const creds = await apiCall('GET', '/credentials?limit=50');
|
||||||
|
for (const c of (creds.data?.data || [])) {
|
||||||
|
if (c.name === 'WhatsApp_Prisa') {
|
||||||
|
await apiCall('DELETE', `/credentials/${c.id}`);
|
||||||
|
console.log(` 🗑 Deleted credential: ${c.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('\n🚀 Prisa Yachts — WhatsApp Bot Deployment\n');
|
||||||
|
|
||||||
|
console.log('0/4 Cleaning up previous attempts...');
|
||||||
|
await cleanup();
|
||||||
|
console.log(' ✅ Clean');
|
||||||
|
|
||||||
|
// 1. Credential
|
||||||
|
console.log('1/4 Creating WhatsApp credential...');
|
||||||
|
const credRes = await apiCall('POST', '/credentials', {
|
||||||
|
name: 'WhatsApp_Prisa',
|
||||||
|
type: 'httpHeaderAuth',
|
||||||
|
data: { name: 'Authorization', value: 'Bearer PLACEHOLDER_PENDING_META_APPROVAL' }
|
||||||
|
});
|
||||||
|
if (![200,201].includes(credRes.status)) {
|
||||||
|
console.error(' ❌ Credential error:', JSON.stringify(credRes.data, null, 2));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const credId = credRes.data.id;
|
||||||
|
console.log(` ✅ Credential created id=${credId}`);
|
||||||
|
|
||||||
|
// 2. Workflow
|
||||||
|
console.log('2/4 Creating workflow...');
|
||||||
|
const wfRes = await apiCall('POST', '/workflows', buildWorkflow(credId));
|
||||||
|
if (![200,201].includes(wfRes.status)) {
|
||||||
|
console.error(' ❌ Workflow error:', JSON.stringify(wfRes.data, null, 2));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const wfId = wfRes.data.id;
|
||||||
|
console.log(` ✅ Workflow created id=${wfId}`);
|
||||||
|
|
||||||
|
// 3. Activate
|
||||||
|
console.log('3/4 Activating workflow...');
|
||||||
|
const actRes = await apiCall('POST', `/workflows/${wfId}/activate`);
|
||||||
|
if (actRes.status !== 200) {
|
||||||
|
console.warn(' ⚠️ Activation returned', actRes.status, JSON.stringify(actRes.data));
|
||||||
|
} else {
|
||||||
|
console.log(' ✅ Workflow ACTIVE');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Confirm
|
||||||
|
console.log('4/4 Verifying...');
|
||||||
|
const chk = await apiCall('GET', `/workflows/${wfId}`);
|
||||||
|
console.log(` ✅ active = ${chk.data?.active}`);
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
══════════════════════════════════════════════════════
|
||||||
|
✅ DEPLOYMENT COMPLETE
|
||||||
|
══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
Webhook URL → https://n8n.crewinghunters.com/webhook/prisa-whatsapp
|
||||||
|
Workflow ID → ${wfId}
|
||||||
|
Credential → WhatsApp_Prisa (id=${credId})
|
||||||
|
|
||||||
|
══════════════════════════════════════════════════════
|
||||||
|
NEXT STEPS — after Meta approves your WhatsApp account
|
||||||
|
══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
1. n8n → Settings → Credentials → WhatsApp_Prisa
|
||||||
|
Change value from PLACEHOLDER_PENDING_META_APPROVAL
|
||||||
|
to Bearer <YOUR_ACCESS_TOKEN>
|
||||||
|
|
||||||
|
2. Open workflow "Prisa Yachts — WhatsApp Bot"
|
||||||
|
In these 3 nodes, change the URL:
|
||||||
|
• Send WhatsApp — Client
|
||||||
|
• Send WhatsApp — Alvaro
|
||||||
|
• Send WhatsApp — Federico
|
||||||
|
Replace PLACEHOLDER_PHONE_NUMBER_ID
|
||||||
|
With <YOUR_PHONE_NUMBER_ID>
|
||||||
|
|
||||||
|
3. Meta Developer Portal → WhatsApp → Configuration
|
||||||
|
Webhook URL : https://n8n.crewinghunters.com/webhook/prisa-whatsapp
|
||||||
|
Verify Token : any string (e.g. "prisayachts2025")
|
||||||
|
Subscribe to : messages
|
||||||
|
|
||||||
|
4. Send "hola" to +17863963315 from any WhatsApp to test.
|
||||||
|
|
||||||
|
══════════════════════════════════════════════════════
|
||||||
|
ROUTING
|
||||||
|
══════════════════════════════════════════════════════
|
||||||
|
1 Engines & Mechanical → Alvaro +19546554084
|
||||||
|
2 Electrical & Electronics → Alvaro +19546554084
|
||||||
|
3 Teak Deck Recovery → Federico +17542093375
|
||||||
|
4 Captaining & Crewing → Federico +17542093375
|
||||||
|
5 Yacht Care & Detailing → Federico +17542093375
|
||||||
|
6 Crew Placement & Staffing → Federico +17542093375
|
||||||
|
7 Other / Otro → BOTH
|
||||||
|
══════════════════════════════════════════════════════
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error('Fatal:', e); process.exit(1); });
|
||||||
+1076
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.18.1",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
|
"puppeteer": "^25.1.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
// scrape_marine_emails.js
|
||||||
|
// Reads marine business directory Excel, searches for emails/websites,
|
||||||
|
// writes results back to the same file with checkpoints every 25 rows.
|
||||||
|
|
||||||
|
const xlsx = require('xlsx');
|
||||||
|
const axios = require('axios');
|
||||||
|
const cheerio = require('cheerio');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const EXCEL_PATH = path.resolve(__dirname, 'Directorio_Marino_FL_Acumulativo_4.xlsx');
|
||||||
|
const SHEETS = ['MARINAS', 'ASTILLEROS', 'ALMACENAMIENTO', 'SUMINISTROS'];
|
||||||
|
const CHECKPOINT = 25;
|
||||||
|
const DELAY_MS = 2000;
|
||||||
|
const TIMEOUT_MS = 10000;
|
||||||
|
const MAX_PAGES = 3;
|
||||||
|
|
||||||
|
const COL = { NUM:0, NAME:1, ADDR:2, PHONE:3, EMAIL:4, CITY:5, COUNTY:6, WEBSITE:7 };
|
||||||
|
|
||||||
|
const EMAIL_RE = /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g;
|
||||||
|
|
||||||
|
const summary = { processed:0, emails:0, websites:0, noEmail:[] };
|
||||||
|
|
||||||
|
const http = axios.create({
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36',
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.5'
|
||||||
|
},
|
||||||
|
maxRedirects: 5,
|
||||||
|
validateStatus: s => s < 500
|
||||||
|
});
|
||||||
|
|
||||||
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||||
|
|
||||||
|
// Guard 1: sanitize names before using in search queries
|
||||||
|
function sanitize(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str).replace(/[<>"'`;&|$\\{}]/g, '').trim().substring(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEmails(text) {
|
||||||
|
const raw = text.match(EMAIL_RE) || [];
|
||||||
|
return [...new Set(raw.filter(e => {
|
||||||
|
const low = e.toLowerCase();
|
||||||
|
return !low.match(/\.(png|jpg|jpeg|gif|svg|webp|pdf|zip|js|css)$/) &&
|
||||||
|
!low.includes('example.com') &&
|
||||||
|
!low.includes('sentry.io') &&
|
||||||
|
low.includes('.');
|
||||||
|
}))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreEmail(email) {
|
||||||
|
const low = email.toLowerCase();
|
||||||
|
if (/dockmaster@/.test(low)) return 5;
|
||||||
|
if (/marina@/.test(low)) return 4;
|
||||||
|
if (/service@/.test(low)) return 3;
|
||||||
|
if (/contact@/.test(low)) return 2;
|
||||||
|
if (/info@/.test(low)) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bestEmail(emails) {
|
||||||
|
if (!emails.length) return null;
|
||||||
|
return emails.sort((a, b) => scoreEmail(b) - scoreEmail(a))[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPage(url) {
|
||||||
|
try {
|
||||||
|
if (!url.startsWith('http')) url = 'https://' + url;
|
||||||
|
const res = await http.get(url);
|
||||||
|
if (res.status === 200 && res.data) return { html: String(res.data), url };
|
||||||
|
} catch { /* timeout or network error — skip */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchDDG(query) {
|
||||||
|
const q = encodeURIComponent(sanitize(query));
|
||||||
|
try {
|
||||||
|
const res = await http.get(`https://html.duckduckgo.com/html/?q=${q}&kl=us-en`);
|
||||||
|
if (!res.data) return [];
|
||||||
|
const $ = cheerio.load(res.data);
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// Primary: result URL spans
|
||||||
|
$('a.result__url').each((_, el) => {
|
||||||
|
let href = $(el).attr('href') || $(el).text().trim();
|
||||||
|
if (href && !href.includes('duckduckgo.com')) {
|
||||||
|
if (!href.startsWith('http')) href = 'https://' + href;
|
||||||
|
results.push(href);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fallback: uddg= redirect links
|
||||||
|
if (!results.length) {
|
||||||
|
$('a[href*="uddg="]').each((_, el) => {
|
||||||
|
const href = $(el).attr('href') || '';
|
||||||
|
try {
|
||||||
|
const u = new URL(href, 'https://duckduckgo.com');
|
||||||
|
const dest = u.searchParams.get('uddg');
|
||||||
|
if (dest) results.push(decodeURIComponent(dest));
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...new Set(results)].slice(0, 6);
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function rootDomain(url) {
|
||||||
|
try { return new URL(url).origin; } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKIP_DOMAINS = /yelp|yellowpages|mapquest|tripadvisor|facebook|google|instagram|twitter|linkedin|bbb\.org|manta\.com|chamberofcommerce|indeed|glassdoor|angieslist|homeadvisor|findagrave/i;
|
||||||
|
|
||||||
|
async function scrapeBusiness(name, city) {
|
||||||
|
const query = `"${name}" ${city} Florida marina contact email`;
|
||||||
|
let website = null;
|
||||||
|
let email = null;
|
||||||
|
let pagesFetched = 0;
|
||||||
|
const visitedDomains = new Set();
|
||||||
|
|
||||||
|
const results = await searchDDG(query);
|
||||||
|
await sleep(300);
|
||||||
|
|
||||||
|
for (const url of results) {
|
||||||
|
if (SKIP_DOMAINS.test(url)) continue;
|
||||||
|
const domain = rootDomain(url);
|
||||||
|
if (!domain || visitedDomains.has(domain)) continue;
|
||||||
|
visitedDomains.add(domain);
|
||||||
|
|
||||||
|
if (!website) website = url;
|
||||||
|
|
||||||
|
// Fetch homepage
|
||||||
|
if (pagesFetched >= MAX_PAGES) break;
|
||||||
|
const home = await fetchPage(url);
|
||||||
|
pagesFetched++;
|
||||||
|
if (!home) continue;
|
||||||
|
|
||||||
|
const e1 = extractEmails(home.html);
|
||||||
|
if (e1.length) { email = bestEmail(e1); break; }
|
||||||
|
|
||||||
|
// Fetch /contact page
|
||||||
|
if (pagesFetched < MAX_PAGES) {
|
||||||
|
const contactUrl = domain + '/contact';
|
||||||
|
const contact = await fetchPage(contactUrl) ||
|
||||||
|
await fetchPage(domain + '/contact-us');
|
||||||
|
pagesFetched++;
|
||||||
|
if (contact) {
|
||||||
|
const e2 = extractEmails(contact.html);
|
||||||
|
if (e2.length) { email = bestEmail(e2); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch /about page
|
||||||
|
if (pagesFetched < MAX_PAGES && !email) {
|
||||||
|
const about = await fetchPage(domain + '/about') ||
|
||||||
|
await fetchPage(domain + '/about-us');
|
||||||
|
pagesFetched++;
|
||||||
|
if (about) {
|
||||||
|
const e3 = extractEmails(about.html);
|
||||||
|
if (e3.length) { email = bestEmail(e3); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { website, email };
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveWorkbook(wb) {
|
||||||
|
const tmp = EXCEL_PATH + '.tmp';
|
||||||
|
try {
|
||||||
|
xlsx.writeFile(wb, tmp);
|
||||||
|
if (fs.existsSync(tmp)) {
|
||||||
|
fs.copyFileSync(tmp, EXCEL_PATH);
|
||||||
|
fs.unlinkSync(tmp);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// If original is locked by Excel, save to sidecar so no progress is lost
|
||||||
|
const sidecar = EXCEL_PATH.replace('.xlsx', '_output.xlsx');
|
||||||
|
console.log(`\n ⚠️ File locked — saving to: ${path.basename(sidecar)}`);
|
||||||
|
xlsx.writeFile(wb, sidecar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processSheet(wb, sheetName) {
|
||||||
|
const ws = wb.Sheets[sheetName];
|
||||||
|
const data = xlsx.utils.sheet_to_json(ws, { header:1, defval:'' });
|
||||||
|
|
||||||
|
// Find the header row (contains "NOMBRE") — could be row 0 or 1
|
||||||
|
let headerIdx = 0;
|
||||||
|
for (let r = 0; r < Math.min(3, data.length); r++) {
|
||||||
|
if (data[r] && data[r].includes('NOMBRE')) { headerIdx = r; break; }
|
||||||
|
}
|
||||||
|
if (data[headerIdx] && !data[headerIdx][COL.WEBSITE]) data[headerIdx][COL.WEBSITE] = 'WEBSITE';
|
||||||
|
|
||||||
|
const dataStart = headerIdx + 1;
|
||||||
|
console.log(`\n${'═'.repeat(60)}`);
|
||||||
|
console.log(` Sheet: ${sheetName} (${data.length - dataStart} data rows, header at row ${headerIdx})`);
|
||||||
|
console.log(`${'═'.repeat(60)}`);
|
||||||
|
|
||||||
|
let rowsSinceCheckpoint = 0;
|
||||||
|
|
||||||
|
for (let i = dataStart; i < data.length; i++) {
|
||||||
|
const row = data[i];
|
||||||
|
const name = sanitize(row[COL.NAME]);
|
||||||
|
const city = sanitize(row[COL.CITY]);
|
||||||
|
|
||||||
|
if (!name) continue;
|
||||||
|
|
||||||
|
// Skip rows already complete
|
||||||
|
if (row[COL.EMAIL] && row[COL.WEBSITE]) {
|
||||||
|
console.log(` row ${String(i).padStart(3)} SKIP (filled): ${name.substring(0,35)}`);
|
||||||
|
summary.processed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(` row ${String(i).padStart(3)} ${name.substring(0,38).padEnd(38)} → `);
|
||||||
|
|
||||||
|
let email = row[COL.EMAIL] || null;
|
||||||
|
let website = row[COL.WEBSITE] || null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!email || !website) {
|
||||||
|
const result = await scrapeBusiness(name, city);
|
||||||
|
if (!email && result.email) email = result.email;
|
||||||
|
if (!website && result.website) website = result.website;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
process.stdout.write(`ERR(${err.message.substring(0,30)})\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email) row[COL.EMAIL] = email;
|
||||||
|
if (website) row[COL.WEBSITE] = website;
|
||||||
|
|
||||||
|
summary.processed++;
|
||||||
|
if (email) summary.emails++;
|
||||||
|
if (website) summary.websites++;
|
||||||
|
if (!email) summary.noEmail.push(`[${sheetName}] ${name} — ${city}`);
|
||||||
|
|
||||||
|
process.stdout.write(`${email || '(no email)'.padEnd(35)} ${website ? website.substring(0,40) : '(no site)'}\n`);
|
||||||
|
|
||||||
|
const newWs = xlsx.utils.aoa_to_sheet(data);
|
||||||
|
wb.Sheets[sheetName] = newWs;
|
||||||
|
|
||||||
|
rowsSinceCheckpoint++;
|
||||||
|
if (rowsSinceCheckpoint >= CHECKPOINT) {
|
||||||
|
saveWorkbook(wb);
|
||||||
|
console.log(`\n ★ CHECKPOINT: saved after ${summary.processed} total rows processed ★\n`);
|
||||||
|
rowsSinceCheckpoint = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newWs = xlsx.utils.aoa_to_sheet(data);
|
||||||
|
wb.Sheets[sheetName] = newWs;
|
||||||
|
saveWorkbook(wb);
|
||||||
|
console.log(`\n ✅ ${sheetName} complete — file saved.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`
|
||||||
|
✅ GUARD 1 — Input: business names sanitized before search queries
|
||||||
|
✅ GUARD 2 — Network: ${TIMEOUT_MS/1000}s timeout, max ${MAX_PAGES} pages/domain, ${DELAY_MS/1000}s inter-domain delay
|
||||||
|
✅ GUARD 3 — Data: reads/writes only ${path.basename(EXCEL_PATH)}, no credentials stored
|
||||||
|
|
||||||
|
🚀 Starting marine directory email scraper...
|
||||||
|
File: ${EXCEL_PATH}
|
||||||
|
Sheets: ${SHEETS.join(', ')}
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(EXCEL_PATH)) {
|
||||||
|
console.error('❌ Excel file not found:', EXCEL_PATH);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wb = xlsx.readFile(EXCEL_PATH);
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
for (const sheet of SHEETS) {
|
||||||
|
if (!wb.SheetNames.includes(sheet)) {
|
||||||
|
console.log(`Sheet "${sheet}" not found — skipping.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await processSheet(wb, sheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveWorkbook(wb);
|
||||||
|
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||||
|
|
||||||
|
const noEmailList = summary.noEmail.length
|
||||||
|
? summary.noEmail.map(n => ' • ' + n).join('\n')
|
||||||
|
: ' (all businesses have email)';
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
${'═'.repeat(60)}
|
||||||
|
FINAL SUMMARY
|
||||||
|
${'═'.repeat(60)}
|
||||||
|
Rows processed : ${summary.processed}
|
||||||
|
Emails found : ${summary.emails} (${Math.round(summary.emails/summary.processed*100)||0}%)
|
||||||
|
Websites found : ${summary.websites} (${Math.round(summary.websites/summary.processed*100)||0}%)
|
||||||
|
Time elapsed : ${Math.floor(elapsed/60)}m ${elapsed%60}s
|
||||||
|
Saved to : ${EXCEL_PATH}
|
||||||
|
|
||||||
|
Businesses with NO email (${summary.noEmail.length}) — for manual follow-up:
|
||||||
|
${noEmailList}
|
||||||
|
${'═'.repeat(60)}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => { console.error('Fatal:', err); process.exit(1); });
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
# Voz → Calendario Nextcloud — Blueprint Completo
|
||||||
|
**Versión:** 1.0
|
||||||
|
**Fecha:** 2026-07-03
|
||||||
|
**Autor:** Álvaro
|
||||||
|
**n8n:** https://n8n.crewinghunters.com
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visión General
|
||||||
|
|
||||||
|
Grabas un audio en el teléfono → se lo mandas al bot de Telegram → la IA local transcribe y entiende el evento → aparece en tu calendario de Nextcloud → te recuerda 30 minutos antes por Telegram.
|
||||||
|
|
||||||
|
```
|
||||||
|
📱 Audio (teléfono)
|
||||||
|
│
|
||||||
|
▼ Telegram
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ n8n (Raspberry Pi 4 — Colombia) │
|
||||||
|
│ │
|
||||||
|
│ 1. Recibe audio de Telegram │
|
||||||
|
│ 2. Descarga el archivo .ogg/.mp3 │
|
||||||
|
│ 3. Llama a Whisper API (tu PC via Tailscale) │
|
||||||
|
│ 4. Llama a Ollama (tu PC via Tailscale) │
|
||||||
|
│ 5. Crea evento en Nextcloud Calendar (CalDAV) │
|
||||||
|
│ 6. Confirma por Telegram │
|
||||||
|
│ 7. Programa recordatorio 30min antes │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼ Tailscale VPN (túnel privado gratis)
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Tu PC (Windows) │
|
||||||
|
│ ├── Ollama → AsistentePersonal / qwen2.5:14b │
|
||||||
|
│ └── Whisper API → faster-whisper (Python) │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack Tecnológico
|
||||||
|
|
||||||
|
| Componente | Herramienta | Dónde corre |
|
||||||
|
|-----------|------------|------------|
|
||||||
|
| Automatización | n8n | RPi4 Colombia |
|
||||||
|
| Bot mensajería | Telegram Bot | Cloud (Telegram) |
|
||||||
|
| Transcripción voz | faster-whisper | Tu PC (Windows) |
|
||||||
|
| LLM extracción evento | Ollama / qwen2.5:14b | Tu PC (Windows) |
|
||||||
|
| Calendario | Nextcloud CalDAV | RPi4 Colombia |
|
||||||
|
| Túnel privado | Tailscale | PC + RPi4 |
|
||||||
|
| API transcripción | FastAPI (Python) | Tu PC (Windows) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 1 — Tailscale (conectar PC con RPi4)
|
||||||
|
|
||||||
|
Tailscale crea una red privada entre tus dispositivos. Una vez instalado, tu PC tendrá una IP tipo `100.x.x.x` accesible desde el RPi4 sin abrir puertos ni router.
|
||||||
|
|
||||||
|
### En tu PC (Windows)
|
||||||
|
1. Descarga e instala Tailscale: https://tailscale.com/download/windows
|
||||||
|
2. Inicia sesión con Google o email
|
||||||
|
3. Anota tu IP Tailscale: aparece en el icono de la bandeja del sistema
|
||||||
|
- Ejemplo: `100.64.1.10`
|
||||||
|
|
||||||
|
### En el RPi4 (via SSH desde Colombia)
|
||||||
|
```bash
|
||||||
|
# Conectar al RPi4
|
||||||
|
ssh usuario@n8n.crewinghunters.com
|
||||||
|
|
||||||
|
# Instalar Tailscale en RPi4
|
||||||
|
curl -fsSL https://tailscale.com/install.sh | sh
|
||||||
|
|
||||||
|
# Iniciar y autenticar (con la MISMA cuenta que en el PC)
|
||||||
|
sudo tailscale up
|
||||||
|
|
||||||
|
# Verificar que ve tu PC
|
||||||
|
tailscale ping 100.64.1.10 # ← IP de tu PC Tailscale
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ Si el ping responde, están conectados. El RPi4 puede hablar con tu PC.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 2 — Whisper API en tu PC
|
||||||
|
|
||||||
|
Instalamos faster-whisper y levantamos una API HTTP que n8n puede llamar.
|
||||||
|
|
||||||
|
### Instalar faster-whisper
|
||||||
|
```bash
|
||||||
|
# En tu PC (PowerShell)
|
||||||
|
pip install faster-whisper fastapi uvicorn python-multipart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Crear el servidor API
|
||||||
|
|
||||||
|
Guarda este archivo en: `C:\whisper-api\server.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI, UploadFile, File
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
import tempfile, os
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
# Carga el modelo una vez al iniciar (medium es bueno para español)
|
||||||
|
# Opciones: "tiny", "base", "small", "medium", "large-v3"
|
||||||
|
model = WhisperModel("medium", device="cpu", compute_type="int8")
|
||||||
|
|
||||||
|
@app.post("/transcribe")
|
||||||
|
async def transcribe(file: UploadFile = File(...)):
|
||||||
|
# Guardar audio temporalmente
|
||||||
|
suffix = os.path.splitext(file.filename)[1] or ".ogg"
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||||
|
tmp.write(await file.read())
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
# Transcribir
|
||||||
|
segments, info = model.transcribe(tmp_path, language="es")
|
||||||
|
text = " ".join([seg.text for seg in segments]).strip()
|
||||||
|
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
return {"text": text, "language": info.language}
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Script para iniciar la API (guarda como `C:\whisper-api\start.bat`)
|
||||||
|
```bat
|
||||||
|
@echo off
|
||||||
|
cd C:\whisper-api
|
||||||
|
uvicorn server:app --host 0.0.0.0 --port 8765
|
||||||
|
```
|
||||||
|
|
||||||
|
### Iniciar automáticamente con Windows
|
||||||
|
1. Presiona `Win + R` → escribe `shell:startup`
|
||||||
|
2. Crea un acceso directo a `start.bat` en esa carpeta
|
||||||
|
3. La API estará disponible en: `http://100.64.1.10:8765` (via Tailscale)
|
||||||
|
|
||||||
|
### Verificar que funciona (desde RPi4)
|
||||||
|
```bash
|
||||||
|
# Desde el RPi4 via SSH
|
||||||
|
curl http://100.64.1.10:8765/health
|
||||||
|
# Debe responder: {"status":"ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 3 — Exponer Ollama via Tailscale
|
||||||
|
|
||||||
|
Por defecto Ollama solo escucha en localhost. Hay que permitir que escuche en todas las interfaces.
|
||||||
|
|
||||||
|
### En tu PC (PowerShell como Administrador)
|
||||||
|
```powershell
|
||||||
|
# Agregar variable de entorno para Ollama
|
||||||
|
[System.Environment]::SetEnvironmentVariable("OLLAMA_HOST", "0.0.0.0:11434", "Machine")
|
||||||
|
|
||||||
|
# Reiniciar Ollama (busca en la bandeja del sistema → clic derecho → Quit, luego reabre)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verificar desde RPi4
|
||||||
|
```bash
|
||||||
|
curl http://100.64.1.10:11434/api/tags
|
||||||
|
# Debe listar tus modelos
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 4 — Bot de Telegram
|
||||||
|
|
||||||
|
### Crear el bot
|
||||||
|
1. Abre Telegram → busca `@BotFather`
|
||||||
|
2. Escribe `/newbot`
|
||||||
|
3. Nombre del bot: `Mi Calendario Personal` (o el que quieras)
|
||||||
|
4. Username: `mi_calendario_alvaro_bot` (debe terminar en `_bot`)
|
||||||
|
5. BotFather te da un **token** — guárdalo: `7123456789:AAF...`
|
||||||
|
|
||||||
|
### Obtener tu Chat ID
|
||||||
|
1. Escribe cualquier mensaje a tu nuevo bot
|
||||||
|
2. Abre en el navegador (reemplaza TOKEN):
|
||||||
|
```
|
||||||
|
https://api.telegram.org/botTOKEN/getUpdates
|
||||||
|
```
|
||||||
|
3. Busca `"id"` dentro de `"chat"` — ese número es tu **Chat ID**
|
||||||
|
- Ejemplo: `123456789`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 5 — Nextcloud Calendar (CalDAV)
|
||||||
|
|
||||||
|
n8n se conecta al calendario de Nextcloud via CalDAV.
|
||||||
|
|
||||||
|
### Obtener la URL CalDAV
|
||||||
|
En Nextcloud → Calendario → ⚙️ (engranaje junto al calendario) → "Copiar enlace privado"
|
||||||
|
|
||||||
|
La URL tiene esta forma:
|
||||||
|
```
|
||||||
|
https://tu-nextcloud.com/remote.php/dav/calendars/USUARIO/personal/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Credenciales
|
||||||
|
- Usuario: tu usuario de Nextcloud
|
||||||
|
- Contraseña: genera una **contraseña de aplicación** en Nextcloud:
|
||||||
|
Configuración → Seguridad → Dispositivos y sesiones → "Crear nueva contraseña de aplicación"
|
||||||
|
- Nombre: `n8n-calendario`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 6 — Organizar Carpetas en n8n
|
||||||
|
|
||||||
|
Antes de crear el workflow, organiza las carpetas:
|
||||||
|
|
||||||
|
1. Abre https://n8n.crewinghunters.com
|
||||||
|
2. En el panel izquierdo → "Workflows" → "Add folder"
|
||||||
|
3. Crea estas carpetas:
|
||||||
|
|
||||||
|
```
|
||||||
|
📁 Prisa Yachts ← mueve el bot de WhatsApp aquí
|
||||||
|
📁 Personal - Productividad
|
||||||
|
📁 AIS Navigator
|
||||||
|
📁 Pruebas / Sandbox
|
||||||
|
```
|
||||||
|
|
||||||
|
4. El nuevo workflow irá en `📁 Personal - Productividad`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 7 — Workflow n8n Completo
|
||||||
|
|
||||||
|
Nombre del workflow: `🎙️ Voz → Calendario Nextcloud`
|
||||||
|
Carpeta: `Personal - Productividad`
|
||||||
|
|
||||||
|
### Nodos del workflow (en orden):
|
||||||
|
|
||||||
|
```
|
||||||
|
[Telegram Trigger]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[IF: ¿es audio?]
|
||||||
|
│ sí
|
||||||
|
▼
|
||||||
|
[HTTP: Descargar audio de Telegram]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[HTTP: Whisper API → transcripción]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[HTTP: Ollama → extraer evento]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[Code: parsear JSON respuesta Ollama]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[HTTP: Crear evento CalDAV en Nextcloud]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[Telegram: Confirmar al usuario]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[Wait: esperar hasta 30min antes del evento]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[Telegram: Enviar recordatorio]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Nodo 1 — Telegram Trigger
|
||||||
|
```
|
||||||
|
Tipo: Telegram Trigger
|
||||||
|
Credential: (crear nueva → Token del bot)
|
||||||
|
Updates: message
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nodo 2 — IF: ¿Es audio?
|
||||||
|
```
|
||||||
|
Tipo: IF
|
||||||
|
Condición: {{ $json.message.voice }} existe
|
||||||
|
O {{ $json.message.audio }} existe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nodo 3 — HTTP: Descargar audio de Telegram
|
||||||
|
```
|
||||||
|
Tipo: HTTP Request
|
||||||
|
Método: GET
|
||||||
|
URL: https://api.telegram.org/bot{{ $env.TELEGRAM_TOKEN }}/getFile
|
||||||
|
Parámetros:
|
||||||
|
file_id: {{ $json.message.voice.file_id }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Luego un segundo HTTP Request para descargar el archivo:
|
||||||
|
```
|
||||||
|
URL: https://api.telegram.org/file/bot{{ $env.TELEGRAM_TOKEN }}/{{ $json.result.file_path }}
|
||||||
|
Respuesta: archivo binario
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nodo 4 — HTTP: Whisper API (transcripción)
|
||||||
|
```
|
||||||
|
Tipo: HTTP Request
|
||||||
|
Método: POST
|
||||||
|
URL: http://100.64.1.10:8765/transcribe ← IP Tailscale de tu PC
|
||||||
|
Body: form-data
|
||||||
|
file: [archivo de audio del nodo anterior]
|
||||||
|
```
|
||||||
|
|
||||||
|
Resultado esperado:
|
||||||
|
```json
|
||||||
|
{ "text": "Reunión con Juan el viernes a las diez de la mañana en el puerto" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nodo 5 — HTTP: Ollama (extraer evento)
|
||||||
|
```
|
||||||
|
Tipo: HTTP Request
|
||||||
|
Método: POST
|
||||||
|
URL: http://100.64.1.10:11434/api/generate
|
||||||
|
Body JSON:
|
||||||
|
{
|
||||||
|
"model": "qwen2.5:14b",
|
||||||
|
"stream": false,
|
||||||
|
"prompt": "Extrae el evento de esta frase y devuelve SOLO JSON válido sin explicaciones:\n\nFrase: '{{ $json.text }}'\n\nFecha actual: {{ $now.format('YYYY-MM-DD') }}, {{ $now.format('dddd') }}\n\nDevuelve exactamente este formato:\n{\"titulo\": \"...\", \"fecha\": \"YYYY-MM-DD\", \"hora_inicio\": \"HH:MM\", \"hora_fin\": \"HH:MM\", \"lugar\": \"...\", \"descripcion\": \"...\"}\n\nSi no hay hora fin, suma 1 hora a la hora inicio. Si no hay lugar, pon vacío."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nodo 6 — Code: Parsear respuesta Ollama
|
||||||
|
```javascript
|
||||||
|
// Extraer el JSON de la respuesta de Ollama
|
||||||
|
const responseText = $input.item.json.response;
|
||||||
|
|
||||||
|
// Buscar el JSON dentro del texto (por si Ollama añade texto extra)
|
||||||
|
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
||||||
|
if (!jsonMatch) throw new Error("Ollama no devolvió JSON válido: " + responseText);
|
||||||
|
|
||||||
|
const evento = JSON.parse(jsonMatch[0]);
|
||||||
|
|
||||||
|
// Construir fechas iCal (formato: 20260704T100000)
|
||||||
|
const fechaInicio = evento.fecha.replace(/-/g, '') + 'T' + evento.hora_inicio.replace(':', '') + '00';
|
||||||
|
const fechaFin = evento.fecha.replace(/-/g, '') + 'T' + evento.hora_fin.replace(':', '') + '00';
|
||||||
|
|
||||||
|
// UID único para el evento
|
||||||
|
const uid = 'n8n-' + Date.now() + '@crewinghunters.com';
|
||||||
|
|
||||||
|
// Formato iCalendar (CalDAV)
|
||||||
|
const ical = `BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//n8n//Calendario Voz//ES
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:${uid}
|
||||||
|
DTSTART:${fechaInicio}
|
||||||
|
DTEND:${fechaFin}
|
||||||
|
SUMMARY:${evento.titulo}
|
||||||
|
LOCATION:${evento.lugar || ''}
|
||||||
|
DESCRIPTION:${evento.descripcion || 'Creado por voz via n8n'}
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
json: {
|
||||||
|
...evento,
|
||||||
|
ical,
|
||||||
|
uid,
|
||||||
|
fechaInicio,
|
||||||
|
fechaFin,
|
||||||
|
// Para el recordatorio: fecha ISO completa
|
||||||
|
fechaRecordatorio: new Date(evento.fecha + 'T' + evento.hora_inicio + ':00').toISOString()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nodo 7 — HTTP: Crear evento en Nextcloud (CalDAV)
|
||||||
|
```
|
||||||
|
Tipo: HTTP Request
|
||||||
|
Método: PUT
|
||||||
|
URL: https://tu-nextcloud.com/remote.php/dav/calendars/USUARIO/personal/{{ $json.uid }}.ics
|
||||||
|
Autenticación: Basic Auth
|
||||||
|
Usuario: tu_usuario_nextcloud
|
||||||
|
Password: contraseña_de_aplicacion
|
||||||
|
Headers:
|
||||||
|
Content-Type: text/calendar; charset=utf-8
|
||||||
|
Body: {{ $json.ical }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nodo 8 — Telegram: Confirmar
|
||||||
|
```
|
||||||
|
Tipo: Telegram
|
||||||
|
Operación: Send Message
|
||||||
|
Chat ID: {{ $env.TELEGRAM_CHAT_ID }}
|
||||||
|
Texto:
|
||||||
|
✅ *Evento creado en tu calendario*
|
||||||
|
|
||||||
|
📅 *{{ $json.titulo }}*
|
||||||
|
🗓 {{ $json.fecha }}
|
||||||
|
🕐 {{ $json.hora_inicio }} – {{ $json.hora_fin }}
|
||||||
|
📍 {{ $json.lugar || 'Sin ubicación' }}
|
||||||
|
|
||||||
|
_Te recordaré 30 minutos antes_ ⏰
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nodo 9 — Wait (hasta 30min antes del evento)
|
||||||
|
```
|
||||||
|
Tipo: Wait
|
||||||
|
Modo: Until specified time
|
||||||
|
Fecha/Hora: {{ $json.fechaRecordatorio }}
|
||||||
|
Offset: -30 minutos
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nodo 10 — Telegram: Recordatorio
|
||||||
|
```
|
||||||
|
Tipo: Telegram
|
||||||
|
Operación: Send Message
|
||||||
|
Chat ID: {{ $env.TELEGRAM_CHAT_ID }}
|
||||||
|
Texto:
|
||||||
|
⏰ *Recordatorio — en 30 minutos:*
|
||||||
|
|
||||||
|
📅 *{{ $json.titulo }}*
|
||||||
|
🕐 {{ $json.hora_inicio }}
|
||||||
|
📍 {{ $json.lugar || '' }}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 8 — Variables de Entorno en n8n
|
||||||
|
|
||||||
|
En n8n → Settings → Variables → Agregar:
|
||||||
|
|
||||||
|
| Variable | Valor |
|
||||||
|
|---------|-------|
|
||||||
|
| `TELEGRAM_TOKEN` | `7123456789:AAF...` (token del bot) |
|
||||||
|
| `TELEGRAM_CHAT_ID` | `123456789` (tu chat ID) |
|
||||||
|
| `WHISPER_URL` | `http://100.64.1.10:8765` |
|
||||||
|
| `OLLAMA_URL` | `http://100.64.1.10:11434` |
|
||||||
|
| `NEXTCLOUD_URL` | `https://tu-nextcloud.com` |
|
||||||
|
| `NEXTCLOUD_USER` | `tu_usuario` |
|
||||||
|
| `NEXTCLOUD_PASS` | `contraseña_de_aplicacion` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 9 — Prueba Completa
|
||||||
|
|
||||||
|
1. Graba un audio en el teléfono diciendo:
|
||||||
|
> *"Reunión con Federico el próximo martes a las tres de la tarde en el puerto de Miami"*
|
||||||
|
|
||||||
|
2. Envía el audio al bot de Telegram
|
||||||
|
|
||||||
|
3. En ~10-15 segundos deberías recibir:
|
||||||
|
```
|
||||||
|
✅ Evento creado en tu calendario
|
||||||
|
📅 Reunión con Federico
|
||||||
|
🗓 2026-07-07
|
||||||
|
🕐 15:00 – 16:00
|
||||||
|
📍 Puerto de Miami
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Verifica en Nextcloud Calendar que el evento aparece
|
||||||
|
|
||||||
|
5. Espera el recordatorio 30 min antes ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist de Instalación
|
||||||
|
|
||||||
|
### En tu PC (Windows)
|
||||||
|
- [ ] Instalar Tailscale → anotar IP (`100.x.x.x`)
|
||||||
|
- [ ] `pip install faster-whisper fastapi uvicorn python-multipart`
|
||||||
|
- [ ] Crear `C:\whisper-api\server.py` (código arriba)
|
||||||
|
- [ ] Crear `C:\whisper-api\start.bat`
|
||||||
|
- [ ] Agregar `start.bat` al inicio de Windows
|
||||||
|
- [ ] Iniciar la API y verificar: `curl http://localhost:8765/health`
|
||||||
|
- [ ] Configurar Ollama para escuchar en `0.0.0.0`: variable `OLLAMA_HOST`
|
||||||
|
|
||||||
|
### En el RPi4 (via SSH)
|
||||||
|
- [ ] `curl -fsSL https://tailscale.com/install.sh | sh`
|
||||||
|
- [ ] `sudo tailscale up` → autenticar con la misma cuenta
|
||||||
|
- [ ] Verificar ping a PC: `tailscale ping 100.x.x.x`
|
||||||
|
- [ ] Verificar Whisper: `curl http://100.x.x.x:8765/health`
|
||||||
|
- [ ] Verificar Ollama: `curl http://100.x.x.x:11434/api/tags`
|
||||||
|
|
||||||
|
### En Telegram
|
||||||
|
- [ ] Crear bot con @BotFather → guardar token
|
||||||
|
- [ ] Obtener tu Chat ID
|
||||||
|
- [ ] Escribir un mensaje al bot para activarlo
|
||||||
|
|
||||||
|
### En Nextcloud
|
||||||
|
- [ ] Copiar URL CalDAV del calendario
|
||||||
|
- [ ] Crear contraseña de aplicación para n8n
|
||||||
|
|
||||||
|
### En n8n
|
||||||
|
- [ ] Crear carpetas: Prisa Yachts / Personal / AIS Navigator / Sandbox
|
||||||
|
- [ ] Crear variables de entorno (8 variables)
|
||||||
|
- [ ] Crear workflow `🎙️ Voz → Calendario Nextcloud`
|
||||||
|
- [ ] Agregar los 10 nodos en orden
|
||||||
|
- [ ] Activar el workflow
|
||||||
|
- [ ] Prueba con audio real
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Solución de Problemas
|
||||||
|
|
||||||
|
| Problema | Causa probable | Solución |
|
||||||
|
|---------|---------------|---------|
|
||||||
|
| n8n no llega a Whisper | Tailscale no conectado | `sudo tailscale up` en RPi4 |
|
||||||
|
| Whisper tarda mucho | Modelo "medium" pesado en CPU | Cambiar a "small" en server.py |
|
||||||
|
| Ollama no responde | OLLAMA_HOST no configurado | Reiniciar Ollama tras la variable |
|
||||||
|
| Evento en fecha incorrecta | Ollama interpretó mal el día | Ajustar el prompt con más contexto |
|
||||||
|
| CalDAV 401 Unauthorized | Contraseña de app incorrecta | Crear nueva contraseña en Nextcloud |
|
||||||
|
| Audio no se procesa | Formato no soportado | Telegram envía .ogg — faster-whisper lo soporta |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mejoras Futuras (Fase 2)
|
||||||
|
|
||||||
|
- [ ] **Cancelar eventos por voz**: "Cancela la reunión del martes" → n8n borra el evento
|
||||||
|
- [ ] **Consultar agenda**: "¿Qué tengo mañana?" → n8n lee el CalDAV y responde
|
||||||
|
- [ ] **Recordatorio personalizable**: "Recuérdame 1 hora antes"
|
||||||
|
- [ ] **Múltiples calendarios**: personal, trabajo, AIS Navigator
|
||||||
|
- [ ] **Texto además de voz**: si escribes (no mandas audio), también funciona
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Blueprint generado: 2026-07-03 | AR Electronics / Álvaro*
|
||||||
Reference in New Issue
Block a user