// ==UserScript== // @name Mútuo // @description Quem não te segue de volta no Instagram, direto no Safari do iPhone. // @version 1.3.1 // @match https://www.instagram.com/* // @run-at document-idle // @inject-into auto // @grant GM.getValue // @grant GM.setValue // @grant GM.deleteValue // @updateURL https://usemutuo.com.br/iphone/mutuo.user.js // @downloadURL https://usemutuo.com.br/iphone/mutuo.user.js // ==/UserScript== /* Gerado por ferramentas/gerar-iphone.mjs: a fonte é extensao/ e celular/. Não edite aqui. */ (function () { 'use strict'; if (window.top !== window || document.getElementById('mutuo-botao')) return; function abrirMutuo() { /* Mútuo: motor gerado por ferramentas/motor-celular.mjs. Não edite: a fonte é extensao/. */ (function () { 'use strict'; if (window.__mutuoMotor) return; /* * O cookie da sessão. O Android entrega pelo CookieManager (que enxerga o que * o document.cookie não enxerga); a ponte só responde por ds_user_id e * csrftoken, que é o que o motor pede. */ const cookiesNativos = { async get({ name }) { let valor = ''; try { if (window.MutuoNativo) valor = String(window.MutuoNativo.cookie(name) || ''); } catch (e) { valor = ''; } if (!valor) { const achado = document.cookie.split('; ').find((c) => c.startsWith(name + '=')); valor = achado ? decodeURIComponent(achado.slice(name.length + 1)) : ''; } return valor ? { value: valor } : null; }, }; /* * A "aba do Instagram" da extensão é a própria página (ver o topo de * ferramentas/motor-celular.mjs). sendMessage entrega a mensagem ao ouvinte * que extensao/ponte.js registrou, e devolve o que ele responder. A aba * nunca é nossa para fechar: remove não faz nada. */ let ouvinteDaPonte = null; const chrome = { runtime: { onMessage: { addListener(f) { ouvinteDaPonte = f; } }, sendMessage: () => Promise.resolve(), }, tabs: { query: async () => [{ id: 1 }], create: async () => ({ id: 1 }), remove: async () => {}, sendMessage: (id, mensagem) => new Promise((responder) => { if (!ouvinteDaPonte) { responder(undefined); return; } let respondeu = false; const assincrono = ouvinteDaPonte(mensagem, {}, (r) => { respondeu = true; responder(r); }); if (assincrono !== true && !respondeu) responder(undefined); }), }, }; /* * A amostra do plano FREE. * * Fica em módulo próprio porque é a única peça do limite que é lógica pura — * sem rede, sem tela — e por isso a única que dá para testar sozinha. As regras * abaixo parecem óbvias e nenhuma delas é: cada uma corresponde a uma conta * real que ficaria mal servida. */ /** * Junta duas listas alternando entre elas, sem repetir ninguém, até o limite. * * Alterna em vez de fatiar metade de cada uma por causa das contas * desequilibradas, que são a maioria: quem segue 8 pessoas e tem 600 seguidores * receberia 8 + 25 = 33 perfis analisados num corte fixo, e recebe os 50 aqui. * * Quem aparece nas duas listas entra uma vez só: é a MESMA pessoa, e ocuparia * duas vagas da amostra à toa. * * @param {{pk: string}[]} listaA * @param {{pk: string}[]} listaB * @param {number} limite * @returns {{pk: string}[]} */ function amostrar(listaA = [], listaB = [], limite = 0) { const vistos = new Set(); const amostra = []; const maior = Math.max(listaA.length, listaB.length); for (let i = 0; i < maior && amostra.length < limite; i += 1) { for (const pessoa of [listaA[i], listaB[i]]) { // O pk vira texto: `1` e `'1'` são a MESMA pessoa, e um Set não converte // tipo. Se uma das listas vier com pk numérico, a dedupe silenciosamente // parava de funcionar e a amostra de 50 viraria 25 pessoas reais. const chave = pessoa && String(pessoa.pk); if (!pessoa || vistos.has(chave) || amostra.length >= limite) continue; vistos.add(chave); amostra.push(pessoa); } } return amostra; } /* * O ritmo das ações que ESCREVEM na conta (seguir, deixar de seguir). * * Fica em módulo próprio porque é aritmética pura — e porque estes números são * a diferença entre uma ferramenta e uma conta bloqueada. Mexer aqui muda o * risco de quem usa, então cada valor tem um motivo escrito. * * O QUE O INSTAGRAM PUNE não é o total, é o PADRÃO. Três coisas, nesta ordem: * * 1. Rajada. Cinco ações em cinco segundos e depois dois minutos parado é * assinatura de script. Era exatamente o que a extensão fazia antes: o * freio limitava o volume por janela, mas dentro da janela as ações saíam * coladas. Por isso o intervalo agora é POR AÇÃO. * 2. Cadência constante. Uma ação a cada 18 segundos, cravado, é tão * identificável quanto a rajada. Por isso o intervalo é SORTEADO a cada * vez, dentro de uma faixa. * 3. Volume por dia. É o que gera o "action block" (`feedback_required`), * que trava ações por 24 horas a uma semana — e cada bloqueio deixa o * próximo mais provável. Daí o teto de 24 horas, que antes não existia: * nada impedia 800 ações num dia. * * OS VALORES DE HOJE (16/08/2026) SÃO OS DA SEGUNDA RODADA. A primeira, de * 15/08, mirava ~35 minutos a cada 100 contas — ~170 ações/hora com teto de 200 * por dia — e ficava acima do que eu recomendava. A conta do dono levou "action * block", que é exatamente o gatilho que estava escrito aqui, e ele escolheu * desacelerar: ~75 ações/hora, teto de 100 por dia, ~1h20 a cada 100 contas. * * Continua valendo, e agora com um caso concreto por trás: SE A CONTA VOLTAR A * LEVAR BLOQUEIO, o caminho é aumentar os intervalos e diminuir o teto — nunca o * contrário. */ const RITMO = { // Entre uma conta e a seguinte, sorteado a cada vez. minMs: 28000, maxMs: 50000, // Uma parada mais longa de tempos em tempos — imita quem larga a tela e // volta. Sem ela, uma hora de intervalos curtos ainda desenha uma linha reta. // Mais curta na contagem (a cada 25, não 40) e mais longa no relógio: a linha // reta que ela quebra fica mais evidente quanto mais longa é a sessão. pausaACada: 25, pausaMs: 300000, // 5 min }; /* * Teto de ações num período de 24 horas CORRIDAS, contando tudo que escreve * (seguir, deixar de seguir, Close Friends). * * Rolante, e não "por dia do calendário", de propósito: zerando à meia-noite, * dava para fazer 100 às 23h e mais 100 à 0h05 — 200 em dez minutos, que é * justamente o volume que derruba conta. */ const TETO_24H = 100; /** Teto de rajada: quantas ações cabem numa janela curta. */ const MAX_ACOES_POR_JANELA = 10; const JANELA_MS = 120000; /* * O CLOSE FRIENDS, UMA PESSOA POR PEDIDO (17/09/2026). * * Até esta data um pedido levava 50 pessoas, e cem contas eram duas chamadas. * O caminho que o site usa hoje (o Bloks, em instagram.js) leva uma só, então a * ação em massa passou a ser uma sequência de pedidos, como o unfollow. O ritmo * dela é outro, e mais rápido, por dois motivos medidos na própria tela do * Instagram: ela manda um pedido a cada toque, no ritmo em que a pessoa toca, e * avisa que "não enviamos notificações quando você edita sua lista Amigos * Próximos". Ninguém é alcançado, que é o que distingue esta escrita de seguir. * * Mais rápido não é sem freio: intervalo SORTEADO entre pedidos (cadência fixa * é assinatura de script, como no unfollow) e uma pausa a cada 25. Cem contas * ficam em cerca de 9 minutos, dito na confirmação antes de começar. */ const RITMO_CLOSE = { minMs: 2500, maxMs: 6000, pausaACada: 25, pausaMs: 45000, }; /* * Quantas alterações do Close Friends valem UMA ação nos contadores de 2 * minutos e de 24 horas. * * Aqueles tetos foram calibrados para seguir e deixar de seguir, que é o que o * Instagram pune. Contar cada pessoa do Close Friends como uma ação faria uma * lista de cem gastar o dia inteiro de unfollow, por uma escrita que não chega a * ninguém. Até 17/09/2026 a conta já era por pedido, e um pedido levava 50; 25 * é a metade disso, porque agora o volume de requisições é maior. A TRAVA * pós-bloqueio não entra nessa conta: ela vale para cada pedido, sem desconto. * * SE UMA CONTA LEVAR BLOQUEIO LOGO DEPOIS DE MEXER NO CLOSE FRIENDS, este número * e o `RITMO_CLOSE` são os primeiros a mudar: diminuir um, alongar o outro. */ const CLOSE_POR_ACAO = 25; /** * Quanto esperar antes da próxima ação. * @param {{minMs:number, maxMs:number}} faixa * @param {() => number} sorteio injetável para o teste ser determinístico */ function intervaloEntreAcoes(sorteio = Math.random, faixa = RITMO) { return Math.round(faixa.minMs + sorteio() * (faixa.maxMs - faixa.minMs)); } /** * A pausa longa cai depois desta ação? (1-based: a 25ª, a 50ª…) * @param {number} feitas * @param {{pausaACada:number}} ritmo `RITMO` (unfollow) ou `RITMO_CLOSE` */ function toca(feitas, ritmo = RITMO) { return feitas > 0 && feitas % ritmo.pausaACada === 0; } /** * Quanto tempo, em segundos, N contas devem levar — o número que aparece na * confirmação, antes de qualquer coisa acontecer. * * Usa a MÉDIA do intervalo. Não é promessa: o sorteio pode puxar para cima ou * para baixo, e uma espera do teto de rajada entra por fora. Melhor um número * honesto e aproximado do que nenhum — sem ele, quem clica achando que são * segundos abandona no meio. */ function segundosPara(n, ritmo = RITMO) { if (n <= 0) return 0; const media = (ritmo.minMs + ritmo.maxMs) / 2; const pausas = Math.floor(Math.max(0, n - 1) / ritmo.pausaACada); return Math.round((n * media + pausas * ritmo.pausaMs) / 1000); } /* * --------------------------------------------------------------------------- * DEPOIS DE UM BLOQUEIO * --------------------------------------------------------------------------- * * Os freios acima existem para o bloqueio não acontecer. Estes existem para o * SEGUNDO não acontecer — e são a parte que faltava. * * O que acontecia até 16/08/2026: a ação levava `feedback_required`, a operação * parava, a tela dizia "pare por hoje", e nada impedia um novo clique trinta * segundos depois. Continuar batendo numa conta recém-bloqueada é o que * transforma 24 horas de bloqueio em uma semana, e uma semana em conta marcada. * O aviso sozinho não segura ninguém: quem está no meio de uma limpeza de 400 * contas tenta de novo, e a extensão deixava. * * Então a trava é da FERRAMENTA, e não um pedido: * * - "action block" (`feedback_required`/spam) → 24 h sem nenhuma escrita; * - "espere alguns minutos" (rate limit) → 1 h. É um aviso mais leve, e um * castigo de 24 horas para ele seria desproporcional — mas zero também * estava errado, porque é o Instagram dizendo, com todas as letras, que o * ritmo está alto demais AGORA. * * Verificação (`challenge`) NÃO entra aqui de propósito: a mesma exceção cobre * sessão expirada e 401/403, e travar a ferramenta por 24 horas porque alguém * precisou refazer o login seria punir o usuário por um problema que ele * resolve em dez segundos. * * E a trava TEM SAÍDA (`dispensada`). A primeira versão não tinha, e estava * errada: a conta é de quem usa, e uma trava sem saída é a ferramenta decidindo * no lugar do dono — pior ainda num produto vendido, onde ela vira 24 horas de * tela morta que ninguém pediu. Esperar continua sendo o certo, e é o padrão; * dispensar é escolha explícita, e mantém o teto reduzido da retomada. */ const CASTIGO = { bloqueio: 24 * 60 * 60 * 1000, desacelerar: 60 * 60 * 1000, }; /* * Passada a trava, a conta não volta ao ritmo cheio de uma vez: por 72 horas o * teto do dia cai para 40. * * Voltar direto a 100 no dia seguinte a um bloqueio é repetir o volume que * causou o bloqueio, com a conta ainda marcada — a janela em que o Instagram * está mais sensível é justamente a de logo depois. */ const RETOMADA_MS = 72 * 60 * 60 * 1000; const TETO_RETOMADA = 40; /** * A conta pode escrever agora, e com que teto — dado o último bloqueio. * * Lógica pura para poder ser testada: recebe o registro guardado * (`{ em, tipo }` ou nada) e o instante, devolve a situação. * * @param {{em:number, tipo:string}|null|undefined} bloqueio * @param {number} agora */ function situacaoDaConta(bloqueio, agora = Date.now()) { const em = Number(bloqueio?.em) || 0; const livre = { travada: false, faltam: 0, teto: TETO_24H, retomando: false, tipo: '' }; if (!em) return livre; const tipo = bloqueio.tipo === 'desacelerar' ? 'desacelerar' : 'bloqueio'; /* * Marca no futuro (relógio do computador adiantado, ou mexido) conta como * AGORA, e não como "já passou". Ignorar devolveria a conta ao ritmo cheio no * dia do bloqueio, que é o pior momento possível; o custo do outro lado é uma * espera indevida que se resolve acertando o relógio. */ const desde = Math.max(0, agora - em); /* * Dispensada à mão: a espera cai, mas o resto NÃO. A data do bloqueio continua * valendo, então o teto reduzido da retomada segue de pé pelas 72 horas — quem * dispensou a trava pediu para tentar de novo, não para voltar ao volume que * causou o bloqueio. */ if (bloqueio.dispensada !== true && desde < CASTIGO[tipo]) { return { travada: true, faltam: Math.ceil((CASTIGO[tipo] - desde) / 1000), teto: 0, retomando: false, tipo, }; } if (tipo === 'bloqueio' && desde < RETOMADA_MS) { return { travada: false, faltam: 0, teto: TETO_RETOMADA, retomando: true, tipo }; } return livre; } /** * O novo registro de recusa SUBSTITUI o que já está guardado? * * A regra é uma só: **o leve não apaga o grave**. Um "desacelerar" (1 h) não * pode tomar o lugar de um "action block", porque o que sobra do action block * depois da espera é o teto reduzido de 72 horas — e trocar o registro joga * fora justamente essa parte. * * O caso que fez esta função existir, e que a guarda anterior não pegava: quem * DISPENSA a trava está fazendo o que o aviso manda (tentar de novo). Se a ação * falhar — e ela falha, se o Instagram ainda estiver bloqueando —, a recusa * costuma vir como HTTP 429, que é classificado como "desacelerar". Com a * guarda presa a `travada`, esse desacelerar apagava o action block, e UMA HORA * depois a conta voltava ao teto cheio, sem aviso nenhum. O castigo de 72 horas * evaporava exatamente para quem já tinha sido bloqueado uma vez. * * Vive aqui, e não em `app.js`, para poder ser testada: é a única proteção que * sobra depois da dispensa, e ela estava no único arquivo que nenhum teste * alcança. * * @param {{em:number, tipo:string, dispensada?:boolean}|null|undefined} atual * @param {'bloqueio'|'desacelerar'} tipoNovo * @param {number} agora */ function deveRegistrar(atual, tipoNovo, agora = Date.now()) { // Um action block sempre entra: ele é o mais grave, e reinicia a contagem. if (tipoNovo !== 'desacelerar') return true; const situacao = situacaoDaConta(atual, agora); // Enquanto o action block ainda pesa — travando OU segurando o teto — ele manda. return !(situacao.tipo === 'bloqueio' && (situacao.travada || situacao.retomando)); } /** O mesmo tempo, escrito para quem vai ler na tela. */ function tempoEmTexto(segundos) { if (segundos < 60) return 'menos de um minuto'; const minutos = Math.round(segundos / 60); if (minutos < 60) return `cerca de ${minutos} minuto${minutos > 1 ? 's' : ''}`; const horas = Math.floor(minutos / 60); const resto = minutos % 60; const h = `${horas} hora${horas > 1 ? 's' : ''}`; return resto ? `cerca de ${h} e ${resto} min` : `cerca de ${h}`; } /* * Exportar a lista para arquivo — recurso do plano PRO. * * Módulo próprio porque é lógica pura (dados entram, texto sai) e por isso * testável sozinha. Quem baixa o arquivo é o app.js. * * O arquivo é montado NO NAVEGADOR e salvo pelo próprio navegador. Não há * upload, não há servidor, e não é preciso pedir a permissão `downloads` ao * Chrome: um Blob mais um link com `download` bastam. É a mesma regra que vale * para o resto da extensão — os seus dados não saem da sua máquina. */ /** As colunas do CSV, na ordem. */ const COLUNAS = [ ['username', (p) => p.username], ['nome', (p) => p.full_name || ''], ['relacao', (p) => p.relacao], ['genero', (p) => p.genero], ['close_friends', (p) => (p.close ? 'sim' : 'nao')], ['privado', (p) => (p.is_private ? 'sim' : 'nao')], ['verificado', (p) => (p.is_verified ? 'sim' : 'nao')], ['perfil', (p) => `https://www.instagram.com/${p.username}/`], ]; /* * As colunas da BUSCA POR NICHO. Outra lista, e não um remendo na de cima: a * base exporta relação e gênero (que a busca não tem), e a busca exporta nota, * votos da rede, categoria, local e bio (que a base não tem). Espremer as duas * numa lista só daria um arquivo com metade das colunas sempre vazias. * * A BIO ENTRA COM AS QUEBRAS DE LINHA DESFEITAS: é texto de terceiro, escrito * livremente, e uma bio de quatro linhas quebraria a planilha em quatro. O * escape contra fórmula (`campoCsv`) importa ainda mais aqui do que na base — * bio é o campo mais livre que existe no Instagram. */ const COLUNAS_NICHO = [ ['username', (p) => p.username], ['nome', (p) => p.full_name || ''], ['nota', (p) => p.nota], ['pessoas_da_sua_rede', (p) => p.votos || 0], ['categoria', (p) => p.categoria || ''], ['local', (p) => p.endereco || ''], ['bio', (p) => String(p.bio || '').replace(/\s+/g, ' ').trim()], ['privado', (p) => (p.is_private ? 'sim' : 'nao')], ['verificado', (p) => (p.is_verified ? 'sim' : 'nao')], ['perfil', (p) => `https://www.instagram.com/${p.username}/`], ]; /* * Escapa um campo do CSV. * * Aspas duplicadas e o campo inteiro entre aspas quando houver vírgula, aspas * ou quebra de linha — um `full_name` do Instagram pode ter qualquer um dos * três, e um único nome com vírgula desalinharia a planilha inteira. * * O apóstrofo na frente de `=`, `+`, `-` e `@` não é capricho: sem ele, um nome * começando com `=` vira FÓRMULA ao abrir no Excel ou no Google Sheets. É a * injeção de fórmula em CSV, e o dado aqui vem de perfis de terceiros. */ function campoCsv(valor) { let texto = String(valor ?? ''); if (/^[=+\-@\t\r]/.test(texto)) { texto = "'" + texto; } return /[",\n\r;]/.test(texto) ? '"' + texto.replace(/"/g, '""') + '"' : texto; } /** * A lista em CSV. * * Separador `;` e BOM no começo: é o que faz o Excel em português abrir o * arquivo com as colunas separadas e os acentos corretos, que é onde essa lista * costuma acabar. Sem os dois, a planilha abre tudo numa coluna só. */ function paraCsv(pessoas, colunas = COLUNAS) { const linhas = [colunas.map(([nome]) => nome).join(';')]; for (const p of pessoas) { linhas.push(colunas.map(([, ler]) => campoCsv(ler(p))).join(';')); } return '' + linhas.join('\r\n') + '\r\n'; } /** * A mesma lista em JSON, para quem vai reprocessar em vez de abrir na planilha. * Traz o pk, que o CSV não traz: na planilha ele só atrapalha, mas em código é * a única identificação que não muda quando a pessoa troca de @. */ function paraJson(pessoas, meta = {}) { return JSON.stringify({ ...meta, contas: pessoas }, null, 2); } /** * Nome do arquivo com a data — quem exporta duas vezes precisa saber qual é * qual, e "mutuo (3).csv" não diz nada. * * @param {string} extensao 'csv' ou 'json' * @param {Date} quando */ function nomeDoArquivo(extensao, quando, prefixo = 'mutuo') { const d = quando; const p = (n) => String(n).padStart(2, '0'); const carimbo = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` + `_${p(d.getHours())}${p(d.getMinutes())}`; return `${prefixo}_${carimbo}.${extensao}`; } /* * Histórico de mudanças entre sincronizações — recurso do plano PRO. * * Módulo próprio por ser lógica pura: duas bases entram, uma lista de eventos * sai. Nada de rede, nada de tela. * * O QUE ELE NÃO PODE FAZER, e é a razão de existir separado com teste próprio: * comparar duas leituras PARCIAIS. No plano FREE cada sincronização lê uma * amostra de 50 perfis, e a amostra seguinte pode ser outra gente — quem saiu * dela não "deixou de te seguir", só não foi sorteado desta vez. Marcar isso * como perda seria acusar pessoas erradas, que é o pior defeito possível num * histórico de seguidores. Por isso `compararBases` exige que AS DUAS leituras * sejam completas e, na dúvida, devolve lista vazia. */ const TIPOS = { perdeu: 'perdeu', // deixou de te seguir ganhou: 'ganhou', // começou a te seguir segui: 'segui', // você passou a seguir deixei: 'deixei', // você deixou de seguir }; /* * Uma leitura serve de base de comparação? Só se tiver lido tudo — e "tudo" * precisa ser CONFERIDO, não declarado. * * `parcial` é escrito à mão como `false` no caminho completo, então sozinho ele * não prova nada: uma coleta que terminasse cedo sem erro (uma página sem * `next_max_id` antes da hora) entraria aqui como completa, e todo seguidor que * faltou viraria um evento "deixou de te seguir". Numa base de 2000 que parou em * 1200, seriam 800 acusações falsas. * * Por isso a comparação com o total que o próprio perfil anuncia. A margem de * 10% existe porque os dois números divergem por natureza: o perfil conta contas * desativadas que as listas não devolvem mais. */ const MARGEM = 0.9; function bateComOTotal(lidos, total) { if (!Number.isFinite(total) || total <= 0) return true; // sem total, não dá para conferir return Number.isFinite(lidos) && lidos >= total * MARGEM; } function completa(base) { if (!base || !Array.isArray(base.followers) || !Array.isArray(base.following)) return false; const a = base.analise; if (!a || a.plano !== 'pro' || a.parcial) return false; return bateComOTotal(a.lidosSeguidores, a.totalSeguidores) && bateComOTotal(a.lidosSeguindo, a.totalSeguindo); } const porPk = (lista) => new Map(lista.map((u) => [String(u.pk), u])); /** * O que mudou entre duas leituras completas. * * @param {object|null} anterior a base guardada antes desta sincronização * @param {object} nova a base recém-lida * @param {number} quando timestamp do evento * @returns {{em:number, tipo:string, pk:string, username:string, full_name:string}[]} * vazio quando não dá para comparar — a primeira leitura de todas, ou * qualquer uma das duas sendo parcial. */ function compararBases(anterior, nova, quando) { if (!completa(anterior) || !completa(nova)) return []; const eventos = []; const registrar = (tipo, u) => eventos.push({ em: quando, tipo, pk: String(u.pk), // O nome vai junto: a pessoa pode sumir da base depois, e um histórico que // só guarda o pk vira uma lista de números. username: u.username || '', full_name: u.full_name || '', }); const seguidoresAntes = porPk(anterior.followers); const seguidoresAgora = porPk(nova.followers); const seguindoAntes = porPk(anterior.following); const seguindoAgora = porPk(nova.following); for (const [pk, u] of seguidoresAntes) { if (!seguidoresAgora.has(pk)) registrar(TIPOS.perdeu, u); } for (const [pk, u] of seguidoresAgora) { if (!seguidoresAntes.has(pk)) registrar(TIPOS.ganhou, u); } for (const [pk, u] of seguindoAgora) { if (!seguindoAntes.has(pk)) registrar(TIPOS.segui, u); } for (const [pk, u] of seguindoAntes) { if (!seguindoAgora.has(pk)) registrar(TIPOS.deixei, u); } return eventos; } /* * Quantos eventos ficam guardados. O `chrome.storage.local` tem limite de * espaço, e um histórico que cresce para sempre acabaria derrubando a gravação * da BASE — que é o dado que não pode se perder. Os mais novos ficam. */ const TETO_HISTORICO = 2000; /** Junta os eventos novos aos guardados, do mais recente para o mais antigo. */ function juntarHistorico(guardado, novos) { // `guardado` nulo virava um `null` dentro do array, e `renderHistorico` // estourava ao ler `e.tipo` — protegido hoje só pelo Array.isArray de quem // carrega, que é longe demais daqui. return [...(novos || [])].concat(guardado || []).slice(0, TETO_HISTORICO); } /* * A PONTE — content script em instagram.com. * * POR QUE ELA EXISTE (18/08/2026). * * As rotas REST de amizade morreram e a escrita passou para * `POST /api/graphql`. Só que o Instagram RECUSA essa mutação quando ela parte * da extensão: responde `error: 1357004` ("Feche e abra a janela do seu * navegador novamente"), que é o código do `fb_dtsg` inválido. * * Não é o token, e isso foi medido, não suposto. A MESMA chamada, com o MESMO * token e o MESMO conjunto de cabeçalhos, disparada de dentro de uma página do * instagram.com, responde 200 com o JSON certo. A única diferença que sobra é a * origem: `chrome-extension://…` de um lado, `https://www.instagram.com` do * outro. Ler o Instagram deixa passar; ESCREVER não. * * Daí a ponte: a requisição sai de uma página do instagram.com, na sessão da * própria pessoa. Repare no que isso NÃO é — não há Origin reescrito, nem * User-Agent trocado, nem nada fingindo ser o que não é. A requisição é mesmo * da página. É a mesma regra que já governava os cabeçalhos neste projeto: * parecer com o site legítimo por SER o site legítimo, nunca por disfarce. * * ELA É UM CANO BURRO, DE PROPÓSITO. Repassa a requisição e devolve o que * voltou, cru: status, texto e o claim. Nada de decidir se a resposta é recusa * da conta, caminho errado ou bloqueio — essa classificação vive em * `instagram.js` (`recusaDaConta`), é o que segura os freios da conta, e está * coberta por teste. Duplicá-la aqui criaria um segundo lugar para ela * divergir, que é exatamente como um freio deixa de frear sem ninguém notar. */ /** * Os tokens do cliente web, raspados de um HTML do Instagram. * * `bkv` é a versão do Bloks (`WebBloksVersioningID`), que o pedido do Close * Friends leva na URL desde 17/09/2026. Mora no mesmo bloco de configuração que * o `fb_dtsg`, e por isso sai daqui e não de uma constante que envelheceria. */ function tokensDoHtml(html) { const pegar = (re) => { const achado = html.match(re); return achado ? achado[1] : ''; }; return { dtsg: pegar(/"DTSGInitialData",\[\],\{"token":"([^"]+)"/), lsd: pegar(/"LSD",\[\],\{"token":"([^"]+)"/), rev: pegar(/"__spin_r":(\d+)/), bkv: pegar(/"WebBloksVersioningID",\[\],\{"versioningID":"([0-9a-f]+)"/), }; } chrome.runtime.onMessage.addListener((msg, remetente, responder) => { if (!msg || typeof msg.tipo !== 'string' || !msg.tipo.startsWith('mutuo-')) { return undefined; } if (msg.tipo === 'mutuo-ping') { responder({ ok: true }); return undefined; } if (msg.tipo === 'mutuo-tokens') { (async () => { let tk = tokensDoHtml(document.documentElement.innerHTML); /* * O HTML da SPA já montada pode ter perdido o bloco inicial. Nesse caso * vale buscar a home — daqui é mesma origem, então o custo é só a * requisição, e ela responde o HTML logado. Faltar só o `bkv` também * busca: sem ele o Close Friends não sai, e o custo é o mesmo. */ if (!tk.dtsg || !tk.bkv) { try { const html = await (await fetch('/', { credentials: 'include' })).text(); tk = tokensDoHtml(html); } catch (erro) { responder({ ok: false, erro: String((erro && erro.message) || erro) }); return; } } responder({ ok: true, ...tk }); })(); return true; } if (msg.tipo === 'mutuo-fetch') { (async () => { try { const res = await fetch(msg.url, { method: msg.metodo || 'GET', credentials: 'include', headers: msg.cabecalhos || {}, body: msg.corpo, }); responder({ ok: true, status: res.status, url: res.url, redirected: res.redirected, claim: res.headers.get('x-ig-set-www-claim'), texto: await res.text(), }); } catch (erro) { responder({ ok: false, erro: String((erro && erro.message) || erro) }); } })(); return true; } return undefined; }); /* * Anuncia-se ao carregar. A tela pode estar esperando por esta aba — foi ela * que mandou abrir — e esperar por um aviso é mais barato que ficar sondando. * Sem ninguém ouvindo, `sendMessage` rejeita; aqui isso é normal, não erro. */ chrome.runtime.sendMessage({ tipo: 'mutuo-ponte-pronta' }).catch(() => {}); /* * Coleta de seguidores e seguindo pelos endpoints internos do Instagram — * os mesmos que o site chama quando você rola a lista. * * As requisições partem do SEU navegador, com a SUA sessão (os cookies que já * estão no instagram.com). Nada passa por servidor nenhum. É por isso que este * modelo não corre o risco de "IP de datacenter" que uma versão hospedada * correria. * * O ritmo e as paradas abaixo são proteção, não detalhe — portados das decisões * do backend original. Ler estes endpoints ainda contraria os Termos de Uso do * Instagram, e o risco prático é a conta receber um pedido de verificação. * REDUZIR as pausas ou insistir contra uma verificação é o caminho para o * bloqueio. Se for mexer, aumente as pausas — nunca diminua. */ const APP_ID = '936619743392459'; const BASE = 'https://www.instagram.com'; /* * OS CABEÇALHOS QUE O SITE ENVIA E NÓS NÃO ENVIÁVAMOS (18/08/2026). * * Sintoma: escrever deixou de funcionar, e o diagnóstico apontava para a rota * errada. `/api/v1/web/friendships/{pk}/follow/` passou a responder 404, e a * conclusão natural — "a rota morreu" — estava errada. A prova veio de * `show_many/`: MESMA rota, 200 no site do Instagram e 404 aqui, na mesma * sessão e no mesmo navegador. Rota igual e resposta diferente só sobra uma * explicação: o que vai junto da requisição. * * Capturado do próprio site, na aba Network, ao clicar em Seguir: * * x-asbd-id 359341 * x-ig-www-claim hmac.AR0Y_… (derivado da sessão, muda) * x-instagram-ajax 1045433650 (o número do BUILD, não "1") * * Note o que NÃO é feito aqui: nada de trocar o User-Agent nem de fingir ser o * aplicativo de celular. Estes são os cabeçalhos do cliente WEB, mandados do * navegador da própria pessoa — ficar mais parecido com o site legítimo é o * oposto de disfarce, e é o que a regra do projeto pede. */ const ASBD_ID = '359341'; /* * O build do site. Envelhece a cada atualização do Instagram, e é o primeiro * suspeito quando escrever voltar a falhar com HTML. Para renovar: F12 no * instagram.com, aba Network, qualquer requisição, header `x-instagram-ajax`. */ const AJAX_BUILD = '1045433650'; /* * O claim vem do servidor e volta para ele na requisição seguinte. Começa em * '0', que é o que um cliente sem claim manda — e é aceito. Guardado só em * memória, de propósito: é derivado da sessão, e persistir um claim de uma * sessão encerrada mandaria credencial velha em toda chamada. */ let wwwClaim = '0'; /** Aprende o claim que o servidor mandou, se mandou. */ function guardarClaim(res) { /* * O `?.` não é frouxidão: o claim é OPCIONAL, e uma resposta sem ele — ou * sem `headers` legíveis — não pode derrubar a chamada que já deu certo. * Sem esta guarda, um `TypeError` aqui matava a coleta INTEIRA depois de o * Instagram já ter respondido os dados; foi o que quebrou cinco arquivos de * teste de uma vez, e em produção teria quebrado a sincronização. */ const novo = res.headers?.get?.('x-ig-set-www-claim'); if (novo) wwwClaim = novo; } /** Os cabeçalhos comuns a leitura e escrita. */ async function cabecalhos(csrf, extras = {}) { return { 'x-ig-app-id': APP_ID, 'x-csrftoken': await csrfAtual(csrf), 'x-requested-with': 'XMLHttpRequest', 'x-asbd-id': ASBD_ID, 'x-ig-www-claim': wwwClaim, 'x-instagram-ajax': AJAX_BUILD, ...extras, }; } const PAGE_SIZE = 50; // Faixa de pausa entre páginas, mais uma pausa longa periódica. Cadência // constante é fácil de detectar; por isso o valor é sorteado a cada página. // // Estes valores foram REDUZIDOS a pedido, para a coleta ser mais rápida. O // ritmo ainda existe, e ainda é proteção: são os endpoints internos, e uma // enxurrada de requisições continua podendo render um pedido de verificação. // A favor da redução: aqui a coleta sai do SEU navegador, com o SEU IP // residencial — bem menos suspeito que um servidor. Se quiser mais velocidade, // saiba que é por sua conta e risco; se começar a tomar verificação, aumente // estes números de volta. const PACE = { minDelayMs: 900, maxDelayMs: 2200, longPauseEvery: 15, longPauseMs: 8000, }; function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function pace(page) { const span = PACE.maxDelayMs - PACE.minDelayMs; await delay(PACE.minDelayMs + Math.random() * span); // A cada N páginas, uma pausa mais longa — imita quem para para ler. if (page % PACE.longPauseEvery === 0) { await delay(PACE.longPauseMs); } } async function cookie(name) { const found = await cookiesNativos.get({ url: BASE + '/', name }); return found ? found.value : ''; } /** Erros que a interface trata de formas diferentes. */ class ChallengeError extends Error {} class NotLoggedInError extends Error {} class RateLimitError extends Error {} class ActionBlockedError extends Error {} /* * O PERFIL de outra pessoa não abriu — e isso não é problema da SUA conta. * Nasceu com a busca por nicho, que lê listas de terceiros: ver o comentário * em `recusaDaConta`. */ class PerfilFechadoError extends Error {} /* * O `csrftoken` do cookie, sempre relido na hora. * * Ele NÃO é fixo: o Instagram troca esse cookie de tempos em tempos, e uma * sincronização leva minutos e centenas de requisições. Guardar o valor do * começo e usar até o fim é o que fazia as ESCRITAS começarem a receber a * página em HTML no meio do caminho, enquanto as leituras seguiam normais — * GET o Instagram deixa passar, POST com token velho ele recusa. O parâmetro * fica como reserva para quando o cookie não puder ser lido. */ async function csrfAtual(guardado) { const doCookie = await cookie('csrftoken').catch(() => ''); return doCookie || guardado || ''; } async function apiGet(path, csrf) { const res = await fetch(BASE + path, { method: 'GET', headers: await cabecalhos(csrf, { accept: '*/*' }), // Inclui os cookies do instagram.com. Só é possível porque o manifest // declara host_permissions para instagram.com. credentials: 'include', }); guardarClaim(res); return res; } /** O texto como objeto, ou `null` se não for JSON (a página em HTML, por ex.). */ /* * O PREFIXO ANTI-SEQUESTRO DE JSON, e por que ele precisa cair aqui. * * O `/api/graphql` da Meta responde com `for (;;);` grudado na frente do JSON * (e, em alguns caminhos, `)]}'`). É proteção antiga contra ler JSON por tag *