Skip to main content
Glama
granola-client.ts12.5 kB
/** * Granola API Client for Cloudflare Workers * Reverse-engineered from Granola Electron app v6.267.0 */ /** * Cache interface for storing document summaries * In Cloudflare Workers, this would be implemented with KV */ export interface SummaryCache { get(documentId: string): Promise<MeetingSummary | null>; set(documentId: string, summary: MeetingSummary, ttlSeconds?: number): Promise<void>; } export interface GranolaTokens { access_token: string; refresh_token: string; expires_at: number; provider: string; } export interface GranolaDocument { updated_at: string; owner: boolean; } export interface GranolaDocumentSet { documents: Record<string, GranolaDocument>; } export interface GranolaDocumentMetadata { id: string; title: string; created_at: string; updated_at: string; owner: boolean; shared_with?: string[]; } export interface MeetingSummary { title: string; description: string; } export interface CompleteMeeting { id: string; title: string; updated_at: string; owner: boolean; summary?: MeetingSummary; } const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Granola/6.267.0 Chrome/136.0.7103.177 Electron/36.9.3 Safari/537.36'; export class GranolaClient { private apiUrl: string; private notesUrl: string; private tokens: GranolaTokens; private refreshUrl: string | null = null; private onTokenRefresh: ((tokens: GranolaTokens) => Promise<void>) | null = null; private cache: SummaryCache | null = null; constructor(apiUrl: string, notesUrl: string, tokens: GranolaTokens) { this.apiUrl = apiUrl; this.notesUrl = notesUrl; this.tokens = tokens; } /** * Set cache for document summaries to speed up repeated requests */ setCache(cache: SummaryCache): void { this.cache = cache; } /** * Configure auto-refresh settings for handling 401 errors during API calls */ setAutoRefresh(refreshUrl: string, onTokenRefresh: (tokens: GranolaTokens) => Promise<void>): void { this.refreshUrl = refreshUrl; this.onTokenRefresh = onTokenRefresh; } /** * Get current tokens (for external access/storage) */ getTokens(): GranolaTokens { return this.tokens; } /** * Check if access token is expired */ isTokenExpired(): boolean { const bufferSeconds = 300; // 5 minute buffer return Date.now() >= this.tokens.expires_at - bufferSeconds * 1000; } /** * Refresh access token */ async refreshToken(refreshUrl: string): Promise<GranolaTokens> { const response = await fetch(refreshUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT, 'X-Client-Version': '6.267.0', 'X-Platform': 'macos', }, body: JSON.stringify({ refresh_token: this.tokens.refresh_token, }), }); if (!response.ok) { const errorText = await response.text().catch(() => response.statusText); throw new Error(`Failed to refresh token: ${errorText}`); } const data = await response.json() as any; // Validate response has required fields if (!data.access_token) { throw new Error('Refresh response missing access_token'); } // Default to 6 hours if expires_in not provided const expiresInSeconds = Number(data.expires_in) || 21600; this.tokens = { access_token: data.access_token, refresh_token: data.refresh_token || this.tokens.refresh_token, expires_at: Date.now() + (expiresInSeconds * 1000), provider: this.tokens.provider, }; return this.tokens; } /** * Make authenticated API request with automatic retry on 401 */ private async makeRequest(endpoint: string, body?: any, isRetry: boolean = false): Promise<any> { const headers = { 'Authorization': `Bearer ${this.tokens.access_token}`, 'Content-Type': 'application/json', 'User-Agent': USER_AGENT, 'X-Client-Version': '6.267.0', 'X-Platform': 'macos', 'Accept': '*/*', }; const response = await fetch(`${this.apiUrl}${endpoint}`, { method: 'POST', headers, body: body ? JSON.stringify(body) : undefined, }); // Handle 401 with auto-refresh retry (only once to prevent infinite loops) if (response.status === 401 && !isRetry && this.refreshUrl) { try { await this.refreshToken(this.refreshUrl); // Notify callback to persist new tokens to KV if (this.onTokenRefresh) { await this.onTokenRefresh(this.tokens); } // Retry the request with new token return this.makeRequest(endpoint, body, true); } catch (refreshError) { throw new Error(`Token refresh failed: ${refreshError instanceof Error ? refreshError.message : String(refreshError)}`); } } if (!response.ok) { throw new Error(`API request failed: ${response.statusText}`); } return response.json(); } /** * Get all document IDs (fast endpoint) */ async getDocumentSet(): Promise<GranolaDocumentSet> { return this.makeRequest('/v1/get-document-set'); } /** * Get documents with optional list filtering */ async getDocuments(listId?: string): Promise<any> { const body = listId ? { list_id: listId } : {}; return this.makeRequest('/v2/get-documents', body); } /** * Get all document lists/folders */ async getDocumentLists(): Promise<any> { return this.makeRequest('/v1/get-document-lists'); } /** * Get metadata for a specific document */ async getDocumentMetadata(documentId: string): Promise<any> { return this.makeRequest('/v1/get-document-metadata', { document_id: documentId }); } /** * Extract meeting summary from HTML page (with caching support) */ async getDocumentSummaryFromHTML(documentId: string): Promise<MeetingSummary> { // Check cache first if (this.cache) { const cached = await this.cache.get(documentId); if (cached) { return cached; } } const url = `${this.notesUrl}/d/${documentId}`; const response = await fetch(url, { headers: { 'User-Agent': USER_AGENT, }, }); if (!response.ok) { throw new Error(`Failed to fetch document HTML: ${response.statusText}`); } const html = await response.text(); const summary = this.extractSummaryFromHTML(html); // Store in cache (5 minute TTL) if (this.cache) { this.cache.set(documentId, summary, 300).catch(() => {}); } return summary; } /** * Get all meetings with summaries (parallelized for performance) */ async getAllMeetingsWithSummaries(limit: number = 20): Promise<CompleteMeeting[]> { const docSet = await this.getDocumentSet(); // Sort by updated_at descending const sortedDocs = Object.entries(docSet.documents) .sort(([, a], [, b]) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() ) .slice(0, limit); const meetingPromises = sortedDocs.map(async ([id, doc]) => { try { const summary = await this.getDocumentSummaryFromHTML(id); return { id, title: summary.title, updated_at: doc.updated_at, owner: doc.owner, summary, } as CompleteMeeting; } catch (error) { // If summary fetch fails, add meeting without summary return { id, title: 'Unknown', updated_at: doc.updated_at, owner: doc.owner, } as CompleteMeeting; } }); return Promise.all(meetingPromises); } /** * Extract structured summary data from Granola Next.js HTML */ private extractSummaryFromHTML(html: string): MeetingSummary { // Title const titleMatch = html.match(/<title>(.*?)<\/title>/); const title = titleMatch ? titleMatch[1].replace(' - Granola', '').trim() : 'Untitled'; // Meta descriptions const ogMatch = html.match(/<meta\s+property="og:description"\s+content="(.*?)"\s*\/?>/); const metaDescMatch = html.match(/<meta\s+name="description"\s+content="(.*?)"\s*\/?>/); const ogDescription = ogMatch ? this.decodeHtmlEntities(ogMatch[1]) : ''; const fallbackDescription = metaDescMatch ? this.decodeHtmlEntities(metaDescMatch[1]) : ''; // self.__next_f.push chunks const nextFPushRegex = /self\.__next_f\.push\(\[(.*?)\]\)/gs; let fullHtmlContent: string | null = null; let match: RegExpExecArray | null; while ((match = nextFPushRegex.exec(html)) !== null) { const chunk = match[1]; try { const parsed = JSON.parse(`[${chunk}]`); // Look for string content that contains HTML markup for (const item of parsed) { if (typeof item === 'string' && /<h\d|<li|<p|<section/i.test(item)) { if (!fullHtmlContent || item.length > (fullHtmlContent?.length ?? 0)) { fullHtmlContent = item; } } } } catch { // Ignore parse errors for push chunks } } let textContent = ogDescription; if (fullHtmlContent) { const cleaned = this.cleanHtmlToText(fullHtmlContent); if (cleaned.trim().length > 0) { textContent = cleaned; } } const finalDescription = textContent || fallbackDescription || 'Summary not available.'; return { title, description: finalDescription, }; } /** * Convert HTML content into readable text */ private cleanHtmlToText(html: string): string { let text = html; text = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ''); text = text.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ''); text = text.replace(/<br\s*\/?>/gi, '\n'); text = text.replace(/<\/p>/gi, '\n\n'); text = text.replace(/<li[^>]*>/gi, '\n- '); text = text.replace(/<\/li>/gi, ''); text = text.replace(/<\/(ul|ol)>/gi, '\n'); text = this.decodeHtmlEntities(text); text = text.replace(/<[^>]+>/g, ''); text = text.replace(/\r?\n\s*\r?\n\s*\r?\n+/g, '\n\n'); text = text .split('\n') .map((line) => line.trim()) .filter((line) => line.length > 0) .join('\n'); return text.trim(); } /** * Search meetings by keyword */ async searchMeetings(keyword: string, limit: number = 20): Promise<CompleteMeeting[]> { const allMeetings = await this.getAllMeetingsWithSummaries(limit * 2); // Fetch more to filter const lowerKeyword = keyword.toLowerCase(); const filtered = allMeetings.filter(meeting => meeting.title.toLowerCase().includes(lowerKeyword) || (meeting.summary?.description?.toLowerCase().includes(lowerKeyword) ?? false) ); return filtered.slice(0, limit); } /** * Get recent meetings from last N days (parallelized for performance) */ async getRecentMeetings(days: number = 7, limit: number = 20): Promise<CompleteMeeting[]> { const docSet = await this.getDocumentSet(); const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - days); const recentDocs = Object.entries(docSet.documents) .filter(([, doc]) => new Date(doc.updated_at) >= cutoffDate) .sort(([, a], [, b]) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() ) .slice(0, limit); const meetingPromises = recentDocs.map(async ([id, doc]) => { try { const summary = await this.getDocumentSummaryFromHTML(id); return { id, title: summary.title, updated_at: doc.updated_at, owner: doc.owner, summary, } as CompleteMeeting; } catch (error) { return { id, title: 'Unknown', updated_at: doc.updated_at, owner: doc.owner, } as CompleteMeeting; } }); return Promise.all(meetingPromises); } /** * Decode HTML entities */ private decodeHtmlEntities(text: string): string { const entities: Record<string, string> = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&nbsp;': ' ', }; return text .replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(Number(dec))) .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))) .replace(/&[#\w]+;/g, match => entities[match] || match); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/pavitarsaini/granola-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server