Pular para o conteúdo
Node.js

Google Maps API: extraindo telefones de uma cidade

Paloma Macetko
Ilustração mágica: um unicórnio de crina colorida usa uma varinha para projetar um mapa de cidade em linhas de luz sobre uma mesa, com pinos de localização flutuando, enquanto uma coruja segura um cartão com um telefone e um castelo de velas flutuantes aparece ao fundo

Olá meus Unicórnios! 🦄✨

Precisei montar uma lista de estabelecimentos de uma cidade — nome, endereço e telefone — para alimentar uma ferramenta de prospecção. Parecia meia hora de trabalho: chama a API do Google Maps, pega os telefones, acabou. 😅

Só que quase tudo que eu lembrava da API do Places estava desatualizado. A API nova mudou o endereço, mudou o método, mudou o nome dos campos, e — a parte que mais me pegou — ela não devolve absolutamente nada se você não pedir. Nem os campos, nem a paginação. 🙃

Este artigo é o que eu queria ter lido antes de começar. O código é Node.js puro, sem dependência nenhuma, e roda de verdade — todas as saídas de terminal aqui vieram de buscas de verdade em Brusque/SC enquanto eu escrevia. Só uma ressalva importante: os nomes e telefones dos estabelecimentos foram trocados por fictícios. 🙏 Telefone de comércio pequeno — ainda mais o celular de uma dentista que atende sozinha — é dado pessoal, e não vou publicar o de ninguém para ilustrar tutorial. Os números de quantos vieram e quantos ficaram de fora são os de verdade, que é o que interessa para o que o artigo ensina. 🚀

Exemplos_GoogleMapsTelefones no GitHubO script completo, sem dependência. Só precisa da sua chave da Places API.github.com

🗺️ Places API (New): o que mudou

Existem hoje duas APIs de Places convivendo, e quase todo tutorial que você acha no Google ainda ensina a antiga. Vale saber diferenciar, porque os nomes dos campos não são os mesmos:

  • Legada: GET em maps.googleapis.com/maps/api/place/textsearch/json, chave na query string, campo de telefone chamado formatted_phone_number.
  • Nova (v1): POST em places.googleapis.com/v1/places:searchText, chave no header X-Goog-Api-Key, telefone em nationalPhoneNumber.

Se você tentar pedir formattedPhoneNumber na API nova, ela te responde na lata — e o erro, ao menos, é honesto:

{
  "error": {
    "code": 400,
    "message": "Request contains an invalid argument.",
    "status": "INVALID_ARGUMENT",
    "details": [
      {
        "fieldViolations": [
          {
            "field": "places.formattedPhoneNumber",
            "description": "Cannot find matching fields for path."
          }
        ]
      }
    ]
  }
}

Mas a maior mudança não é o nome do campo. É que a API nova não devolve nada por padrão. 🤯

🎭 A FieldMask: você paga pelo que pede

Na API nova, todo request precisa de um header X-Goog-FieldMask listando os campos que você quer. Sem ele, não vem uma resposta vazia — vem um erro:

curl -s -X POST 'https://places.googleapis.com/v1/places:searchText' \
  -H 'Content-Type: application/json' \
  -H "X-Goog-Api-Key: $KEY" \
  -d '{"textQuery":"pizzaria em Brusque, SC"}'

E a resposta, HTTP 400:

{
  "error": {
    "code": 400,
    "message": "FieldMask is a required parameter.",
    "status": "INVALID_ARGUMENT"
  }
}

Isso parece chatice, mas é proteção de fatura: o Google cobra a busca por SKU, e os campos que você pede definem em qual faixa de preço o request cai. Telefone e site estão na faixa mais cara. Pedir places.displayName sozinho é bem mais barato do que pedir o pacote completo.

Ou seja: a FieldMask é a diferença entre uma conta de dez dólares e uma de cem. Peça só o que você vai usar de verdade. 💸

😳 A mentira mais cara: a paginação que morre calada

Aqui está o erro que me custou mais tempo, e o motivo pelo qual eu escrevi este artigo. Repare nesta FieldMask, que parece perfeitamente razoável:

-H 'X-Goog-FieldMask: places.displayName,places.nationalPhoneNumber'

Ela funciona. Devolve HTTP 200, devolve 20 pizzarias com telefone, não emite aviso nenhum. E silenciosamente destrói a sua paginação. Foi exatamente o que eu vi no terminal:

=== A) FieldMask SEM nextPageToken ===
chaves: places | token: AUSENTE (paginacao morre aqui)

O motivo é cruel na sua elegância: nextPageToken é um campo da resposta como qualquer outro. Se ele não está na FieldMask, o Google não o inclui. E sem token não existe página 2 — o seu laço de paginação encerra achando, educadamente, que a cidade só tem 20 pizzarias.

Repare no detalhe que faz a diferença: ele não leva o prefixo places., porque não está dentro do array de lugares — está na raiz da resposta:

const CAMPOS = [
  'places.id',
  'places.displayName',
  'places.nationalPhoneNumber',
  'nextPageToken',            // <- sem "places." e sem ele nao ha pagina 2
].join(',');

Com o token no lugar, a mesma busca respira:

chaves no topo: places, nextPageToken
places: 20
nextPageToken: SIM

📞 O telefone vem junto (e isso é novidade)

Na API legada, buscar era só o começo: a busca devolvia os lugares sem telefone, e você tinha que fazer uma chamada de Place Details para cada um. Vinte estabelecimentos = 1 busca + 20 chamadas extras.

Na API nova, o telefone vem na própria resposta da busca, desde que você o peça na FieldMask. Um request, vinte telefones. É de longe a maior economia da migração — some com 95% das chamadas.

São dois campos, e vale pedir os dois com um fallback, porque nem todo cadastro tem o nacional preenchido:

telefone: p.nationalPhoneNumber || p.internationalPhoneNumber || '',
  • nationalPhoneNumber(47) 3000-0000
  • internationalPhoneNumber+55 47 3000-0000

🕳️ O buraco silencioso: nem todo lugar tem telefone

Esta é a segunda armadilha, e ela é de dados, não de API. Uma parte dos estabelecimentos simplesmente não tem telefone cadastrado no Google. Na minha busca por pizzarias:

  50 lugares, 46 com telefone, 4 sem

  sem telefone (descartados por um filtro distraído):
    Pizzaria Tia Fada
    Lanchonete e Pizzaria Sereia
    Pizzaria Maria Estrela
    DOSS Local Pizza Checkpoint

O problema não é que eles existam — é o que o código costuma fazer com eles. Esta linha, que parece defensiva e sensata, apaga os quatro sem dizer nada:

.filter((c) => c.name && c.phone)

Você pediu 50 lugares e importou 46. O relatório diz "importação concluída". Ninguém percebe — até alguém perguntar por que a Pizzaria Tia Frida, que fica na esquina, não está na lista. 🤔

Não estou dizendo para não filtrar: sem telefone, o lead é inútil para quem vai ligar. Estou dizendo para contar quantos você descartou e mostrar esse número. É por isso que o meu script imprime os dois totais e ainda lista os descartados.

🔢 O teto real: 60 resultados, não 100

Todo mundo repete que o Places para em 60 resultados. Eu quis conferir, e o número bate — mas o caminho até ele tem um detalhe. Paginei até o token acabar:

=== profundidade: quantas paginas ate acabar? ===
  pagina 1: 20 resultados (acumulado 20) | proximo token: SIM
  pagina 2: 20 resultados (acumulado 40) | proximo token: SIM
  pagina 3: 20 resultados (acumulado 60) | proximo token: AUSENTE

Três páginas de 20 e o token some. 60 é o teto por busca, confirmado empiricamente.

Isso importa porque o código que eu usei como referência trazia um maxPages = 5, mirando 100 resultados. Não é errado — é só otimista: o laço nunca chega na quarta volta, porque a API encerra antes. Quem lê o código acha que o limite do sistema é 100; o limite real é 60. E se você precisa de mais que isso numa cidade grande, não existe parâmetro que resolva — a saída é fatiar a busca em vários centros geográficos ou em vários termos, e deduplicar pelo places.id.

🏷️ O erro que eu cometi: includedType não fala português

Eu escrevi o script, rodei confiante, e tomei um 400 na cara na primeira execução:

node telefones.js "pizzaria em Brusque, SC" pizzaria
Error: HTTP 400: Invalid included_type: 'pizzaria'. See full list of supported
types at https://developers.google.com/maps/documentation/places/web-service/
supported_types#table1

Eu tinha jogado o mesmo texto em dois lugares que não aceitam a mesma coisa:

  • textQuery é texto livre, em português: "pizzaria em Brusque, SC" funciona lindamente.
  • includedType é um slug do catálogo do Google: pizza_restaurant, dentist, gym. Português ali é 400 na hora.

Foi por isso que, no sistema que usei de referência, existe uma tabela inteira mapeando "Pizzaria" → pizza_restaurant, "Dentista" → dentist, com uns 250 tipos. Eu achava exagero até levar o 400. 😅

E o includedType ganha o seu lugar quando você vê o que a busca por texto puro devolve. Sem ele, "pizzaria em Brusque" me trouxe isto na segunda página:

  Mr. Grelha                       (47) 90000-0012
  Sushi do Dragao                  (47) 3000-0013
  Rosa Mexicana - Brusque          (47) 3000-0014
  Alexandria Burger Brusque        (47) 90000-0015

Sushi, comida mexicana e hambúrguer numa busca por pizzaria. 🍣 O textQuery é generoso demais — ele entende "pizzaria" como "lugar de comer". Com includedType: 'pizza_restaurant', a lista vira pizzaria de verdade, do primeiro ao último.

📍 locationBias é sugestão, não cerca

Mais um detalhe que engana pelo nome. Você manda um círculo com centro e raio, e é natural ler isso como "só me traga o que está aqui dentro". Não é o que acontece:

  • locationBiaspreferência. Resultados de fora do círculo podem aparecer, e aparecem.
  • locationRestrictionfiltro de verdade. Fora do círculo não entra.

Se a sua interface tem um slider de "raio de busca", vale saber qual dos dois você está usando — porque com locationBias aquele slider é um conselho amigável, não uma promessa. Combinado com rankPreference: 'DISTANCE', o efeito prático é bom: os mais próximos vêm primeiro, e a vizinhança entra se faltar gente.

💰 Cache: onde mora a economia (e um bug de unidade)

A busca é a parte cara. Duas pessoas procurando "dentista em Brusque" no mesmo dia deveriam custar um request, não dois — e a solução padrão é cachear pela assinatura da busca:

export const buildSearchCacheKey = ({ placeType, lat, lng, radius, keyword }) => {
  const norm = {
    placeType: String(placeType || '').trim().toLowerCase(),
    // Arredonda coordenadas para 6 casas — evita chaves distintas por ruido de float.
    lat: Number(lat).toFixed(6),
    lng: Number(lng).toFixed(6),
    radius: String(Number(radius) || 0),
    keyword: String(keyword || '').trim().toLowerCase(),
  };
  const raw = `${norm.placeType}|${norm.lat}|${norm.lng}|${norm.radius}|${norm.keyword}`;
  return createHash('sha256').update(raw).digest('hex');
};

O arredondamento das coordenadas é o detalhe esperto: sem ele, -27.0989969 e -27.09899690000001 geram chaves diferentes e o cache nunca acerta. Seis casas ≈ 10 cm — mais que suficiente para dizer que é a mesma busca.

E aqui vai o achado que eu não esperava. O TTL é gravado assim:

const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000);

O valor vem de uma variável de ambiente. Na documentação do projeto, "TTL: 24 horas". No .env da máquina:

GOOGLE_PLACES_CACHE_TTL=604800

604800 é o número de segundos em uma semana — clássico, todo mundo já escreveu isso. Só que o código lê minutos. A conta que eu fiz para ter certeza:

$ php -r 'echo round(604800/60/24)." dias\n";'
420 dias

Um cache de 24 horas virou um cache de 420 dias. 😱 E o detalhe que transforma isso de bug em dor de cabeça: o expiresAt é absoluto, calculado na hora da gravação. Corrigir a variável não conserta o que já está no banco — aquelas linhas continuam válidas até 2027, servindo telefone velho de estabelecimento que já fechou.

📦 O script completo

É o arquivo que gerou todas as saídas deste artigo — Node 18+, sem dependência nenhuma, fetch nativo. Salve como telefones.js:

GOOGLE_PLACES_API_KEY=sua_chave node telefones.js "pizzaria em Brusque, SC" pizza_restaurant
/**
 * Extrai nome, telefone e endereço de empresas de uma cidade
 * usando a Places API (New) do Google.
 *
 *   GOOGLE_PLACES_API_KEY=sua_chave node telefones.js "pizzaria em Brusque, SC"
 *   GOOGLE_PLACES_API_KEY=sua_chave node telefones.js "dentista em Brusque, SC" dentist
 *
 * O 2º argumento é o includedType — opcional, e tem de ser um slug oficial
 * (pizza_restaurant, dentist, gym). Português ali devolve 400.
 *
 * Node 18+ (fetch nativo). Sem dependências.
 */

const CHAVE = process.env.GOOGLE_PLACES_API_KEY;
const URL_BUSCA = 'https://places.googleapis.com/v1/places:searchText';

// Cada campo pedido tem custo. E nextPageToken NÃO leva o prefixo "places.":
// sem ele na máscara, a resposta vem sem token e a paginação para na página 1.
const CAMPOS = [
  'places.id',
  'places.displayName',
  'places.formattedAddress',
  'places.nationalPhoneNumber',
  'places.internationalPhoneNumber',
  'nextPageToken',
].join(',');

/** Busca todas as páginas de uma consulta. Na prática o Google entrega até 60. */
async function buscar(textQuery, tipo) {
  const lugares = [];
  const vistos = new Set();
  let pageToken;

  do {
    const corpo = { textQuery, languageCode: 'pt-BR', regionCode: 'BR', pageSize: 20 };
    if (tipo) corpo.includedType = tipo;
    if (pageToken) corpo.pageToken = pageToken;

    const resposta = await fetch(URL_BUSCA, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Goog-Api-Key': CHAVE,
        'X-Goog-FieldMask': CAMPOS,
      },
      body: JSON.stringify(corpo),
    });

    const dados = await resposta.json();
    // O erro vem em JSON e explica a causa — mostrar só o status esconde ela.
    if (!resposta.ok) throw new Error(`HTTP ${resposta.status}: ${dados.error.message}`);

    for (const p of dados.places || []) {
      // Dedup pelo id, nunca pelo nome: filiais da mesma marca têm telefones diferentes.
      if (vistos.has(p.id)) continue;
      vistos.add(p.id);
      lugares.push({
        nome: p.displayName.text,
        telefone: p.nationalPhoneNumber || p.internationalPhoneNumber || '',
        endereco: p.formattedAddress || '',
      });
    }

    pageToken = dados.nextPageToken;
  } while (pageToken);

  return lugares;
}

(async () => {
  const termo = process.argv[2];
  const tipo = process.argv[3];

  if (!CHAVE || !termo) {
    console.error('Uso: GOOGLE_PLACES_API_KEY=chave node telefones.js "<busca>" [tipo]');
    process.exit(1);
  }

  const lugares = await buscar(termo, tipo);
  const comTelefone = lugares.filter((l) => l.telefone);

  // Mostrar quantos ficaram de fora: o filtro apaga esses registros em silêncio.
  console.log(`\n${lugares.length} lugares, ${comTelefone.length} com telefone,`
    + ` ${lugares.length - comTelefone.length} sem\n`);

  for (const l of comTelefone) {
    console.log(`  ${l.nome.padEnd(36).slice(0, 36)} ${l.telefone}`);
  }
  console.log();
})();

E a saída, rodando aqui enquanto eu escrevia (com os nomes e telefones trocados por fictícios, como avisei lá em cima):

node telefones.js "dentista em Brusque, SC" dentist
60 lugares, 56 com telefone, 4 sem

  Dra Amanda Estrelar - Brusque        (47) 90000-0001
  Dra Nicole Arco-Iris - Magnifica     (47) 3000-0002
  Odonto Unicornio Brusque             (47) 90000-0003
  Dra. Juliana Lunar                   (47) 90000-0004
  Vitalit Odontologia Especializada    (47) 90000-0005
  Leoni Odontologia e Estética         (47) 3000-0006
  Dr Delvio Francisco da Coruja        (47) 90000-0007
  Dra. Adriana Cristal                 (47) 90000-0008
  Dra. Samira Boreal - Dentista em B   (47) 90000-0009
  Odonto Clin - Clínica Odontológica   (47) 90000-0010
  Clínica Odontológica Estelar | Brusq (47) 90000-0011

Repare que deu exatamente 60 — o teto, atingido em três páginas. Numa cidade com mais dentistas que isso, esses 60 são todos que eu consigo por esta busca. E quatro deles não têm telefone. 🤷‍♀️

⚖️ Antes de sair extraindo: o lado chato

Não dá para escrever um artigo sobre extrair telefone em massa e fingir que isso não tem consequência. Três coisas que valem mais que o código:

  • Os termos do Google. Cachear resultado do Places tem limite de tempo previsto em contrato, e revender os dados brutos não é permitido. Ler os termos custa dez minutos e evita perder a chave.
  • LGPD. Telefone comercial de empresa é um caso mais tranquilo que dado pessoal — mas telefone de autônomo (aquela dentista que atende no consultório dela) é dado pessoal, sim. Guardar exige base legal e finalidade.
  • Bom senso. Uma lista de 60 telefones não é permissão para 60 ligações não solicitadas. A ferramenta boa é a que ajuda a chegar em quem quer ser encontrado.

✅ O resumo que eu colaria na parede

  • FieldMask é obrigatória e define o preço — peça só o que usar.
  • nextPageToken tem que estar na FieldMask, sem o prefixo places., ou a paginação morre em silêncio.
  • Telefone vem na busca (nationalPhoneNumber), sem Place Details — a economia da API nova.
  • 60 é o teto por busca, em três páginas. Precisa de mais? Fatie por região e deduplique pelo id.
  • includedType é slug do Google, não português — e é ele que impede sushi de entrar na busca por pizzaria.
  • locationBias não é cerca; quem restringe é locationRestriction.
  • Conte os descartados. Nem todo lugar tem telefone, e o filtro que os apaga não avisa.
  • Ponha a unidade no nome da variável de TTL. Pergunte aos meus 420 dias. 😅

💾 O repositório

O script está lá inteiro, e o README resume as armadilhas — a da FieldMask em primeiro lugar, que é a que devolve menos dados sem avisar:

Exemplos_GoogleMapsTelefones no GitHubnode telefones.js "pizzaria em Brusque, SC". Node 18+, zero dependência.github.com

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Leia também