/** * Rixe — Looker Studio community connector. * * Reads the public data API (/v1) with an organization API key. Read-only by construction: the * connector has no write path and the key it uses cannot write either. * * Deploy: create a new Apps Script project, paste this file over Code.gs and appsscript.json * over the manifest, then Deploy > Test deployments > Looker Studio. The connector is unlisted: * every customer runs their own copy under their own Google account. Setup guide: * https://developers.rixe.ai/developers/looker-studio */ var BASE_URL = 'https://api.rixe.ai/v1'; var HELP_URL = 'https://developers.rixe.ai/developers/looker-studio'; var KEY_PROPERTY = 'rixe.apiKey'; var ALL_WORKSPACES = '__all__'; /** Rows per page and the maximum number of pages one getData call walks (1000 x 20). */ var PAGE_SIZE = 1000; var MAX_PAGES = 20; var cc = DataStudioApp.createCommunityConnector(); // --------------------------------------------------------------------------------------------- // Authentication (API key) // --------------------------------------------------------------------------------------------- function getAuthType() { return cc.newAuthTypeResponse().setAuthType(cc.AuthType.KEY).setHelpUrl(HELP_URL).build(); } function readKey() { return PropertiesService.getUserProperties().getProperty(KEY_PROPERTY) || ''; } /** A key is valid when the API answers the workspace list with it. */ function keyWorks(key) { if (!key) return false; var response = UrlFetchApp.fetch(BASE_URL + '/workspaces', { headers: { 'x-api-key': key }, muteHttpExceptions: true, }); return response.getResponseCode() === 200; } function isAuthValid() { return keyWorks(readKey()); } function setCredentials(request) { var key = (request.key || '').trim(); if (!keyWorks(key)) return { errorCode: 'INVALID_CREDENTIALS' }; PropertiesService.getUserProperties().setProperty(KEY_PROPERTY, key); return { errorCode: 'NONE' }; } function resetAuth() { PropertiesService.getUserProperties().deleteProperty(KEY_PROPERTY); } // --------------------------------------------------------------------------------------------- // HTTP // --------------------------------------------------------------------------------------------- function buildQuery(params) { var parts = []; for (var name in params) { if (params[name] === undefined || params[name] === null || params[name] === '') continue; parts.push(encodeURIComponent(name) + '=' + encodeURIComponent(String(params[name]))); } return parts.length ? '?' + parts.join('&') : ''; } /** GET a /v1 path with the stored key; surfaces API failures as a Looker user error. */ function fetchApi(path, params) { var response = UrlFetchApp.fetch(BASE_URL + path + buildQuery(params || {}), { headers: { 'x-api-key': readKey() }, muteHttpExceptions: true, }); var code = response.getResponseCode(); if (code !== 200) { var hint = code === 401 ? 'The API key was revoked or is wrong. Reconnect with a new key.' : code === 403 ? 'The API key is missing a scope this data set needs.' : code === 429 ? 'Rate limit reached. Lower the data freshness in Looker Studio.' : 'Rixe API answered ' + code + '.'; cc.newUserError().setText(hint).setDebugText(response.getContentText()).throwException(); } return JSON.parse(response.getContentText()); } // --------------------------------------------------------------------------------------------- // Configuration: workspace + data set // --------------------------------------------------------------------------------------------- function getConfig() { var config = cc.getConfig(); var workspace = config .newSelectSingle() .setId('workspaceId') .setName('Workspace') .setHelpText('Which Rixe workspace to read. "All workspaces" merges every workspace the key can reach.'); workspace.addOption(config.newOptionBuilder().setLabel('All workspaces').setValue(ALL_WORKSPACES)); // The list comes from the API so the user picks a name, not a UUID. If the call fails here the // "All workspaces" option still works and the error shows up on the first data fetch instead. try { var workspaces = fetchApi('/workspaces'); for (var i = 0; i < workspaces.length; i++) { workspace.addOption( config.newOptionBuilder().setLabel(String(workspaces[i].name)).setValue(String(workspaces[i].id)), ); } } catch (error) { // Ignored on purpose: see the comment above. } var dataset = config .newSelectSingle() .setId('dataset') .setName('Data set') .setHelpText('Daily visibility is one row per day, brand and engine. Cited sources is one row per domain.'); dataset.addOption(config.newOptionBuilder().setLabel('Daily visibility').setValue('daily')); dataset.addOption(config.newOptionBuilder().setLabel('Cited sources').setValue('citations')); config.setDateRangeRequired(true); return config.build(); } function configOf(request) { var params = (request && request.configParams) || {}; var workspaceId = params.workspaceId && params.workspaceId !== ALL_WORKSPACES ? params.workspaceId : ''; return { workspaceId: workspaceId, dataset: params.dataset === 'citations' ? 'citations' : 'daily' }; } // --------------------------------------------------------------------------------------------- // Schema. Field ids equal the property names the API returns, so getData needs no mapping table. // --------------------------------------------------------------------------------------------- function dailyFields() { var fields = cc.getFields(); var types = cc.FieldType; var agg = cc.AggregationType; fields.newDimension().setId('date').setName('Date').setType(types.YEAR_MONTH_DAY); fields.newDimension().setId('brand').setName('Brand').setType(types.TEXT); fields.newDimension().setId('engine').setName('Engine').setType(types.TEXT); fields .newDimension() .setId('isCompetitor') .setName('Is competitor') .setDescription('True when the brand row is a tracked competitor, false for your own brand.') .setType(types.BOOLEAN); fields .newDimension() .setId('insufficient') .setName('Insufficient sample') .setDescription('True when fewer than 4 measurements ran that day; filter these rows out of charts.') .setType(types.BOOLEAN); fields.newMetric().setId('runs').setName('Measurements').setType(types.NUMBER).setAggregation(agg.SUM); fields.newMetric().setId('mentions').setName('Mentions').setType(types.NUMBER).setAggregation(agg.SUM); fields .newMetric() .setId('mentionRate') .setName('Visibility score') .setDescription('Share of answers that mention the brand (mentions / measurements).') .setType(types.PERCENT) .setAggregation(agg.AVG); fields .newMetric() .setId('sov') .setName('Share of voice') .setDescription('The brand share of all brand mentions that day.') .setType(types.PERCENT) .setAggregation(agg.AVG); fields .newMetric() .setId('avgPosition') .setName('Average position') .setType(types.NUMBER) .setAggregation(agg.AVG); fields.newMetric().setId('ciLow').setName('CI low').setType(types.PERCENT).setAggregation(agg.AVG); fields.newMetric().setId('ciHigh').setName('CI high').setType(types.PERCENT).setAggregation(agg.AVG); fields.setDefaultDimension('date'); fields.setDefaultMetric('mentionRate'); return fields; } function citationFields() { var fields = cc.getFields(); var types = cc.FieldType; var agg = cc.AggregationType; fields.newDimension().setId('domain').setName('Domain').setType(types.TEXT); fields.newDimension().setId('kind').setName('Source kind').setType(types.TEXT); fields.newDimension().setId('access').setName('Access').setType(types.TEXT); fields .newDimension() .setId('brandMentioned') .setName('Mentions brand') .setDescription('True when the cited page mentions your brand.') .setType(types.BOOLEAN); fields.newMetric().setId('citations').setName('Citations').setType(types.NUMBER).setAggregation(agg.SUM); fields .newMetric() .setId('promptCoverage') .setName('Prompts covered') .setDescription('Number of tracked prompts whose answers cite this domain.') .setType(types.NUMBER) .setAggregation(agg.SUM); fields.setDefaultDimension('domain'); fields.setDefaultMetric('citations'); return fields; } function fieldsFor(dataset) { return dataset === 'citations' ? citationFields() : dailyFields(); } function getSchema(request) { return { schema: fieldsFor(configOf(request).dataset).build() }; } // --------------------------------------------------------------------------------------------- // Data // --------------------------------------------------------------------------------------------- /** Walks /daily-scores page by page; the API caps one page at 1000 rows. */ function fetchDailyRows(workspaceId, range) { var rows = []; for (var page = 0; page < MAX_PAGES; page++) { var payload = fetchApi('/daily-scores', { workspaceId: workspaceId, from: range.startDate, to: range.endDate, limit: PAGE_SIZE, offset: page * PAGE_SIZE, }); rows = rows.concat(payload.data); if (payload.data.length < PAGE_SIZE) break; } return rows; } function fetchCitationRows(workspaceId) { return fetchApi('/citations', { workspaceId: workspaceId, limit: PAGE_SIZE }).data; } /** Turns one API value into the shape Looker expects for the field type. */ function cellValue(fieldId, row) { var value = row[fieldId]; if (value === null || value === undefined) return null; if (fieldId === 'date') return String(value).slice(0, 10).replace(/-/g, ''); if (typeof value === 'boolean') return value; // PostgreSQL numerics arrive as strings; counts as numbers. Text stays text. if (typeof value === 'number') return value; if (/^-?\d+(\.\d+)?$/.test(String(value))) return Number(value); return String(value); } function getData(request) { var config = configOf(request); var requested = (request.fields || []).map(function (field) { return field.name; }); var fields = fieldsFor(config.dataset).forIds(requested); var source = config.dataset === 'citations' ? fetchCitationRows(config.workspaceId) : fetchDailyRows(config.workspaceId, request.dateRange || {}); var rows = source.map(function (row) { return { values: requested.map(function (id) { return cellValue(id, row); }), }; }); return { schema: fields.build(), rows: rows, filtersApplied: false }; }