feat: n8n initial commit — JavaScript (Node.js) n8n workflow automation + axios/cheerio/puppeteer/xlsx

This commit is contained in:
2026-07-03 12:15:42 -04:00
commit f4731fa87e
11 changed files with 3561 additions and 0 deletions
+33
View File
@@ -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
@@ -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 17 / Responde 17\`;
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); });
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); });
+518
View File
@@ -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*