// ===================== Instagram → painel online (Âmbito) ===================== // Para clientes que acompanham o Instagram no painel (com ou sem tráfego). // Lê na Graph API da Meta os números do perfil (por dia) e de cada post, guarda nas abas Perfil e Posts // e envia ao painel só números agregados do perfil da empresa: nada de seguidores, comentários ou mensagens. // // Como ligar (planilha nova, Extensões → Apps Script, cole este arquivo): // 1. prepararPlanilha() cria as abas Config, Perfil, Posts e Eventos // 2. Na aba Config: PAINEL_EMPRESA (identificador criado na tela Clientes), IG_USUARIO (o @) e META_TOKEN // (token de usuário do sistema do Gerenciador de Negócios com instagram_basic, instagram_manage_insights, // pages_show_list e pages_read_engagement). O token sai da planilha na primeira leitura e fica só no script. // 3. descobrirInstagram() acha a conta do Instagram ligada à página e preenche IG_USER_ID // 4. codigoPareamentoInstagram() e cole o código (aba Eventos) na tela Clientes do painel // 5. primeiraCarga() lê os últimos IG_DIAS dias e envia // 6. criarGatilhos() de hora em hora (últimos 3 dias e posts da semana) e todo dia às 6h (todos os posts e miniaturas) // // Os nomes não repetem os de painel.gs: os dois arquivos podem ficar no mesmo projeto e usam a mesma chave de envio. const IG_TZ = 'America/Sao_Paulo'; const IG_ABAS = { Config: ['chave', 'valor', 'para que serve'], Perfil: ['dia', 'alcance', 'visualizacoes', 'interacoes', 'contas_engajadas', 'toques_links', 'seguiram', 'deixaram', 'seguidores_total', 'lido_em'], Posts: ['media_id', 'publicado_em', 'dia', 'formato', 'titulo', 'link', 'alcance', 'visualizacoes', 'curtidas', 'comentarios', 'salvos', 'compartilhamentos', 'interacoes', 'visitas_perfil', 'seguiram', 'miniatura', 'lido_em'], Eventos: ['quando', 'acao', 'situacao', 'detalhe'], }; const IG_CONFIG_PADRAO = [ ['PAINEL_URL', 'https://painel.ambito.net.br', 'endereço do painel'], ['PAINEL_EMPRESA', '', 'identificador do cliente na tela Clientes do painel'], ['META_TOKEN', '', 'cole o token aqui; na primeira leitura ele sai da planilha e fica guardado só no script'], ['IG_USUARIO', '', '@ do Instagram do cliente, sem o @ (ex.: advbrendalobo); escolhe a conta quando o token enxerga várias'], ['IG_USER_ID', '', 'preenchido por descobrirInstagram()'], ['IG_DIAS', '120', 'quantos dias de histórico ler e enviar'], ['GRAPH_VERSAO', 'v26.0', 'versão da Graph API da Meta'], ]; // métricas por tipo de post (a Meta não aceita visitas ao perfil e seguidores em Reels) const IG_METRICAS_FEED = 'reach,views,likes,comments,saved,shares,total_interactions,profile_visits,follows'; const IG_METRICAS_REELS = 'reach,views,likes,comments,saved,shares,total_interactions'; // ---------- comandos ---------- /** Cria as abas que faltam (não mexe nas que já existem). */ function prepararPlanilha() { const ss = SpreadsheetApp.getActive(); Object.keys(IG_ABAS).forEach(nome => { let sh = ss.getSheetByName(nome); if (!sh) { sh = ss.insertSheet(nome); sh.getRange(1, 1, 1, IG_ABAS[nome].length).setValues([IG_ABAS[nome]]).setFontWeight('bold'); sh.setFrozenRows(1); if (nome === 'Config') sh.getRange(2, 1, IG_CONFIG_PADRAO.length, 3).setValues(IG_CONFIG_PADRAO); } }); ss.setSpreadsheetTimeZone(IG_TZ); igLog_('preparar_planilha', 'ok', 'Abas prontas. Preencha PAINEL_EMPRESA e META_TOKEN na aba Config.'); return 'ok'; } /** Lista as contas do Instagram que o token enxerga e preenche IG_USER_ID quando só há uma. */ function descobrirInstagram() { const r = igGraph_('me/accounts', { fields: 'name,instagram_business_account{id,username,followers_count}', limit: 100 }); const contas = (r.data || []).filter(p => p.instagram_business_account).map(p => ({ pagina: p.name, id: p.instagram_business_account.id, usuario: p.instagram_business_account.username, seguidores: p.instagram_business_account.followers_count })); if (!contas.length) { igLog_('descobrir_instagram', 'erro', 'O token não enxerga nenhuma página com Instagram profissional ligado. Confira se a página e a conta do Instagram foram atribuídas ao usuário do sistema.'); return []; } contas.forEach(c => igLog_('descobrir_instagram', 'ok', 'Página "' + c.pagina + '" → @' + c.usuario + ' (IG_USER_ID ' + c.id + ', ' + c.seguidores + ' seguidores)')); const quero = String(igConfig_().IG_USUARIO || '').trim().replace(/^@/, '').toLowerCase(); const escolhida = quero ? contas.find(c => String(c.usuario || '').toLowerCase() === quero) : (contas.length === 1 ? contas[0] : null); if (escolhida) { igSetConfig_('IG_USER_ID', escolhida.id); igLog_('descobrir_instagram', 'ok', 'IG_USER_ID preenchido com @' + escolhida.usuario + '.'); } else if (quero) igLog_('descobrir_instagram', 'erro', 'O token não enxerga @' + quero + '. Atribua a página e a conta do Instagram desse cliente ao usuário do sistema e rode de novo.'); else igLog_('descobrir_instagram', 'atencao', 'Há ' + contas.length + ' contas: preencha IG_USUARIO com o @ deste cliente e rode de novo.'); return contas; } /** Mostra o código de pareamento (hash da chave de envio; não serve como senha) e registra na aba Eventos. */ function codigoPareamentoInstagram() { const h = igHashChave_(); igLog_('painel_pareamento', 'ok', 'Código de pareamento: ' + h); console.log(h); return h; } /** Primeira vez: lê todo o histórico de IG_DIAS dias, envia os números e as miniaturas. */ function primeiraCarga() { return igRodar_('primeira_carga', () => { igLerPerfil_(igDias_()); igLerPosts_(null); }, true); } /** Gatilho de hora em hora: últimos 3 dias do perfil e posts dos últimos 7 dias. */ function atualizarInstagramHora() { return igRodar_('atualizar_hora', () => { igLerPerfil_(3); igLerPosts_(7); }, false); } /** Gatilho diário (6h): última semana do perfil, todos os posts do período e as miniaturas novas. */ function atualizarInstagramDia() { return igRodar_('atualizar_dia', () => { igLerPerfil_(7); igLerPosts_(null); }, true); } /** Rode uma vez: cria os dois gatilhos (sem mexer em outros gatilhos do projeto). */ function criarGatilhos() { const existentes = ScriptApp.getProjectTriggers().map(t => t.getHandlerFunction()); if (existentes.indexOf('atualizarInstagramHora') < 0) ScriptApp.newTrigger('atualizarInstagramHora').timeBased().everyHours(1).create(); if (existentes.indexOf('atualizarInstagramDia') < 0) ScriptApp.newTrigger('atualizarInstagramDia').timeBased().everyDays(1).atHour(6).create(); igLog_('criar_gatilhos', 'ok', 'Gatilhos de hora em hora e diário prontos.'); return 'ok'; } // ---------- envio ao painel ---------- /** Envia agora o resumo (só números) para o painel. */ function enviarInstagramPainel() { // com cliente.gs no mesmo projeto, o envio completo (vendas, campanhas, anúncios e Instagram) sai por ele if (typeof enviarPainel === 'function' && typeof clMontar_ === 'function') return enviarPainel(); const cfg = igConfig_(); const url = String(cfg.PAINEL_URL || '').replace(/\/+$/, ''), empresa = String(cfg.PAINEL_EMPRESA || '').trim(); if (!url || !empresa) throw new Error('Preencha PAINEL_URL e PAINEL_EMPRESA na aba Config.'); let st = {}; try { st = JSON.parse(PropertiesService.getScriptProperties().getProperty('IG_STATUS') || '{}'); } catch (e) {} const corpo = { v: 1, empresa: empresa, gerado_em: new Date().toISOString(), atualizacao: { ig_ok: st.ok !== false, quando: st.quando || '' }, instagram: montarInstagram_() }; const site = igSite_(); if (site) corpo.site = site; const texto = JSON.stringify(corpo); const res = UrlFetchApp.fetch(url + '/api/ingest/' + encodeURIComponent(empresa), { method: 'post', contentType: 'application/json', payload: texto, headers: { Authorization: 'Bearer ' + igChave_() }, muteHttpExceptions: true, }); const code = res.getResponseCode(); if (code !== 200) igLog_('enviar_painel', 'erro', code === 401 ? 'Painel ainda não pareado. Código de pareamento: ' + igHashChave_() : 'HTTP ' + code + ' ' + res.getContentText().slice(0, 200)); console.log('painel', code, texto.length + ' bytes'); return { code: code, bytes: texto.length }; } /** Bloco "instagram" do envio (painel.gs também pode chamar esta função, se o cliente tiver tráfego e Instagram). */ function montarInstagram_() { const corte = igDia_(new Date(Date.now() - igDias_() * 864e5)); const P = igLer_('Perfil'), M = igLer_('Posts'); const n = v => (v === '' || v == null || isNaN(Number(v)) ? '' : Number(v)); const diario = P.rows.filter(r => igTexto_(r[P.c('dia')]) >= corte).map(r => [igTexto_(r[P.c('dia')]), n(r[P.c('alcance')]), n(r[P.c('visualizacoes')]), n(r[P.c('interacoes')]), n(r[P.c('contas_engajadas')]), n(r[P.c('toques_links')]), n(r[P.c('seguiram')]), n(r[P.c('deixaram')]), n(r[P.c('seguidores_total')])]); const posts = M.rows.filter(r => igTexto_(r[M.c('dia')]) >= corte).map(r => [String(r[M.c('media_id')]), igTexto_(r[M.c('dia')]), String(r[M.c('formato')] || ''), String(r[M.c('titulo')] || '').slice(0, 120), String(r[M.c('link')] || ''), n(r[M.c('alcance')]), n(r[M.c('visualizacoes')]), n(r[M.c('curtidas')]), n(r[M.c('comentarios')]), n(r[M.c('salvos')]), n(r[M.c('compartilhamentos')]), n(r[M.c('interacoes')]), n(r[M.c('visitas_perfil')]), n(r[M.c('seguiram')])]); let perfil = {}; try { perfil = JSON.parse(PropertiesService.getScriptProperties().getProperty('IG_PERFIL') || '{}'); } catch (e) {} return { perfil: perfil.usuario ? '@' + perfil.usuario : '', seguidores: perfil.seguidores != null ? perfil.seguidores : '', posts_total: perfil.posts != null ? perfil.posts : '', diario: diario, posts: posts }; } // cliques no WhatsApp do link da bio (aba Cliques, quando existir), só em contagens function igSite_() { const L = igLer_('Cliques'); if (!L.rows.length || L.c('registrado_em') < 0) return null; const corte = igDia_(new Date(Date.now() - igDias_() * 864e5)), cont = {}; const curto = v => String(v || '').trim().toLowerCase().slice(0, 40); L.rows.forEach(r => { const d = igTexto_(r[L.c('registrado_em')]); if (!d || d < corte || /^teste/.test(curto(r[L.c('utm_source')]))) return; const k = [d, String(r[L.c('pagina')] || '/').trim().slice(0, 60), curto(r[L.c('utm_source')]), curto(r[L.c('utm_medium')])].join('\u0001'); cont[k] = (cont[k] || 0) + 1; }); return { cliques: Object.keys(cont).map(k => k.split('\u0001').concat([cont[k]])), cadastros: [] }; } /** Miniaturas dos posts (120 px), enviadas ao painel uma vez por post. */ function enviarMiniaturasInstagram() { const cfg = igConfig_(); const url = String(cfg.PAINEL_URL || '').replace(/\/+$/, ''), empresa = String(cfg.PAINEL_EMPRESA || '').trim(); const props = PropertiesService.getScriptProperties(); let enviadas = []; try { enviadas = JSON.parse(props.getProperty('IG_MINIATURAS') || '[]'); } catch (e) {} const M = igLer_('Posts'); const faltam = M.rows.map(r => ({ id: String(r[M.c('media_id')]), src: String(r[M.c('miniatura')] || '') })).filter(p => p.id && /^https:\/\//.test(p.src) && enviadas.indexOf(p.id) < 0).slice(0, 120); const imgs = {}; for (let i = 0; i < faltam.length; i += 5) { const lote = faltam.slice(i, i + 5); if (i) Utilities.sleep(350); // a imagem original tem 1080 px: o wsrv.nl reduz para 120 px (é uma imagem pública do próprio perfil) const res = UrlFetchApp.fetchAll(lote.map(p => ({ url: 'https://wsrv.nl/?url=' + encodeURIComponent(p.src) + '&w=120&h=120&fit=cover&output=jpg&q=72', muteHttpExceptions: true }))); res.forEach((r, k) => { if (r.getResponseCode() !== 200) return; const b = r.getBlob(), bytes = b.getBytes(), tipo = b.getContentType() || 'image/jpeg'; if (bytes.length > 200 && bytes.length < 60000 && /^image\//.test(tipo)) imgs[lote[k].id] = 'data:' + tipo + ';base64,' + Utilities.base64Encode(bytes); }); } const ids = Object.keys(imgs); if (!ids.length) return 0; const res = UrlFetchApp.fetch(url + '/api/ingest/' + encodeURIComponent(empresa) + '/miniaturas', { method: 'post', contentType: 'application/json', payload: JSON.stringify({ miniaturas: imgs }), headers: { Authorization: 'Bearer ' + igChave_() }, muteHttpExceptions: true, }); if (res.getResponseCode() === 200) props.setProperty('IG_MINIATURAS', JSON.stringify(enviadas.concat(ids).slice(-400))); igLog_('miniaturas_painel', res.getResponseCode() === 200 ? 'ok' : 'erro', ids.length + ' de ' + faltam.length + ' posts · HTTP ' + res.getResponseCode()); return ids.length; } // ---------- leitura na Graph API ---------- // perfil por dia: alcance, visualizações, interações, contas engajadas, toques nos links, seguiram/deixaram function igLerPerfil_(dias) { const ig = igUsuario_(); const conta = igGraph_(ig, { fields: 'username,followers_count,media_count' }); PropertiesService.getScriptProperties().setProperty('IG_PERFIL', JSON.stringify({ usuario: conta.username, seguidores: conta.followers_count, posts: conta.media_count })); const hoje = igDia_(new Date()); const lista = []; for (let i = dias - 1; i >= 0; i--) lista.push(igDia_(new Date(Date.now() - i * 864e5))); const linhas = {}; for (let i = 0; i < lista.length; i += 15) { const lote = lista.slice(i, i + 15), reqs = []; lote.forEach(d => { const since = igUnix_(d), until = since + 86400; reqs.push(igReq_(ig + '/insights', { metric: 'reach,views,total_interactions,accounts_engaged,profile_links_taps', period: 'day', metric_type: 'total_value', since: since, until: until })); reqs.push(igReq_(ig + '/insights', { metric: 'follows_and_unfollows', period: 'day', metric_type: 'total_value', breakdown: 'follow_type', since: since, until: until })); }); const res = igFetchAll_(reqs); lote.forEach((d, k) => { let a = res[2 * k]; // uma métrica recusada derruba o pedido inteiro: tenta uma por uma if (a.error) a = igUmaPorUma_(ig, 'reach,views,total_interactions,accounts_engaged,profile_links_taps', igUnix_(d)); const val = {}; (a.data || []).forEach(m => { val[m.name] = m.total_value ? m.total_value.value : ''; }); const f = res[2 * k + 1]; let seguiram = '', deixaram = ''; if (!f.error && f.data && f.data[0] && f.data[0].total_value) { const br = (f.data[0].total_value.breakdowns || [])[0]; seguiram = 0; deixaram = 0; ((br && br.results) || []).forEach(x => { if (x.dimension_values[0] === 'FOLLOWER') seguiram = x.value; if (x.dimension_values[0] === 'NON_FOLLOWER') deixaram = x.value; }); } linhas[d] = { dia: d, alcance: igV_(val.reach), visualizacoes: igV_(val.views), interacoes: igV_(val.total_interactions), contas_engajadas: igV_(val.accounts_engaged), toques_links: igV_(val.profile_links_taps), seguiram: seguiram, deixaram: deixaram }; }); } if (linhas[hoje]) linhas[hoje].seguidores_total = conta.followers_count; igGravar_('Perfil', 'dia', linhas); } function igUmaPorUma_(ig, metricas, since) { const res = igFetchAll_(metricas.split(',').map(m => igReq_(ig + '/insights', { metric: m, period: 'day', metric_type: 'total_value', since: since, until: since + 86400 }))); return { data: [].concat.apply([], res.filter(r => !r.error).map(r => r.data || [])) }; } // posts: lista do perfil e números de cada um (recentes = só os dos últimos N dias; null = todo o período) function igLerPosts_(recentes) { const ig = igUsuario_(); const corte = igDia_(new Date(Date.now() - igDias_() * 864e5)); const corteRecente = recentes ? igDia_(new Date(Date.now() - recentes * 864e5)) : corte; const midias = []; let pag = igGraph_(ig + '/media', { fields: 'id,caption,media_type,media_product_type,permalink,timestamp,media_url,thumbnail_url,children{media_type,media_url,thumbnail_url}', limit: 50 }); for (let n = 0; n < 20 && pag; n++) { let parar = false; (pag.data || []).forEach(m => { const d = igDia_(new Date(m.timestamp)); if (d < corteRecente) parar = true; else midias.push(m); }); if (parar || !pag.paging || !pag.paging.next) break; pag = igGet_(pag.paging.next); } const reels = m => m.media_product_type === 'REELS'; const linhas = {}; for (let i = 0; i < midias.length; i += 25) { const lote = midias.slice(i, i + 25); const res = igFetchAll_(lote.map(m => igReq_(m.id + '/insights', { metric: reels(m) ? IG_METRICAS_REELS : IG_METRICAS_FEED }))); lote.forEach((m, k) => { let r = res[k]; if (r.error && !reels(m)) r = igFetchAll_([igReq_(m.id + '/insights', { metric: IG_METRICAS_REELS })])[0]; const v = {}; (r.data || []).forEach(x => { v[x.name] = x.values && x.values[0] ? x.values[0].value : (x.total_value ? x.total_value.value : ''); }); const filho = m.children && m.children.data && m.children.data[0]; const mini = m.thumbnail_url || m.media_url || (filho && (filho.thumbnail_url || filho.media_url)) || ''; const legenda = String(m.caption || '').replace(/\s+/g, ' ').trim(); linhas[m.id] = { media_id: m.id, publicado_em: m.timestamp, dia: igDia_(new Date(m.timestamp)), formato: reels(m) ? 'reels' : m.media_type === 'CAROUSEL_ALBUM' ? 'carrossel' : m.media_type === 'VIDEO' ? 'video' : 'imagem', titulo: legenda.slice(0, 120), link: m.permalink || '', alcance: igV_(v.reach), visualizacoes: igV_(v.views), curtidas: igV_(v.likes), comentarios: igV_(v.comments), salvos: igV_(v.saved), compartilhamentos: igV_(v.shares), interacoes: igV_(v.total_interactions), visitas_perfil: igV_(v.profile_visits), seguiram: igV_(v.follows), miniatura: mini, }; }); } igGravar_('Posts', 'media_id', linhas); return midias.length; } // ---------- utilidades ---------- // lê na Meta, marca se a leitura deu certo e envia ao painel (com as miniaturas novas, se pedido) function igRodar_(acao, leitura, miniaturas) { const lock = LockService.getScriptLock(); if (!lock.tryLock(30000)) { igLog_(acao, 'atencao', 'Outra leitura ainda estava rodando; esta foi pulada.'); return null; } const props = PropertiesService.getScriptProperties(); const quando = Utilities.formatDate(new Date(), IG_TZ, 'dd/MM HH:mm'); IG_CFG = null; try { try { leitura(); props.setProperty('IG_STATUS', JSON.stringify({ ok: true, quando: quando })); } catch (e) { props.setProperty('IG_STATUS', JSON.stringify({ ok: false, quando: quando })); igLog_(acao, 'erro', String(e && e.message || e).slice(0, 300)); } // envia mesmo quando a leitura falhou: o painel mostra o aviso e os números que já estavam na planilha const r = enviarInstagramPainel(); if (miniaturas) { try { enviarMiniaturasInstagram(); } catch (e) { igLog_('miniaturas_painel', 'erro', String(e && e.message || e).slice(0, 300)); } } return r; } finally { lock.releaseLock(); } } let IG_CFG = null; // aba Config lida uma vez por execução function igConfig_() { if (IG_CFG) return IG_CFG; const sh = SpreadsheetApp.getActive().getSheetByName('Config'); const out = {}; if (sh && sh.getLastRow() >= 2) sh.getRange(2, 1, sh.getLastRow() - 1, 2).getValues().forEach(r => { const k = String(r[0] || '').trim(); if (k) out[k] = r[1]; }); IG_CFG = out; return out; } function igSetConfig_(chave, valor) { IG_CFG = null; const sh = SpreadsheetApp.getActive().getSheetByName('Config'); const vals = sh.getRange(2, 1, Math.max(1, sh.getLastRow() - 1), 1).getValues(); for (let i = 0; i < vals.length; i++) if (String(vals[i][0]).trim() === chave) { sh.getRange(i + 2, 2).setValue(valor); return; } sh.appendRow([chave, valor, '']); } function igDias_() { const n = Number(igConfig_().IG_DIAS); return n >= 7 && n <= 700 ? Math.round(n) : 120; } function igUsuario_() { const id = String(igConfig_().IG_USER_ID || '').trim(); if (!/^\d{5,25}$/.test(id)) throw new Error('Preencha IG_USER_ID na aba Config (rode descobrirInstagram).'); return id; } // o token sai da planilha na primeira leitura e fica só nas Propriedades do script function igToken_() { const props = PropertiesService.getScriptProperties(); const naPlanilha = String(igConfig_().META_TOKEN || '').trim(); if (naPlanilha && naPlanilha.indexOf('guardado') < 0) { props.setProperty('META_TOKEN', naPlanilha); igSetConfig_('META_TOKEN', '(guardado no script em ' + Utilities.formatDate(new Date(), IG_TZ, 'dd/MM/yyyy') + ')'); } const t = props.getProperty('META_TOKEN'); if (!t) throw new Error('Cole o token da Meta em META_TOKEN, na aba Config.'); return t; } function igBase_() { return 'https://graph.facebook.com/' + (String(igConfig_().GRAPH_VERSAO || 'v26.0').trim()) + '/'; } function igReq_(caminho, params) { const q = Object.keys(params || {}).map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k])).join('&'); return { url: igBase_() + caminho + (q ? '?' + q : ''), headers: { Authorization: 'Bearer ' + igToken_() }, muteHttpExceptions: true }; } function igGet_(url) { const r = UrlFetchApp.fetch(url, { headers: { Authorization: 'Bearer ' + igToken_() }, muteHttpExceptions: true }); const j = JSON.parse(r.getContentText() || '{}'); if (j.error) throw new Error('Meta: ' + (j.error.message || 'erro') + ' (código ' + j.error.code + ')'); return j; } function igGraph_(caminho, params) { return igGet_(igReq_(caminho, params).url); } // o Apps Script limita chamadas seguidas ao mesmo endereço: vai em lotes pequenos, com pausa, e tenta de novo quando o Google pede calma function igFetchAll_(reqs) { const out = []; for (let i = 0; i < reqs.length; i += 5) { const lote = reqs.slice(i, i + 5); let res = null; for (let t = 0; t < 4 && !res; t++) { try { res = UrlFetchApp.fetchAll(lote); } catch (e) { if (!/rateMax|muitas vezes|too many times/i.test(String(e && e.message || e)) || t === 3) throw e; Utilities.sleep(2000 * (t + 1)); } } res.forEach(r => { try { out.push(JSON.parse(r.getContentText() || '{}')); } catch (e) { out.push({ error: { message: 'resposta inválida' } }); } }); if (i + 5 < reqs.length) Utilities.sleep(350); } return out; } function igLer_(nome) { const sh = SpreadsheetApp.getActive().getSheetByName(nome); if (!sh || sh.getLastRow() < 2) return { rows: [], c: () => -1 }; const h = sh.getRange(1, 1, 1, sh.getLastColumn()).getValues()[0].map(x => String(x).trim()); return { rows: sh.getRange(2, 1, sh.getLastRow() - 1, h.length).getValues(), c: k => h.indexOf(k) }; } // atualiza as linhas pela chave (dia ou media_id), sem apagar colunas que não vieram nesta leitura function igGravar_(nome, chave, linhas) { const ss = SpreadsheetApp.getActive(); let sh = ss.getSheetByName(nome); if (!sh) { prepararPlanilha(); sh = ss.getSheetByName(nome); } const h = IG_ABAS[nome]; const atuais = sh.getLastRow() > 1 ? sh.getRange(2, 1, sh.getLastRow() - 1, h.length).getValues() : []; const ci = h.indexOf(chave), idx = {}; atuais.forEach((r, i) => { idx[igTexto_(r[ci])] = i; }); const agora = Utilities.formatDate(new Date(), IG_TZ, 'yyyy-MM-dd HH:mm'); Object.keys(linhas).forEach(k => { const o = linhas[k]; let r = idx[k] != null ? atuais[idx[k]] : h.map(() => ''); h.forEach((col, j) => { if (o[col] !== undefined && !(o[col] === '' && col === 'seguidores_total')) r[j] = o[col]; }); r[h.indexOf('lido_em')] = agora; if (idx[k] == null) { idx[k] = atuais.length; atuais.push(r); } }); const cd = h.indexOf('dia'), ct = h.indexOf('titulo'); atuais.sort((a, b) => (igTexto_(a[cd]) < igTexto_(b[cd]) ? 1 : -1)); // mais recente em cima if (atuais.length) { sh.getRange(2, 1, atuais.length, h.length).setNumberFormat('@') .setValues(atuais.map(r => r.map((v, j) => { const t = v == null ? '' : String(v); return j === ct ? igSeguro_(t) : t; }))); } } function igDia_(d) { return Utilities.formatDate(d, IG_TZ, 'yyyy-MM-dd'); } function igTexto_(v) { return v instanceof Date ? igDia_(v) : String(v == null ? '' : v).trim(); } function igUnix_(dia) { return Math.floor(new Date(dia + 'T00:00:00-03:00').getTime() / 1000); } function igV_(v) { return v === '' || v == null || isNaN(Number(v)) ? '' : Number(v); } function igSeguro_(t) { return /^[=+\-@]/.test(t) ? "'" + t : t; } // texto que começa com = + - @ não vira fórmula function igChave_() { const props = PropertiesService.getScriptProperties(); let chave = props.getProperty('PAINEL_CHAVE'); if (!chave) { chave = (Utilities.getUuid() + Utilities.getUuid()).replace(/-/g, ''); props.setProperty('PAINEL_CHAVE', chave); } return chave; } function igHashChave_() { const b = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, igChave_(), Utilities.Charset.UTF_8); return b.map(x => ('0' + ((x + 256) % 256).toString(16)).slice(-2)).join(''); } function igLog_(acao, situacao, detalhe) { const ss = SpreadsheetApp.getActive(); let sh = ss.getSheetByName('Eventos'); if (!sh) { sh = ss.insertSheet('Eventos'); sh.getRange(1, 1, 1, 4).setValues([IG_ABAS.Eventos]).setFontWeight('bold'); sh.setFrozenRows(1); } sh.insertRowAfter(1); sh.getRange(2, 1, 1, 4).setValues([[Utilities.formatDate(new Date(), IG_TZ, 'dd/MM/yyyy HH:mm'), acao, situacao, String(detalhe || '').slice(0, 500)]]); if (sh.getLastRow() > 600) sh.deleteRows(601, sh.getLastRow() - 600); }