/**
* 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> = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
''': "'",
' ': ' ',
};
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);
}
}