Google Maps API: extraindo telefones de uma cidade
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. 🚀
🗺️ 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:
GETemmaps.googleapis.com/maps/api/place/textsearch/json, chave na query string, campo de telefone chamadoformatted_phone_number. - Nova (v1):
POSTemplaces.googleapis.com/v1/places:searchText, chave no headerX-Goog-Api-Key, telefone emnationalPhoneNumber.
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-0000internationalPhoneNumber→+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:
locationBias— preferência. Resultados de fora do círculo podem aparecer, e aparecem.locationRestriction— filtro 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.
nextPageTokentem que estar na FieldMask, sem o prefixoplaces., 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.locationBiasnã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:
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Leia também
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.