|
#!/usr/bin/env node |
|
|
|
// Required parameters: |
|
// @raycast.schemaVersion 1 |
|
// @raycast.title Get my available time slots |
|
// @raycast.mode fullOutput |
|
|
|
// Optional parameters: |
|
// @raycast.icon π
|
|
// @raycast.packageName Calendar Tools |
|
// @raycast.argument1 { "type": "text", "placeholder": "Working days (default 7)", "optional": true } |
|
// @raycast.argument2 { "type": "dropdown", "placeholder": "Timezone", "optional": true, "data": [{"title": "New York (US/12hr)", "value": "New York"}, {"title": "Los Angeles (US/12hr)", "value": "Los Angeles"}, {"title": "Chicago (US/12hr)", "value": "Chicago"}, {"title": "Denver (US/12hr)", "value": "Denver"}, {"title": "Toronto (12hr)", "value": "Toronto"}, {"title": "Mexico City (12hr)", "value": "Mexico City"}, {"title": "SΓ£o Paulo (24hr)", "value": "Sao Paulo"}, {"title": "Buenos Aires (24hr)", "value": "Buenos Aires"}, {"title": "London (24hr)", "value": "London"}, {"title": "Paris (24hr)", "value": "Paris"}, {"title": "Berlin (24hr)", "value": "Berlin"}, {"title": "Madrid (24hr)", "value": "Madrid"}, {"title": "Rome (24hr)", "value": "Rome"}, {"title": "Amsterdam (24hr)", "value": "Amsterdam"}, {"title": "Zurich (24hr)", "value": "Zurich"}, {"title": "Stockholm (24hr)", "value": "Stockholm"}, {"title": "Moscow (24hr)", "value": "Moscow"}, {"title": "Dubai (12hr)", "value": "Dubai"}, {"title": "Istanbul (24hr)", "value": "Istanbul"}, {"title": "Mumbai (12hr)", "value": "Mumbai"}, {"title": "Delhi (12hr)", "value": "Delhi"}, {"title": "Singapore (12hr)", "value": "Singapore"}, {"title": "Hong Kong (12hr)", "value": "Hong Kong"}, {"title": "Tokyo (24hr)", "value": "Tokyo"}, {"title": "Shanghai (12hr)", "value": "Shanghai"}, {"title": "Seoul (12hr)", "value": "Seoul"}, {"title": "Sydney (12hr)", "value": "Sydney"}, {"title": "Melbourne (12hr)", "value": "Melbourne"}, {"title": "Auckland (12hr)", "value": "Auckland"}] } |
|
|
|
// Documentation: |
|
// @raycast.description Fetch available meeting slots from Fantastical |
|
|
|
const fs = require("fs"); |
|
const path = require("path"); |
|
|
|
const SCRIPT_NAME = "my-available-time-slots"; |
|
const LOG_DIR = path.join(process.cwd(), "logs", SCRIPT_NAME); |
|
const LOG_FILE = path.join(LOG_DIR, "debug.log"); |
|
|
|
// !!! REPLACE WITH YOUR FANTASTICAL SCHEDULING PAGE URL !!! |
|
const FANTASTICAL_URL = |
|
"https://fantastical.app/YOUR-USERNAME/YOUR-MEETING-URL"; |
|
|
|
// !!! REPLACE WITH YOUR BROWSERBASE CREDENTIALS !!! |
|
// Get these from https://www.browserbase.com/ |
|
const BROWSERBASE_PROJECT_ID = "YOUR-PROJECT-ID"; |
|
const BROWSERBASE_API_KEY = "YOUR-API-KEY"; |
|
|
|
// Timezone format mapping (24hr vs 12hr) |
|
const TIMEZONE_24HR = [ |
|
"Amsterdam", |
|
"Berlin", |
|
"Paris", |
|
"London", |
|
"Madrid", |
|
"Rome", |
|
"Zurich", |
|
"Stockholm", |
|
"Moscow", |
|
"Istanbul", |
|
"Tokyo", |
|
"Sao Paulo", |
|
"Buenos Aires", |
|
]; |
|
|
|
function uses24HourFormat(timezone) { |
|
return TIMEZONE_24HR.some((tz) => timezone.includes(tz)); |
|
} |
|
|
|
// Color codes |
|
const color = { |
|
reset: "\x1b[0m", |
|
bold: "\x1b[1m", |
|
cyan: "\x1b[36m", |
|
green: "\x1b[32m", |
|
yellow: "\x1b[33m", |
|
red: "\x1b[31m", |
|
bgGreen: "\x1b[42m", |
|
white: "\x1b[97m", |
|
}; |
|
|
|
// Setup logging |
|
try { |
|
if (!fs.existsSync(LOG_DIR)) { |
|
fs.mkdirSync(LOG_DIR, { recursive: true }); |
|
} |
|
if (fs.existsSync(LOG_FILE)) fs.unlinkSync(LOG_FILE); |
|
} catch (err) { |
|
// Silently fail |
|
} |
|
|
|
function logDebug(msg) { |
|
const timestamp = new Date().toISOString(); |
|
const logMsg = `[${timestamp}] ${msg}`; |
|
try { |
|
fs.appendFileSync(LOG_FILE, logMsg + "\n"); |
|
} catch (err) { |
|
// Silently fail |
|
} |
|
} |
|
|
|
// Calculate working days |
|
function getWorkingDaysDate(daysAhead) { |
|
const dates = []; |
|
let currentDate = new Date(); |
|
let count = 0; |
|
|
|
while (count < daysAhead) { |
|
currentDate.setDate(currentDate.getDate() + 1); |
|
const dayOfWeek = currentDate.getDay(); |
|
|
|
// Skip weekends (0 = Sunday, 6 = Saturday) |
|
if (dayOfWeek !== 0 && dayOfWeek !== 6) { |
|
dates.push(new Date(currentDate)); |
|
count++; |
|
} |
|
} |
|
|
|
return dates[dates.length - 1]; // Return the last date |
|
} |
|
|
|
async function createBrowserbaseSession() { |
|
logDebug("Creating Browserbase session..."); |
|
|
|
const response = await fetch("https://www.browserbase.com/v1/sessions", { |
|
method: "POST", |
|
headers: { |
|
"x-bb-api-key": BROWSERBASE_API_KEY, |
|
"Content-Type": "application/json", |
|
}, |
|
body: JSON.stringify({ |
|
projectId: BROWSERBASE_PROJECT_ID, |
|
}), |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorText = await response.text(); |
|
logDebug(`Failed to create session: ${response.status} ${errorText}`); |
|
throw new Error(`Failed to create Browserbase session: ${response.status}`); |
|
} |
|
|
|
const json = await response.json(); |
|
logDebug(`Session created: ${json.id}`); |
|
return json.id; |
|
} |
|
|
|
async function scrapeFantasticalSlots(sessionId, timezone, onProgress) { |
|
logDebug(`Connecting to Browserbase session ${sessionId}...`); |
|
|
|
// Use Playwright (same as Browserbase playground) |
|
const { chromium } = require("playwright-core"); |
|
|
|
const browser = await chromium.connectOverCDP( |
|
`wss://connect.browserbase.com?apiKey=${BROWSERBASE_API_KEY}&sessionId=${sessionId}` |
|
); |
|
|
|
logDebug("Browser connected"); |
|
|
|
try { |
|
const context = browser.contexts()[0]; |
|
const page = context.pages()[0] || (await context.newPage()); |
|
logDebug(`Navigating to ${FANTASTICAL_URL}...`); |
|
onProgress?.("π Loading Fantastical page..."); |
|
|
|
await page.goto(FANTASTICAL_URL, { |
|
waitUntil: "networkidle", |
|
timeout: 60000, |
|
}); |
|
logDebug("Page loaded"); |
|
|
|
// Wait for React app to fully hydrate |
|
await page.waitForSelector("#timezone-selector-dropdown", { |
|
timeout: 10000, |
|
}); |
|
logDebug("App hydrated"); |
|
onProgress?.("β
Page loaded"); |
|
|
|
// Get current timezone display |
|
let currentTimezone = "Default"; |
|
try { |
|
const tzText = await page.$eval( |
|
"#timezone-selector-dropdown span", |
|
(el) => el.textContent |
|
); |
|
currentTimezone = tzText; |
|
logDebug(`Current timezone: ${currentTimezone}`); |
|
} catch (err) { |
|
logDebug(`Could not read current timezone: ${err.message}`); |
|
} |
|
|
|
// If timezone is provided, change it |
|
if (timezone) { |
|
logDebug(`Attempting to change timezone to ${timezone}...`); |
|
onProgress?.(`β° Changing timezone to ${timezone}...`); |
|
|
|
try { |
|
// Click the timezone selector dropdown to open it |
|
await page.click("#timezone-selector-dropdown"); |
|
logDebug("Clicked timezone selector"); |
|
|
|
// Wait for dropdown menu to appear |
|
await page.waitForSelector(".dropdown-menu.show", { |
|
state: "visible", |
|
timeout: 3000, |
|
}); |
|
logDebug("Dropdown menu visible"); |
|
|
|
// Click "Search Time Zones..." button and wait for input to appear |
|
await page.evaluate(() => { |
|
const menuItems = document.querySelectorAll( |
|
'[data-sentry-component="TimezoneMenuDropdown"] li button' |
|
); |
|
for (const button of menuItems) { |
|
if (button.textContent.includes("Search Time Zones")) { |
|
button.click(); |
|
return; |
|
} |
|
} |
|
}); |
|
logDebug("Clicked 'Search Time Zones...'"); |
|
|
|
// Wait for search input and type timezone |
|
await page.waitForSelector('input[role="search"]', { |
|
state: "visible", |
|
timeout: 3000, |
|
}); |
|
await page.type('input[role="search"]', timezone, { delay: 30 }); |
|
logDebug(`Typed timezone: ${timezone}`); |
|
|
|
// Wait for timezone list to appear with results |
|
await page.waitForSelector("ul._timezonesList_18e06_5 li", { |
|
state: "visible", |
|
timeout: 2000, |
|
}); |
|
|
|
// Click the first result from the timezone list |
|
const timezoneSelected = await page.evaluate(() => { |
|
const list = document.querySelector("ul._timezonesList_18e06_5"); |
|
if (list) { |
|
const firstItem = list.querySelector("li"); |
|
if (firstItem) { |
|
const button = firstItem.querySelector("button"); |
|
if (button) { |
|
const timezoneText = button.textContent.trim(); |
|
button.click(); |
|
return timezoneText; |
|
} |
|
} |
|
} |
|
return null; |
|
}); |
|
|
|
if (timezoneSelected) { |
|
logDebug(`Selected timezone: ${timezoneSelected}`); |
|
currentTimezone = timezoneSelected; |
|
// Wait for calendar to re-render with new timezone |
|
await page.waitForLoadState("networkidle"); |
|
} else { |
|
logDebug(`Could not find timezone in search results for ${timezone}`); |
|
console.log( |
|
`${color.yellow}β Could not change timezone, using default${color.reset}` |
|
); |
|
} |
|
} catch (err) { |
|
logDebug(`Error changing timezone: ${err.message}`); |
|
console.log( |
|
`${color.yellow}β Could not change timezone, using default${color.reset}` |
|
); |
|
} |
|
} |
|
|
|
// Scrape available slots with pagination support |
|
logDebug("Scraping available slots..."); |
|
onProgress?.("π
Finding available dates..."); |
|
|
|
// Wait for the week strip to be fully loaded first |
|
try { |
|
await page.waitForSelector("div._weekStrip_del5y_5", { |
|
visible: true, |
|
timeout: 5000, |
|
}); |
|
logDebug("Week strip loaded"); |
|
} catch (err) { |
|
logDebug(`Week strip wait timeout: ${err.message}`); |
|
} |
|
|
|
const desiredDates = 20; // Look for up to 20 working dates |
|
let allSlots = []; |
|
let attempts = 0; |
|
const maxAttempts = 6; // Max 6 weeks to check |
|
|
|
while (allSlots.length < desiredDates && attempts < maxAttempts) { |
|
// Scrape time slots from the current week strip |
|
logDebug(`Scraping week strip (attempt ${attempts + 1})...`); |
|
|
|
const slotsWithTimes = await page.evaluate(() => { |
|
const results = []; |
|
|
|
// Find all date columns in the week strip |
|
const dateColumns = document.querySelectorAll( |
|
"div._weekStrip_del5y_5 div._date_del5y_11" |
|
); |
|
|
|
dateColumns.forEach((column) => { |
|
// Get the date info from header |
|
const dayText = column |
|
.querySelector("div._day_del5y_41") |
|
?.textContent.trim(); |
|
const weekdayText = column |
|
.querySelector("div._weekday_del5y_46") |
|
?.textContent.trim(); |
|
|
|
if (!dayText) return; |
|
|
|
// Find the slot list container |
|
const slotList = column.querySelector("div._date_1ep5f_5"); |
|
if (!slotList) return; |
|
|
|
// Get the full date from aria-label |
|
const ariaLabel = slotList.getAttribute("aria-label"); |
|
const dateMatch = ariaLabel |
|
? ariaLabel.match(/Meeting times for: (.+)/) |
|
: null; |
|
const fullDate = dateMatch |
|
? dateMatch[1] |
|
: `${weekdayText} ${dayText}`; |
|
|
|
// Get all available time slots (NOT disabled) |
|
const slotButtons = slotList.querySelectorAll( |
|
"button._slot_1ep5f_11" |
|
); |
|
const availableTimes = []; |
|
|
|
slotButtons.forEach((btn) => { |
|
// Skip disabled slots |
|
if (btn.hasAttribute("disabled")) return; |
|
|
|
const timeText = btn.textContent.trim(); |
|
if (timeText && /\d{1,2}:\d{2}/.test(timeText)) { |
|
availableTimes.push(timeText); |
|
} |
|
}); |
|
|
|
if (availableTimes.length > 0) { |
|
results.push({ |
|
date: fullDate, |
|
slots: availableTimes, |
|
}); |
|
} |
|
}); |
|
|
|
return results; |
|
}); |
|
|
|
// Add new slots (avoid duplicates by date) |
|
slotsWithTimes.forEach((slot) => { |
|
if (!allSlots.some((s) => s.date === slot.date)) { |
|
allSlots.push(slot); |
|
} |
|
}); |
|
|
|
logDebug( |
|
`Found ${allSlots.length} dates with slots so far (attempt ${ |
|
attempts + 1 |
|
})` |
|
); |
|
|
|
// If we need more dates, click "Go to next week" |
|
if (allSlots.length < desiredDates && attempts < maxAttempts - 1) { |
|
const clicked = await page.evaluate(() => { |
|
const nextButton = document.querySelector( |
|
'button[aria-label="Go to next week"]' |
|
); |
|
if (nextButton && !nextButton.disabled) { |
|
nextButton.click(); |
|
return true; |
|
} |
|
return false; |
|
}); |
|
|
|
if (clicked) { |
|
logDebug("Clicking 'Go to next week'..."); |
|
// Wait for week strip to update |
|
await page.waitForLoadState("networkidle"); |
|
} else { |
|
logDebug("No more weeks available (next button disabled)"); |
|
break; |
|
} |
|
} |
|
|
|
attempts++; |
|
} |
|
|
|
const slots = allSlots; |
|
logDebug(`Total: ${slots.length} dates with time slots`); |
|
|
|
// Get page HTML for debugging if no slots found |
|
if (slots.length === 0) { |
|
const html = await page.content(); |
|
const debugFile = path.join(LOG_DIR, "page.html"); |
|
fs.writeFileSync(debugFile, html); |
|
logDebug(`No slots found. Page HTML saved to ${debugFile}`); |
|
} |
|
|
|
await page.close(); |
|
await browser.close(); |
|
|
|
return { slots, timezone: currentTimezone }; |
|
} catch (err) { |
|
logDebug(`Error during scraping: ${err.message}`); |
|
await browser.close(); |
|
throw err; |
|
} |
|
} |
|
|
|
async function main() { |
|
console.log( |
|
`${color.bold}${color.cyan}π
FANTASTICAL SLOT CHECKER${color.reset}\n` |
|
); |
|
|
|
logDebug("=== SESSION STARTED ==="); |
|
logDebug(`Script: ${process.argv[1]}`); |
|
logDebug(`Working directory: ${process.cwd()}`); |
|
|
|
// Parse arguments |
|
const daysArg = process.argv[2] || "7"; |
|
const timezone = process.argv[3] || ""; |
|
|
|
const days = parseInt(daysArg, 10); |
|
if (isNaN(days) || days < 1 || days > 90) { |
|
console.error( |
|
`${color.red}Error: Days must be a number between 1 and 90${color.reset}` |
|
); |
|
logDebug(`Invalid days argument: ${daysArg}`); |
|
process.exit(1); |
|
} |
|
|
|
logDebug(`Arguments: days=${days}, timezone=${timezone || "(default)"}`); |
|
|
|
console.log(`${color.bold}Configuration:${color.reset}`); |
|
console.log(` URL: ${FANTASTICAL_URL}`); |
|
console.log(` Days: Next ${days} working days`); |
|
if (timezone) { |
|
console.log(` Timezone: ${timezone}`); |
|
} |
|
console.log(); |
|
|
|
// Check for required dependencies |
|
try { |
|
require("playwright-core"); |
|
} catch (err) { |
|
console.error( |
|
`${color.red}Error: playwright-core is not installed${color.reset}` |
|
); |
|
console.error( |
|
`${color.yellow}Run: npm install playwright-core${color.reset}` |
|
); |
|
logDebug("playwright-core not found"); |
|
process.exit(1); |
|
} |
|
|
|
try { |
|
console.log(`${color.cyan}β³ Creating browser session...${color.reset}`); |
|
const sessionId = await createBrowserbaseSession(); |
|
|
|
const result = await scrapeFantasticalSlots(sessionId, timezone, (msg) => |
|
console.log(`${color.cyan}${msg}${color.reset}`) |
|
); |
|
const slots = result.slots; |
|
const selectedTimezone = result.timezone; |
|
|
|
// Helper functions for formatting output |
|
function timeToMinutes(timeStr) { |
|
const match = timeStr.match(/(\d{1,2}):(\d{2})\s*(AM|PM)?/i); |
|
if (!match) return 0; |
|
|
|
let hours = parseInt(match[1]); |
|
const minutes = parseInt(match[2]); |
|
const period = match[3]; |
|
|
|
if (period) { |
|
if (period.toUpperCase() === "PM" && hours !== 12) hours += 12; |
|
if (period.toUpperCase() === "AM" && hours === 12) hours = 0; |
|
} |
|
|
|
return hours * 60 + minutes; |
|
} |
|
|
|
function groupTimeSlots(slots) { |
|
if (slots.length === 0) return []; |
|
|
|
const ranges = []; |
|
let rangeStart = slots[0]; |
|
let rangeEnd = slots[0]; |
|
|
|
for (let i = 1; i < slots.length; i++) { |
|
const prevMinutes = timeToMinutes(slots[i - 1]); |
|
const currMinutes = timeToMinutes(slots[i]); |
|
|
|
if (currMinutes - prevMinutes === 30) { |
|
rangeEnd = slots[i]; |
|
} else { |
|
ranges.push({ start: rangeStart, end: rangeEnd }); |
|
rangeStart = slots[i]; |
|
rangeEnd = slots[i]; |
|
} |
|
} |
|
|
|
ranges.push({ start: rangeStart, end: rangeEnd }); |
|
return ranges; |
|
} |
|
|
|
function addThirtyMinutes(timeStr, use24hr = false) { |
|
const minutes = timeToMinutes(timeStr); |
|
const newMinutes = minutes + 30; |
|
const hours = Math.floor(newMinutes / 60) % 24; |
|
const mins = newMinutes % 60; |
|
|
|
if (use24hr) { |
|
return `${hours.toString().padStart(2, "0")}:${mins |
|
.toString() |
|
.padStart(2, "0")}`; |
|
} else { |
|
const period = hours >= 12 ? "PM" : "AM"; |
|
const displayHours = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours; |
|
return `${displayHours}:${mins.toString().padStart(2, "0")} ${period}`; |
|
} |
|
} |
|
|
|
function convertTo24Hour(timeStr) { |
|
const match = timeStr.match(/(\d{1,2}):(\d{2})\s*(AM|PM)?/i); |
|
if (!match) return timeStr; |
|
|
|
let hours = parseInt(match[1]); |
|
const minutes = match[2]; |
|
const period = match[3]; |
|
|
|
if (period) { |
|
if (period.toUpperCase() === "PM" && hours !== 12) hours += 12; |
|
if (period.toUpperCase() === "AM" && hours === 12) hours = 0; |
|
} |
|
|
|
return `${hours.toString().padStart(2, "0")}:${minutes}`; |
|
} |
|
|
|
function formatDate(dateStr) { |
|
const match = dateStr.match(/(\w+), (\w+) (\d+), (\d+)/); |
|
if (!match) return dateStr; |
|
|
|
const [, weekday, month, day] = match; |
|
return `${weekday}, ${month} ${day}`; |
|
} |
|
|
|
console.log(); |
|
console.log( |
|
`${color.bold}${color.bgGreen}${color.white} AVAILABLE SLOTS ${color.reset}\n` |
|
); |
|
|
|
if (slots.length === 0) { |
|
console.log(`${color.yellow}No available slots found${color.reset}`); |
|
console.log( |
|
`${color.yellow}Check the logs at: ${LOG_FILE}${color.reset}` |
|
); |
|
} else { |
|
// Filter slots to only show requested number of working days |
|
const filteredSlots = slots.slice(0, days); |
|
|
|
// Extract timezone for display - handle "Amsterdam The Netherlands (GMT+2)" format |
|
const tzMatch = selectedTimezone.match(/^(.+?)\s*\(/); |
|
let tzDisplay = tzMatch ? tzMatch[1] : selectedTimezone.split("(")[0]; |
|
|
|
// Fix spacing issues like "AmsterdamThe Netherlands" -> "Amsterdam, The Netherlands" |
|
tzDisplay = tzDisplay.replace(/([a-z])([A-Z])/g, "$1, $2").trim(); |
|
|
|
// Determine if we should use 24hr format |
|
const use24hr = uses24HourFormat(selectedTimezone); |
|
|
|
// Build output string for clipboard |
|
let outputLines = []; |
|
|
|
filteredSlots.forEach((dateGroup, index) => { |
|
const formattedDate = formatDate(dateGroup.date); |
|
const header = `π ${formattedDate} (timezone: ${tzDisplay})`; |
|
console.log(`${color.bold}${header}${color.reset}`); |
|
outputLines.push(header); |
|
|
|
const ranges = groupTimeSlots(dateGroup.slots); |
|
ranges.forEach((range) => { |
|
let startTime = range.start; |
|
let endTime = addThirtyMinutes(range.end, use24hr); |
|
|
|
// Convert to 24hr if needed |
|
if (use24hr) { |
|
startTime = convertTo24Hour(startTime); |
|
} |
|
|
|
const timeRange = ` ${startTime} β ${endTime}`; |
|
console.log(timeRange); |
|
outputLines.push(timeRange); |
|
}); |
|
|
|
if (index < filteredSlots.length - 1) { |
|
console.log(); |
|
outputLines.push(""); |
|
} |
|
}); |
|
|
|
// Copy to clipboard (macOS) |
|
const clipboardText = outputLines.join("\n"); |
|
try { |
|
const { spawnSync } = require("child_process"); |
|
spawnSync("pbcopy", [], { |
|
input: clipboardText, |
|
encoding: "utf-8", |
|
env: { ...process.env, LC_ALL: "en_US.UTF-8" }, |
|
}); |
|
console.log(); |
|
console.log(`${color.green}π Copied to clipboard${color.reset}`); |
|
} catch (err) { |
|
logDebug(`Could not copy to clipboard: ${err.message}`); |
|
} |
|
} |
|
|
|
console.log(); |
|
console.log(`${color.green}β
Done${color.reset}`); |
|
|
|
logDebug("=== SESSION COMPLETED SUCCESSFULLY ==="); |
|
} catch (err) { |
|
console.error(); |
|
console.error(`${color.red}β Error: ${err.message}${color.reset}`); |
|
console.error(`${color.yellow}Check logs at: ${LOG_FILE}${color.reset}`); |
|
logDebug(`Error: ${err.message}`); |
|
logDebug(`Stack: ${err.stack}`); |
|
process.exit(1); |
|
} |
|
} |
|
|
|
main(); |