/** * Veriscore for Google Sheets * --------------------------- * Paste creator handles into a sheet and get Veriscore verdicts back in the * same rows, using your Veriscore API key. * * Setup (about 2 minutes): * Easiest: make a copy of the ready-made template (it already contains * this script): https://veriscore.regnor.systems/developers#google-sheets * * Or add it to any sheet yourself: * 1. Open your Google Sheet, then Extensions > Apps Script. * 2. Delete any code in the editor, paste this whole file, and click Save. * 3. Reload the spreadsheet. A "Veriscore" menu appears. * 4. Veriscore > Set API key. Paste a key from * https://veriscore.regnor.systems/dashboard/api-keys * 5. Veriscore > Set up this sheet, then fill in column A (handle) and * column B (platform: instagram, youtube, tiktok or twitter). * 6. Veriscore > Check handles. * * The script always works on a tab named "Creators" when one exists, so * running it from another tab (for example the template's "Start here" tab) * never treats instructions as handles. * * Why a menu and not a formula: spreadsheet formulas recalculate on their own, * which could start new paid checks without anyone asking. Checks here only run * when you choose them from the menu, after you confirm the credit cost. * * Your API key is stored in your own user properties for this script. Other * people with access to the spreadsheet cannot read it. */ var VERISCORE_API_BASE = 'https://veriscore-production.up.railway.app/api/v1'; var VERISCORE_APP_BASE = 'https://veriscore.regnor.systems'; var COL = { HANDLE: 1, PLATFORM: 2, VERDICT: 3, SCORE: 4, STATUS: 5, REPORT: 6, CHECKED: 7, ID: 8 }; var HEADERS = ['Handle', 'Platform', 'Verdict', 'Risk score', 'Status', 'Report', 'Checked at', 'Analysis ID']; var PLATFORMS = ['instagram', 'youtube', 'tiktok', 'twitter']; var CREDIT_COST = { instagram: 50, youtube: 35, tiktok: 50, twitter: 50 }; var TIER_COLOURS = { Legit: '#d1fae5', Monitor: '#fef3c7', Sketchy: '#ffedd5', Run: '#fee2e2' }; var STATUS = { RUNNING: 'Running', DONE: 'Done', FAILED: 'Failed, credits refunded', INVALID: 'Check the handle and platform', RATE_LIMITED: 'Rate limit reached, run again later', NO_CREDITS: 'Not enough credits' }; // Apps Script stops a run at 6 minutes; stay safely under it. var MAX_RUNTIME_MS = 5 * 60 * 1000; var POLL_INTERVAL_MS = 10 * 1000; var TARGET_SHEET_NAME = 'Creators'; // The tab the script reads and writes: "Creators" if it exists, otherwise // the tab you are on. function targetSheet_() { var ss = SpreadsheetApp.getActive(); return ss.getSheetByName(TARGET_SHEET_NAME) || ss.getActiveSheet(); } // ── Menu ───────────────────────────────────────────────────────────────────── function onOpen() { SpreadsheetApp.getUi() .createMenu('Veriscore') .addItem('Set API key', 'setApiKey') .addItem('Set up this sheet', 'setupSheet') .addSeparator() .addItem('Check handles', 'checkHandles') .addItem('Refresh pending results', 'refreshPending') .addSeparator() .addItem('Show credit balance', 'showBalance') .addItem('Turn on auto-refresh (every 5 minutes)', 'enableAutoRefresh') .addItem('Turn off auto-refresh', 'disableAutoRefresh') .addToUi(); } function setApiKey() { var ui = SpreadsheetApp.getUi(); var response = ui.prompt( 'Veriscore API key', 'Paste your key (it starts with vs_live_). Create one at ' + VERISCORE_APP_BASE + '/dashboard/api-keys', ui.ButtonSet.OK_CANCEL ); if (response.getSelectedButton() !== ui.Button.OK) return; var key = response.getResponseText().trim(); if (key.indexOf('vs_live_') !== 0) { ui.alert('That does not look like a Veriscore API key. Keys start with vs_live_.'); return; } var check = veriscoreRequest_('get', '/credits/balance', null, key); if (check.code !== 200) { ui.alert('That key was not accepted (' + check.code + '). Check it was copied in full and has not been revoked.'); return; } PropertiesService.getUserProperties().setProperty('VERISCORE_API_KEY', key); ui.alert('Key saved. Your balance is ' + check.body.balance + ' credits.'); } function setupSheet() { var sheet = targetSheet_(); sheet.activate(); sheet.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]).setFontWeight('bold'); sheet.setFrozenRows(1); var rule = SpreadsheetApp.newDataValidation() .requireValueInList(PLATFORMS, true) .setAllowInvalid(false) .build(); sheet.getRange(2, COL.PLATFORM, Math.max(sheet.getMaxRows() - 1, 1), 1).setDataValidation(rule); sheet.hideColumns(COL.ID); SpreadsheetApp.getActive().toast('Add handles in column A and platforms in column B, then use Veriscore > Check handles.', 'Veriscore', 8); } function showBalance() { var r = veriscoreRequest_('get', '/credits/balance', null); if (r.code === 200) { SpreadsheetApp.getUi().alert('Your Veriscore balance: ' + r.body.balance + ' credits.'); } else { SpreadsheetApp.getUi().alert(errorMessage_(r)); } } // ── Checking handles ───────────────────────────────────────────────────────── function checkHandles() { var ui = SpreadsheetApp.getUi(); if (!getApiKey_()) { ui.alert('Set your API key first: Veriscore > Set API key.'); return; } var started = Date.now(); var sheet = targetSheet_(); sheet.activate(); var rows = readRows_(sheet); var todo = rows.filter(function (row) { return row.handle && !row.id && row.status !== STATUS.INVALID; }); if (todo.length === 0) { ui.alert('Nothing new to check. Add handles in column A, or use Refresh pending results for checks already running.'); return; } var maxCredits = todo.reduce(function (sum, row) { return sum + (CREDIT_COST[row.platform] || 0); }, 0); var balance = veriscoreRequest_('get', '/credits/balance', null); if (balance.code !== 200) { ui.alert(errorMessage_(balance)); return; } var answer = ui.alert( 'Check ' + todo.length + ' handle' + (todo.length === 1 ? '' : 's') + '?', 'This uses up to ' + maxCredits + ' credits. Re-checking an account you checked in the last 4 days is free. ' + 'Your balance is ' + balance.body.balance + ' credits.', ui.ButtonSet.OK_CANCEL ); if (answer !== ui.Button.OK) return; var stopReason = null; for (var i = 0; i < todo.length; i++) { if (Date.now() - started > MAX_RUNTIME_MS / 2) { stopReason = 'time'; break; } var row = todo[i]; if (PLATFORMS.indexOf(row.platform) === -1) { writeStatus_(sheet, row.index, STATUS.INVALID); continue; } var r = veriscoreRequest_('post', '/analyses', { platform: row.platform, username: normaliseHandle_(row.handle) }); if (r.code === 200 || r.code === 201) { sheet.getRange(row.index, COL.ID).setValue(r.body.analysis_id); if (r.body.status === 'completed') { writeResult_(sheet, row.index, r.body.analysis_id); } else { writeStatus_(sheet, row.index, STATUS.RUNNING); } } else if (r.code === 429) { writeStatus_(sheet, row.index, STATUS.RATE_LIMITED); stopReason = 'rate'; break; } else if (r.code === 402) { writeStatus_(sheet, row.index, STATUS.NO_CREDITS); stopReason = 'credits'; break; } else if (r.code === 400 || r.code === 422) { writeStatus_(sheet, row.index, STATUS.INVALID); } else if (r.code === 401 || r.code === 403) { ui.alert(errorMessage_(r)); return; } else { writeStatus_(sheet, row.index, 'Error ' + r.code + ', try again'); } } SpreadsheetApp.flush(); var pending = pollUntilDone_(sheet, started); var message = pending === 0 ? 'All checks finished.' : pending + ' check(s) still running. Use Veriscore > Refresh pending results in a minute, or turn on auto-refresh.'; if (stopReason === 'rate') message += ' Paused at the hourly limit (30 new checks per hour). Run Check handles again later to continue.'; if (stopReason === 'credits') message += ' Stopped: not enough credits. Top up at ' + VERISCORE_APP_BASE + '/credits.'; if (stopReason === 'time') message += ' More handles are waiting. Run Check handles again to continue.'; ui.alert(message); } function refreshPending() { if (!getApiKey_()) { SpreadsheetApp.getUi().alert('Set your API key first: Veriscore > Set API key.'); return; } var pending = pollUntilDone_(targetSheet_(), Date.now()); SpreadsheetApp.getActive().toast(pending === 0 ? 'All checks finished.' : pending + ' still running.', 'Veriscore', 6); } // Called by the optional time-driven trigger. No dialogs: it may run unattended. function refreshPendingQuietly() { if (!getApiKey_()) return; var sheets = SpreadsheetApp.getActive().getSheets(); for (var i = 0; i < sheets.length; i++) { if (sheets[i].getRange(1, COL.ID).getValue() === HEADERS[COL.ID - 1]) { pollOnce_(sheets[i]); } } } function enableAutoRefresh() { disableAutoRefresh(); ScriptApp.newTrigger('refreshPendingQuietly').timeBased().everyMinutes(5).create(); SpreadsheetApp.getActive().toast('Running checks will update every 5 minutes.', 'Veriscore', 6); } function disableAutoRefresh() { ScriptApp.getProjectTriggers().forEach(function (t) { if (t.getHandlerFunction() === 'refreshPendingQuietly') ScriptApp.deleteTrigger(t); }); } // ── Polling ────────────────────────────────────────────────────────────────── function pollUntilDone_(sheet, started) { var pending = pollOnce_(sheet); while (pending > 0 && Date.now() - started + POLL_INTERVAL_MS < MAX_RUNTIME_MS) { Utilities.sleep(POLL_INTERVAL_MS); pending = pollOnce_(sheet); } return pending; } // Updates every running row once. Returns how many are still running. function pollOnce_(sheet) { var rows = readRows_(sheet); var pending = 0; rows.forEach(function (row) { if (!row.id || row.status !== STATUS.RUNNING) return; var r = veriscoreRequest_('get', '/analyses/' + row.id + '/status', null); if (r.code !== 200) { pending++; return; } if (r.body.status === 'completed') { writeResult_(sheet, row.index, row.id); } else if (r.body.status === 'failed') { writeStatus_(sheet, row.index, STATUS.FAILED); sheet.getRange(row.index, COL.CHECKED).setValue(new Date()); } else { pending++; } }); SpreadsheetApp.flush(); return pending; } function writeResult_(sheet, rowIndex, analysisId) { var r = veriscoreRequest_('get', '/analyses/' + analysisId, null); if (r.code !== 200) { writeStatus_(sheet, rowIndex, STATUS.RUNNING); return; } var tier = r.body.risk_tier || ''; sheet.getRange(rowIndex, COL.VERDICT).setValue(tier).setBackground(TIER_COLOURS[tier] || null); sheet.getRange(rowIndex, COL.SCORE).setValue(r.body.risk_score === null || r.body.risk_score === undefined ? '' : r.body.risk_score); writeStatus_(sheet, rowIndex, STATUS.DONE); sheet.getRange(rowIndex, COL.REPORT).setFormula('=HYPERLINK("' + VERISCORE_APP_BASE + '/report/' + analysisId + '", "Open report")'); sheet.getRange(rowIndex, COL.CHECKED).setValue(new Date()); } // ── Helpers ────────────────────────────────────────────────────────────────── function readRows_(sheet) { var last = sheet.getLastRow(); if (last < 2) return []; var values = sheet.getRange(2, 1, last - 1, HEADERS.length).getValues(); return values.map(function (v, i) { return { index: i + 2, handle: String(v[COL.HANDLE - 1] || '').trim(), platform: String(v[COL.PLATFORM - 1] || '').trim().toLowerCase(), status: String(v[COL.STATUS - 1] || ''), id: String(v[COL.ID - 1] || '').trim() }; }); } function writeStatus_(sheet, rowIndex, text) { sheet.getRange(rowIndex, COL.STATUS).setValue(text); } // Accepts "@name", "name", or a profile URL, and returns the bare handle. function normaliseHandle_(value) { var h = String(value).trim(); var urlMatch = h.match(/^https?:\/\/[^/]+\/(?:@)?([^/?#]+)/i); if (urlMatch) h = urlMatch[1]; return h.replace(/^@/, ''); } function getApiKey_() { return PropertiesService.getUserProperties().getProperty('VERISCORE_API_KEY'); } function veriscoreRequest_(method, path, payload, keyOverride) { var options = { method: method, muteHttpExceptions: true, headers: { 'X-Api-Key': keyOverride || getApiKey_() || '' } }; if (payload) { options.contentType = 'application/json'; options.payload = JSON.stringify(payload); } var response = UrlFetchApp.fetch(VERISCORE_API_BASE + path, options); var body = null; try { body = JSON.parse(response.getContentText()); } catch (e) { body = null; } return { code: response.getResponseCode(), body: body }; } function errorMessage_(r) { if (r.code === 401) return 'Your API key was not accepted. It may have been revoked. Set a new one with Veriscore > Set API key.'; if (r.code === 403) return 'This API key is not allowed to do that.'; var detail = r.body && r.body.detail ? r.body.detail : 'Unexpected error'; return 'Veriscore error ' + r.code + ': ' + detail; }