Integrations
Google Sheets
Paste one script into a spreadsheet and fill a transcript column beside your Reel links. Works on a handful of rows or on thousands.
You keep your Reel links in a sheet. This puts the words next to them, in the same sheet, without exporting anything.
There are two ways in. A custom function is one cell and one formula, good for a short list. The menu runner walks the whole sheet and survives Google's execution limits, which is what thousands of rows need.
Both need an API key. Sign in, open your account page, and make one. New accounts come with 10 credits, so the first few rows cost nothing.
Put the key where the sheet cannot leak it
Open your spreadsheet and go to Extensions, then Apps Script.
In the editor, open Project Settings on the left. Under Script properties, add one:
| Property | Value |
|---|---|
ITT_API_KEY |
your key |
A key in a cell is a key that travels with the file. Anyone you share the sheet with can read it, and a copy of the sheet copies your key with it. A script property stays with the script.
The script
Back in the Editor, delete whatever is in Code.gs and paste this in. Save it.
const ENDPOINT = 'https://instagramtotranscript.com/v1/transcripts';
/** Columns the runner adds. They go on the end, in this order. */
const OUTPUT = ['Transcript', 'Status', 'Duration (s)', 'Language'];
/**
* Apps Script stops a function after six minutes. Stopping ourselves at four
* and a half leaves room to write the last row and book the next run, so a
* long sheet carries on instead of dying half way down.
*/
const BUDGET_MS = 4.5 * 60 * 1000;
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('Transcripts')
.addItem('Transcribe this sheet', 'runSheet')
.addItem('Stop', 'stopSheet')
.addToUi();
}
function key_() {
const key = PropertiesService.getScriptProperties().getProperty('ITT_API_KEY');
if (!key) {
throw new Error('Add ITT_API_KEY under Project Settings, Script properties.');
}
return key;
}
/** One link. Returns words, or a reason there are none. */
function fetchOne_(url) {
const response = UrlFetchApp.fetch(ENDPOINT, {
method: 'post',
contentType: 'application/json',
headers: { Authorization: 'Bearer ' + key_() },
payload: JSON.stringify({ url: url }),
muteHttpExceptions: true,
});
let body;
try {
body = JSON.parse(response.getContentText());
} catch (error) {
return { ok: false, status: 'bad response' };
}
if (body.ok) {
return {
ok: true,
transcript: body.transcript || '',
duration: body.durationSec || '',
language: body.language || '',
};
}
return { ok: false, status: body.code + ': ' + body.message };
}
/** True for the addresses the API will accept. */
function isReel_(value) {
return /^https?:\/\/(www\.|m\.)?instagram\.com\/(reel|reels|p|tv|share)\//i.test(
String(value).trim()
);
}
/** Finds the link column by counting addresses, not by reading headers. */
function linkColumn_(rows) {
let best = -1;
let bestHits = 0;
for (let column = 0; column < rows[0].length; column++) {
let hits = 0;
for (let row = 1; row < rows.length; row++) {
if (isReel_(rows[row][column])) hits++;
}
if (hits > bestHits) {
best = column;
bestHits = hits;
}
}
if (best === -1) throw new Error('No Instagram links found on this sheet.');
return best;
}
/** Adds the output headers if they are not there yet, and returns their column. */
function outputColumn_(sheet, header) {
const at = header.indexOf(OUTPUT[0]);
if (at !== -1) return at;
const start = header.length;
sheet.getRange(1, start + 1, 1, OUTPUT.length).setValues([OUTPUT]);
return start;
}
function runSheet() {
const started = Date.now();
const sheet = SpreadsheetApp.getActiveSheet();
const rows = sheet.getDataRange().getValues();
if (rows.length < 2) return;
const link = linkColumn_(rows);
const out = outputColumn_(sheet, rows[0]);
let done = 0;
for (let row = 1; row < rows.length; row++) {
if (Date.now() - started > BUDGET_MS) {
scheduleRest_();
SpreadsheetApp.getActiveSpreadsheet().toast(
done + ' done. Carrying on in a minute.',
'Transcripts',
5
);
return;
}
const url = String(rows[row][link] || '').trim();
if (!isReel_(url)) continue;
// Already has words, or already has a reason. Either way, leave it alone
// so a second run only picks up what is still empty.
if (String(rows[row][out] || '').trim() !== '') continue;
if (String(rows[row][out + 1] || '').trim() !== '') continue;
const result = fetchOne_(url);
sheet
.getRange(row + 1, out + 1, 1, OUTPUT.length)
.setValues([
result.ok
? [result.transcript, 'done', result.duration, result.language]
: ['', result.status, '', ''],
]);
// Written before the next call, so a run that is cut off short still
// leaves the sheet holding everything it paid for.
SpreadsheetApp.flush();
done++;
}
clearTriggers_();
SpreadsheetApp.getActiveSpreadsheet().toast(done + ' rows filled.', 'Transcripts', 5);
}
function scheduleRest_() {
clearTriggers_();
ScriptApp.newTrigger('runSheet').timeBased().after(60 * 1000).create();
}
function clearTriggers_() {
ScriptApp.getProjectTriggers().forEach(function (trigger) {
if (trigger.getHandlerFunction() === 'runSheet') ScriptApp.deleteTrigger(trigger);
});
}
function stopSheet() {
clearTriggers_();
SpreadsheetApp.getActiveSpreadsheet().toast('Stopped.', 'Transcripts', 5);
}
/**
* A formula, for a short list. Google stops a custom function after thirty
* seconds, so a long Reel will time out here and want the menu instead.
*
* @param {string} url A public Instagram link.
* @return {string} The spoken words.
* @customfunction
*/
function TRANSCRIBE(url) {
if (!url) return '';
if (!isReel_(url)) return 'not an Instagram link';
const result = fetchOne_(url);
return result.ok ? result.transcript : result.status;
}
Run it
Reload the spreadsheet. A Transcripts menu appears next to Help.
Pick Transcribe this sheet. Google asks for permission the first time, because the script reaches out to another site. Allow it.
Rows fill in from the top. The tab can be closed once it starts, because the work runs on Google's servers rather than in your browser. Come back later and read the column.
Or write a formula
For a few links, skip the menu:
=TRANSCRIBE(A2)
Drag it down the column. Two things to know. Google stops a custom function after thirty seconds, so a Reel longer than about two minutes will time out and the cell will show an error. And a formula recalculates, which means it can spend a credit twice on the same row. The menu runner does neither, so it is the better tool for anything more than a handful.
Thousands of rows
The runner is built for this. Four and a half minutes of work, a minute's pause, then it picks up where it stopped, for as long as it takes. Nothing has to stay open.
Three things are worth setting up first.
Split the file by sheet. Ten thousand rows on one tab makes every write slow, because getDataRange reads the lot each time. Two thousand rows a tab is comfortable.
Check your balance before you start. One credit is one minute of audio, rounded up. Two thousand Reels averaging forty seconds is about two thousand credits. Run out half way and the rest of the column fills with INSUFFICIENT_CREDITS rather than words, which costs nothing but wastes a pass.
Leave the failures alone. A private post will be private tomorrow as well. The runner skips any row that already has something in the Status column, so a second pass only retries the rows that are still blank.
When a row says something other than done
The Status column carries the code and the sentence. Two of them turn up in normal use.
PRIVATE means the post needs a login. There is nothing to retry.
RATE_LIMITED means slow down. A key allows six hundred calls an hour. Run Transcribe this sheet again in an hour and it carries on from the same place.
The error reference has the rest.
Excel and other spreadsheets
Apps Script is Google only. For Excel, either use the bulk page, which reads .xlsx in your browser and gives you a CSV back, or call the API from Power Query or a script of your own.