Export Granola Transcripts
Export meeting transcripts from local Granola app to markdown files
Source Code
import fs from 'fs';
import https from 'https';
import zlib from 'zlib';
import path from 'path';
import os from 'os';
import { execSync } from 'child_process';
const [outputDir] = process.argv.slice(2);
const CREDS_PATH = path.join(os.homedir(), 'Library/Application Support/Granola/supabase.json');
const HEADERS = {
'Content-Type': 'application/json',
'Accept': '*/*',
'Accept-Encoding': 'gzip, deflate',
'User-Agent': 'Granola/5.354.0',
'X-Client-Version': '5.354.0'
};
async function request(hostname, reqPath, body, token) {
const postData = JSON.stringify(body);
const options = {
hostname,
port: 443,
path: reqPath,
method: 'POST',
headers: {
...HEADERS,
'Authorization': `Bearer ${token}`,
'Content-Length': Buffer.byteLength(postData)
}
};
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
let stream = res;
if (res.headers['content-encoding'] === 'gzip') {
stream = res.pipe(zlib.createGunzip());
}
const chunks = [];
stream.on('data', chunk => chunks.push(chunk));
stream.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
resolve({ status: res.statusCode, body: body ? JSON.parse(body) : null });
});
stream.on('error', reject);
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
function prosemirrorToMarkdown(node, depth = 0) {
if (!node) return '';
if (node.type === 'text') return node.text || '';
const children = (node.content || []).map(c => prosemirrorToMarkdown(c, depth)).join('');
switch (node.type) {
case 'heading':
return '#'.repeat(node.attrs?.level || 1) + ' ' + children + '\n\n';
case 'paragraph':
return children + '\n\n';
case 'bulletList':
return (node.content || []).map(item => {
const text = prosemirrorToMarkdown(item, depth + 1).trim();
return '- ' + text;
}).join('\n') + '\n\n';
case 'orderedList':
return (node.content || []).map((item, i) => {
const text = prosemirrorToMarkdown(item, depth + 1).trim();
return `${i + 1}. ` + text;
}).join('\n') + '\n\n';
case 'listItem':
return children;
case 'doc':
return children;
default:
return children;
}
}
function formatTranscript(segments) {
return segments.map(seg => {
const time = new Date(seg.start_timestamp).toISOString().slice(11, 19);
const speaker = seg.source === 'assemblyai' ? '' : `${seg.source}: `;
return `**[${time}]** ${speaker}${seg.text}`;
}).join('\n\n');
}
async function main() {
if (!outputDir) {
console.error('Error: outputDir is required');
console.error('Example: node granola-export.mjs ~/Desktop/granola-exports');
process.exit(1);
}
// Load credentials
if (!fs.existsSync(CREDS_PATH)) {
console.error('Granola credentials not found. Make sure Granola is installed and you are logged in.');
process.exit(1);
}
const creds = JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8'));
const workosTokens = JSON.parse(creds.workos_tokens);
let accessToken = workosTokens.access_token;
// Create output directory
fs.mkdirSync(outputDir, { recursive: true });
// Load sync state
const statePath = path.join(outputDir, '.granola-sync-state.json');
let lastSync = null;
if (fs.existsSync(statePath)) {
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
lastSync = state.lastSync;
console.log(`Incremental sync from ${lastSync}`);
}
// Fetch documents
console.log('Fetching documents...');
const docsRes = await request('api.granola.ai', '/v2/get-documents', {
limit: 100,
offset: 0,
include_last_viewed_panel: true
}, accessToken);
if (docsRes.status !== 200) {
console.error('Failed to fetch documents:', docsRes.status);
process.exit(1);
}
let docs = docsRes.body.docs || [];
// Filter to new/updated only
if (lastSync) {
docs = docs.filter(d => new Date(d.updated_at) > new Date(lastSync));
}
console.log(`Processing ${docs.length} documents...`);
let latestUpdate = lastSync;
for (const doc of docs) {
const title = doc.title || 'Untitled';
const date = doc.created_at?.slice(0, 10) || 'unknown';
const filename = `${date} - ${title.replace(/[<>:"/\\|?*]/g, '_')}.md`;
// Get attendees
const attendees = doc.people?.attendees?.map(a =>
a.details?.person?.name?.fullName || a.email
).filter(Boolean) || [];
// Convert notes
let notes = '';
if (doc.last_viewed_panel?.content) {
notes = prosemirrorToMarkdown(doc.last_viewed_panel.content).trim();
}
// Fetch transcript
let transcript = '';
try {
const transcriptRes = await request('api.granola.ai', '/v1/get-document-transcript', {
document_id: doc.id
}, accessToken);
if (transcriptRes.status === 200 && Array.isArray(transcriptRes.body)) {
transcript = formatTranscript(transcriptRes.body);
}
} catch (e) {
// No transcript available
}
// Build markdown
const frontmatter = [
'---',
`granola_id: ${doc.id}`,
`title: "${title.replace(/"/g, '\\"')}"`,
`created_at: ${doc.created_at}`,
`updated_at: ${doc.updated_at}`,
`attendees: [${attendees.map(a => `"${a}"`).join(', ')}]`,
`type: transcript`,
'---'
].join('\n');
let content = `${frontmatter}\n\n# ${title}\n\n`;
if (notes) {
content += `## Notes\n\n${notes}\n\n`;
}
if (transcript) {
content += `## Transcript\n\n${transcript}\n`;
}
fs.writeFileSync(path.join(outputDir, filename), content);
console.log(` ✓ ${filename}`);
if (!latestUpdate || new Date(doc.updated_at) > new Date(latestUpdate)) {
latestUpdate = doc.updated_at;
}
}
// Save sync state
fs.writeFileSync(statePath, JSON.stringify({ lastSync: latestUpdate }, null, 2));
// Open Finder to the export directory (macOS)
try {
execSync(`open "${outputDir}"`);
console.log(`\nDone! ${docs.length} transcripts exported.`);
console.log('Drag the files into Sauna to upload them.');
} catch {
console.log(`\nDone! ${docs.length} transcripts exported to ${outputDir}`);
console.log('Open that folder and drag the files into Sauna.');
}
}
main().catch(console.error);