geo-visibility-cli 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- geo_check/__init__.py +3 -0
- geo_check/analysis.py +358 -0
- geo_check/cli.py +180 -0
- geo_check/dryrun.py +38 -0
- geo_check/history.py +156 -0
- geo_check/html_report.py +177 -0
- geo_check/perplexity.py +84 -0
- geo_check/questions.py +54 -0
- geo_check/report.py +356 -0
- geo_check/robots.py +139 -0
- geo_visibility_cli-0.2.1.dist-info/METADATA +218 -0
- geo_visibility_cli-0.2.1.dist-info/RECORD +16 -0
- geo_visibility_cli-0.2.1.dist-info/WHEEL +5 -0
- geo_visibility_cli-0.2.1.dist-info/entry_points.txt +2 -0
- geo_visibility_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
- geo_visibility_cli-0.2.1.dist-info/top_level.txt +1 -0
geo_check/__init__.py
ADDED
geo_check/analysis.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Answer analysis: brand mentions, cited domains, competitor candidates."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import unicodedata
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def normalize(text: str) -> str:
|
|
10
|
+
"""Lowercase and strip accents."""
|
|
11
|
+
decomposed = unicodedata.normalize("NFKD", text)
|
|
12
|
+
stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
|
|
13
|
+
return stripped.casefold()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def count_mentions(text: str, brand: str) -> int:
|
|
17
|
+
"""Count case/accent-insensitive occurrences of brand as a whole phrase."""
|
|
18
|
+
b = normalize(brand).strip()
|
|
19
|
+
if not b:
|
|
20
|
+
return 0
|
|
21
|
+
pattern = r"(?<!\w)" + r"\s+".join(re.escape(p) for p in b.split()) + r"(?!\w)"
|
|
22
|
+
return len(re.findall(pattern, normalize(text)))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def domain_of(url: str) -> str:
|
|
26
|
+
host = urlparse(url if "//" in url else "//" + url).netloc.lower()
|
|
27
|
+
host = host.split("@")[-1].split(":")[0]
|
|
28
|
+
return host[4:] if host.startswith("www.") else host
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def rank_domains(urls: list[str]) -> list[tuple[str, int]]:
|
|
32
|
+
counts = Counter(d for d in (domain_of(u) for u in urls) if d)
|
|
33
|
+
return sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --------------------------------------------------------------------------
|
|
37
|
+
# Competitor candidates
|
|
38
|
+
# --------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
_CAPW = r"[A-ZÀ-Ý][\w&'’-]*" # capitalized word; no dots, so a sentence end stops it
|
|
41
|
+
_CONN = r"(?:de|da|do|dos|das|di|von|van|of|&)" # connectors allowed inside weak names
|
|
42
|
+
_CONN_ALL = r"(?:de|da|do|dos|das|di|von|van|of|e|and|&)" # inside bold/heading/list names
|
|
43
|
+
_TITLEW = r"(?:Dr[a]?|Prof[a]?|Doutor[a]?)"
|
|
44
|
+
_TOKEN = rf"(?:{_CAPW}|\d[\w-]*)"
|
|
45
|
+
|
|
46
|
+
_SEQ_RE = re.compile(rf"{_CAPW}(?:[ \t]+(?:{_CONN}[ \t]+)?{_CAPW})*")
|
|
47
|
+
_NAMELIKE_RE = re.compile(
|
|
48
|
+
rf"(?:{_TITLEW}\.?[ \t]+)?{_TOKEN}(?:[ \t]+(?:{_CONN_ALL}[ \t]+)?{_TOKEN})*"
|
|
49
|
+
)
|
|
50
|
+
_TITLE_RE = re.compile(
|
|
51
|
+
rf"\b{_TITLEW}\.?[ \t]+{_CAPW}(?:[ \t]+(?:(?:de|da|do|dos|das|di|von|van)[ \t]+)?{_CAPW}){{0,3}}"
|
|
52
|
+
)
|
|
53
|
+
_SEQ_WORDS = rf"{_CAPW}(?:[ \t]+(?:{_CONN}[ \t]+)?{_CAPW})*"
|
|
54
|
+
_BIZ_PREFIX_RE = re.compile(
|
|
55
|
+
r"\b(?:Cl[ií]nicas?|Odonto|Odontologia|Instituto|Centro|Studio|Est[uú]dio|Consult[oó]rio|"
|
|
56
|
+
r"Hospital|Laborat[oó]rio|Escrit[oó]rio|Grupo|Clinic|Institute|Center|Centre|Group|"
|
|
57
|
+
rf"Law[ \t]+(?:Firm|Offices?|Group)|Dental|Dentistry)[ \t]+{_SEQ_WORDS}"
|
|
58
|
+
)
|
|
59
|
+
_BIZ_SUFFIX_RE = re.compile(
|
|
60
|
+
rf"{_CAPW}(?:[ \t]+(?:{_CONN}[ \t]+)?{_CAPW})*?[ \t]+"
|
|
61
|
+
r"(?:Law[ \t]+Firm|Clinic|Dental|Dentistry|Advogados|Associados|Associates|Odontologia)\b"
|
|
62
|
+
)
|
|
63
|
+
_BOLD_RE = re.compile(r"\*\*([^*\n]{2,80}?)\*\*")
|
|
64
|
+
_LIST_RE = re.compile(r"^\s*(?:[-*+•]|\d+[.)])\s+")
|
|
65
|
+
_HEAD_RE = re.compile(r"^\s*#{1,6}\s+")
|
|
66
|
+
_SEP_RE = re.compile(r"\s+[–—-]\s+|\s*:\s|\s*:$")
|
|
67
|
+
_LEAD_CUT_RE = re.compile(r"\s+[–—-]\s+|\s*:\s*|\s*\(")
|
|
68
|
+
_TITLE_PREFIX_RE = re.compile(r"^(?:dra?|drs?|profa?|doutora?)\.?\s+")
|
|
69
|
+
_HAS_TITLE_RE = re.compile(r"^(?:dra?|drs?|profa?|doutora?)\.?\s", re.I)
|
|
70
|
+
|
|
71
|
+
_CONNECT = {"de", "da", "do", "dos", "das", "di", "von", "van", "of", "e", "and"}
|
|
72
|
+
|
|
73
|
+
_STOP = {
|
|
74
|
+
normalize(w)
|
|
75
|
+
for w in (
|
|
76
|
+
# articles, prepositions, conjunctions, pronouns (PT)
|
|
77
|
+
"O Os A As Um Uma Uns Umas Em No Na Nos Nas Para Por Pelo Pela Com Sem Que Se Como "
|
|
78
|
+
"Quando Onde Mas Ou E De Do Da Dos Das Ao Aos Isso Isto Esse Essa Esses Essas Este "
|
|
79
|
+
"Esta Estes Estas Aquele Aquela Aqui Ali Lá Também Porém Contudo Todavia Portanto "
|
|
80
|
+
"Assim Então Logo Pois Porque Embora Ainda Sempre Nunca Já Só Apenas Muito Muita "
|
|
81
|
+
"Muitos Muitas Pouco Poucos Mais Menos Cada Todo Toda Todos Todas Outro Outra Outros "
|
|
82
|
+
"Outras Alguns Algumas Algum Alguma Nenhum Nenhuma Qual Quais Quanto Quanta Quantos "
|
|
83
|
+
"Você Voce Seu Sua Seus Suas Meu Minha Nosso Nossa Ele Ela Eles Elas Nós "
|
|
84
|
+
# sentence openers / discourse (PT)
|
|
85
|
+
"Não Nao Sim Antes Depois Durante Hoje Ontem Agora Caso Dependendo Geralmente "
|
|
86
|
+
"Normalmente Lembre Lembrando Verifique Consulte Pesquise Procure Pergunte Escolha "
|
|
87
|
+
"Considere Compare Veja Confira Peça Evite Prefira Busque Solicite Observe Fique "
|
|
88
|
+
"Além Segundo Entre Dentre Sobre Após Perante Conforme Mediante Segue Seguem "
|
|
89
|
+
"Importante Atenção Observação Nota Dica Dicas Exemplo Exemplos Resumo Conclusão "
|
|
90
|
+
"Finais Final Principais Principal Melhores Melhor Opções Opção Recomendações "
|
|
91
|
+
"Recomendação Considerações Contato Endereço Horário Horários Telefone Localização "
|
|
92
|
+
"Especialidades Especialidade Serviços Serviço Site Registro Registros Fonte Fontes "
|
|
93
|
+
"Preço Preços Valor Valores Avaliação Avaliações Custo Custos Faixa Média "
|
|
94
|
+
# EN
|
|
95
|
+
"The An In On At For To Of And Or But If When While With Without This That These "
|
|
96
|
+
"Those It Its He She They We You I Here There However Also Some Many Most Other "
|
|
97
|
+
"Another Several Each Every All Both Any Such Per Via From Into Over Under About "
|
|
98
|
+
"Between Among Than Then Now Today Just Only Even Still Yes No Not Before After "
|
|
99
|
+
"Always Never Check Consider Look Make Ask Keep Remember Ensure Verify Compare "
|
|
100
|
+
"According Overall Additionally Furthermore Moreover Finally First Second Third "
|
|
101
|
+
"Next Note Tip Tips Important Depending Generally Typically Usually Please Their "
|
|
102
|
+
"Our Your My His Her Whether Since Because Although Though Unlike Like What Which "
|
|
103
|
+
"Who Whom Whose How Can Do Does Is Are Was Were Be Been Based Summary Conclusion "
|
|
104
|
+
"Verdict Overview Introduction Top Picks Pick Best Recommendations Recommendation "
|
|
105
|
+
"Options Option Reviews Review Price Prices Cost Costs Rating Ratings Pros Cons "
|
|
106
|
+
"FAQ Tips Steps Step Contact Address Hours Phone Location Services Service "
|
|
107
|
+
# geography / web noise
|
|
108
|
+
"Brazil Brasil Maps"
|
|
109
|
+
).split()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
_GENERIC = {
|
|
113
|
+
normalize(w)
|
|
114
|
+
for w in (
|
|
115
|
+
"clinica clinicas consultorio consultorios dentista dentistas odontologia odontologico "
|
|
116
|
+
"odontologica odontologicos odonto medico medicos medica medicas advogado advogados "
|
|
117
|
+
"advocacia escritorio escritorios centro centros instituto institutos studio estudio "
|
|
118
|
+
"hospital hospitais laboratorio laboratorios grupo profissional profissionais "
|
|
119
|
+
"especialista especialistas servico servicos saude clinic clinics dentist dentistry "
|
|
120
|
+
"dental doctor doctors lawyer lawyers law firm firms center centre institute office "
|
|
121
|
+
"offices group specialist specialists practice practices care health medical "
|
|
122
|
+
"attorney attorneys associates associados "
|
|
123
|
+
# specialties and generic service words (PT)
|
|
124
|
+
"ortodontia implante implantes implantodontia clareamento endodontia periodontia "
|
|
125
|
+
"estetica protese proteses cardiologia dermatologia pediatria ginecologia "
|
|
126
|
+
"oftalmologia ortopedia neurologia urologia psiquiatria psicologia fisioterapia "
|
|
127
|
+
"nutricao cirurgia tratamento tratamentos consulta consultas direito juridico "
|
|
128
|
+
"juridica trabalhista tributario civil familia "
|
|
129
|
+
# specialties and generic service words (EN)
|
|
130
|
+
"orthodontics orthodontic orthodontist implant cosmetic whitening endodontics "
|
|
131
|
+
"periodontics prosthodontics cardiology dermatology pediatrics gynecology "
|
|
132
|
+
"ophthalmology orthopedics neurology urology psychiatry psychology physiotherapy "
|
|
133
|
+
"surgery treatment treatments consultation consultations legal services"
|
|
134
|
+
).split()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
_REGISTRY_ACR = {
|
|
138
|
+
"cro", "crm", "oab", "crea", "crp", "cfm", "cfo", "crf", "crn", "cref", "creci", "coren",
|
|
139
|
+
"cau", "crc", "cfc", "confea", "anvisa", "ans", "procon", "anatel", "inmetro", "sus",
|
|
140
|
+
"cnpj", "cpf", "bbb", "fda", "ada", "ama", "aap", "aao", "aaoms", "nhs", "gdc", "cdc",
|
|
141
|
+
"nih", "osha", "ftc", "abnt", "iso", "cda", "ahpra",
|
|
142
|
+
}
|
|
143
|
+
_REGISTRY_PHRASES = (
|
|
144
|
+
"reclame aqui", "better business bureau", "conselho regional", "conselho federal",
|
|
145
|
+
"ordem dos advogados", "american dental association", "american medical association",
|
|
146
|
+
"food and drug administration", "ministerio da saude", "ministry of health",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
_BUILTIN_PLATFORMS = {
|
|
150
|
+
"doctoralia", "google", "yelp", "tripadvisor", "facebook", "instagram", "linkedin",
|
|
151
|
+
"youtube", "wikipedia", "reclameaqui", "guiamais", "jusbrasil", "tiktok", "whatsapp",
|
|
152
|
+
"reddit", "trustpilot", "glassdoor", "apontador", "solutudo", "telelistas", "waze",
|
|
153
|
+
"healthgrades", "zocdoc", "webmd", "yellowpages", "angi", "avvo", "justia", "houzz",
|
|
154
|
+
"thumbtack", "foursquare", "twitter", "pinterest", "quora", "ifood", "getninjas",
|
|
155
|
+
"mercadolivre", "olx", "bing", "chatgpt", "perplexity", "gemini",
|
|
156
|
+
}
|
|
157
|
+
_TLDS = {
|
|
158
|
+
"com", "org", "net", "co", "gov", "edu", "ac", "io", "app", "info", "biz", "med", "adv",
|
|
159
|
+
"ind", "blog", "site", "online", "store", "br", "uk", "us",
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _stem(t: str) -> str:
|
|
164
|
+
return t[:-1] if len(t) > 3 and t.endswith("s") else t
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
_GENERIC_STEMS = {_stem(g) for g in _GENERIC}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _tokens(name: str) -> list[str]:
|
|
171
|
+
"""Normalized tokens of a name, without a leading Dr./Dra./Prof. title."""
|
|
172
|
+
n = _TITLE_PREFIX_RE.sub("", normalize(name).strip())
|
|
173
|
+
return re.findall(r"[a-z0-9]+(?:[-&'’][a-z0-9]+)*", n)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _platform_label(entry: str) -> str:
|
|
177
|
+
"""'doctoralia.com.br' -> 'doctoralia'; 'Reclame Aqui' -> 'reclameaqui'."""
|
|
178
|
+
e = entry.strip()
|
|
179
|
+
if "." in e or "/" in e:
|
|
180
|
+
parts = [p for p in domain_of(e).split(".") if p]
|
|
181
|
+
while len(parts) > 1 and (parts[-1] in _TLDS or len(parts[-1]) == 2):
|
|
182
|
+
parts.pop()
|
|
183
|
+
return parts[-1].replace("-", "") if parts else ""
|
|
184
|
+
return re.sub(r"[^a-z0-9]", "", normalize(e))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _clean_candidate(name: str) -> str:
|
|
188
|
+
name = name.strip(" .,:;-–—*_()[]\"'")
|
|
189
|
+
words = name.split()
|
|
190
|
+
while words and normalize(words[0].strip(".")) in _STOP:
|
|
191
|
+
words.pop(0)
|
|
192
|
+
while words and normalize(words[-1].strip(".")) in _STOP:
|
|
193
|
+
words.pop()
|
|
194
|
+
return " ".join(words)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _name_like(s: str) -> bool:
|
|
198
|
+
s = s.strip(" .,:;-–—*_()\"'")
|
|
199
|
+
return bool(s) and len(s.split()) <= 7 and _NAMELIKE_RE.fullmatch(s) is not None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _blank(s: str, a: int, b: int) -> str:
|
|
203
|
+
return s[:a] + " " * (b - a) + s[b:]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _take(rx: re.Pattern, body: str, out: list) -> str:
|
|
207
|
+
"""Record regex matches as strong candidates and blank them out of the line."""
|
|
208
|
+
matches = list(rx.finditer(body))
|
|
209
|
+
for m in matches:
|
|
210
|
+
out.append((m.group(0), True, False))
|
|
211
|
+
for m in matches:
|
|
212
|
+
body = _blank(body, m.start(), m.end())
|
|
213
|
+
return body
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _extract_line(line: str) -> list[tuple[str, bool, bool]]:
|
|
217
|
+
"""Return [(raw_name, strong, at_sentence_start)] for one line of an answer."""
|
|
218
|
+
line = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", line) # markdown links
|
|
219
|
+
line = re.sub(r"https?://\S+|\[\d+\]", " ", line) # bare URLs, [1] citation marks
|
|
220
|
+
out: list[tuple[str, bool, bool]] = []
|
|
221
|
+
|
|
222
|
+
head = _HEAD_RE.match(line)
|
|
223
|
+
lst = None if head else _LIST_RE.match(line)
|
|
224
|
+
marker = head or lst
|
|
225
|
+
body = " " * marker.end() + line[marker.end():] if marker else line
|
|
226
|
+
|
|
227
|
+
# 1. bold spans
|
|
228
|
+
bolds = list(_BOLD_RE.finditer(body))
|
|
229
|
+
for m in bolds:
|
|
230
|
+
lead = _LEAD_CUT_RE.split(m.group(1), 1)[0]
|
|
231
|
+
if _name_like(lead):
|
|
232
|
+
out.append((lead, True, False))
|
|
233
|
+
for m in bolds:
|
|
234
|
+
body = _blank(body, m.start(), m.end())
|
|
235
|
+
body = body.replace("*", " ")
|
|
236
|
+
|
|
237
|
+
# 2. heading text / list-item lead before dash or colon
|
|
238
|
+
if marker:
|
|
239
|
+
sep = _SEP_RE.search(body)
|
|
240
|
+
lead_end = sep.start() if sep else (len(body) if head else 0)
|
|
241
|
+
lead = re.sub(r"^\s*\d+[.)]\s*", "", body[:lead_end]).strip()
|
|
242
|
+
if lead and _name_like(lead):
|
|
243
|
+
words = lead.split()
|
|
244
|
+
camel = len(words) == 1 and (
|
|
245
|
+
re.search(r"[a-z][A-Z]", lead) or re.search(r"\d", lead)
|
|
246
|
+
)
|
|
247
|
+
strong = len(words) > 1 or bool(camel)
|
|
248
|
+
out.append((lead, strong, not strong))
|
|
249
|
+
body = _blank(body, 0, lead_end)
|
|
250
|
+
|
|
251
|
+
# 3. titled people, 4. business patterns
|
|
252
|
+
body = _take(_TITLE_RE, body, out)
|
|
253
|
+
body = _take(_BIZ_PREFIX_RE, body, out)
|
|
254
|
+
body = _take(_BIZ_SUFFIX_RE, body, out)
|
|
255
|
+
|
|
256
|
+
# 5. remaining capitalized sequences (weak); never cross punctuation
|
|
257
|
+
for m in _SEQ_RE.finditer(body):
|
|
258
|
+
prev = body[: m.start()].rstrip(" \t\"'“‘([")
|
|
259
|
+
at_start = not prev or prev[-1] in ".!?:;…"
|
|
260
|
+
out.append((m.group(0), False, at_start))
|
|
261
|
+
return out
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def competitor_candidates(
|
|
265
|
+
texts: list[str],
|
|
266
|
+
brand: str,
|
|
267
|
+
limit: int = 10,
|
|
268
|
+
exclude: tuple[str, ...] = (),
|
|
269
|
+
category: str = "",
|
|
270
|
+
city: str = "",
|
|
271
|
+
platforms: tuple[str, ...] | list[str] = (),
|
|
272
|
+
) -> list[tuple[str, int]]:
|
|
273
|
+
"""Heuristic competitor names found in answers, as (display name, answers).
|
|
274
|
+
|
|
275
|
+
Prefers names with structure (bold, list leads, headings, Dr./Dra. titles,
|
|
276
|
+
business patterns like "Clínica X"). Drops sentence-start words, generic
|
|
277
|
+
category words, the city, the brand, registries (CRO, OAB, ...) and
|
|
278
|
+
platforms (`platforms` = cited domains such as "doctoralia.com.br").
|
|
279
|
+
Candidates are suggestions to review, not verified competitors.
|
|
280
|
+
"""
|
|
281
|
+
per_text = [[o for line in t.splitlines() for o in _extract_line(line)] for t in texts]
|
|
282
|
+
|
|
283
|
+
# words seen capitalized away from a sentence start (or in structure)
|
|
284
|
+
mid_caps = {
|
|
285
|
+
w for occs in per_text for raw, strong, at_start in occs
|
|
286
|
+
if strong or not at_start for w in _tokens(raw)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
brand_n = " ".join(_tokens(brand))
|
|
290
|
+
city_tokens = set(_tokens(city))
|
|
291
|
+
cat_stems = {_stem(t) for t in _tokens(category) if t not in _CONNECT}
|
|
292
|
+
excl_keys = {" ".join(_tokens(e)) for e in (*exclude, city) if e}
|
|
293
|
+
labels = set(_BUILTIN_PLATFORMS) | {_platform_label(p) for p in platforms if p}
|
|
294
|
+
labels.discard("")
|
|
295
|
+
|
|
296
|
+
def generic_like(t: str) -> bool:
|
|
297
|
+
return (
|
|
298
|
+
t in _STOP or t in _CONNECT or t in city_tokens
|
|
299
|
+
or _stem(t) in _GENERIC_STEMS or _stem(t) in cat_stems
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def is_junk(toks: list[str]) -> bool:
|
|
303
|
+
core = [t for t in toks if t not in _CONNECT]
|
|
304
|
+
key = " ".join(toks)
|
|
305
|
+
if not core or len(key) < 3 or key.isdigit():
|
|
306
|
+
return True
|
|
307
|
+
if all(generic_like(t) for t in core):
|
|
308
|
+
return True
|
|
309
|
+
if brand_n and (brand_n in key or key in brand_n):
|
|
310
|
+
return True
|
|
311
|
+
if key in excl_keys:
|
|
312
|
+
return True
|
|
313
|
+
# registries / agencies (CRO-SP, OAB, Procon, Reclame Aqui, BBB...)
|
|
314
|
+
if any(key.startswith(p) for p in _REGISTRY_PHRASES):
|
|
315
|
+
return True
|
|
316
|
+
reg = [t for t in core if re.split(r"[-/]", t)[0] in _REGISTRY_ACR]
|
|
317
|
+
if reg and all(generic_like(t) for t in core if t not in reg):
|
|
318
|
+
return True
|
|
319
|
+
# platforms (cited domains and well-known sites)
|
|
320
|
+
if "".join(core).replace("-", "") in labels:
|
|
321
|
+
return True
|
|
322
|
+
plat = [t for t in core if t.replace("-", "") in labels]
|
|
323
|
+
if plat and all(generic_like(t) for t in core if t not in plat):
|
|
324
|
+
return True
|
|
325
|
+
return False
|
|
326
|
+
|
|
327
|
+
counts: Counter[str] = Counter()
|
|
328
|
+
forms: dict[str, Counter[str]] = {}
|
|
329
|
+
strong_keys: set[str] = set()
|
|
330
|
+
for occs in per_text:
|
|
331
|
+
seen: set[str] = set()
|
|
332
|
+
for raw, strong, at_start in occs:
|
|
333
|
+
name = _clean_candidate(raw)
|
|
334
|
+
toks = _tokens(name)
|
|
335
|
+
if not toks or is_junk(toks):
|
|
336
|
+
continue
|
|
337
|
+
titled = bool(_HAS_TITLE_RE.match(name))
|
|
338
|
+
if not strong and at_start and len(toks) == 1 and not titled and toks[0] not in mid_caps:
|
|
339
|
+
continue
|
|
340
|
+
key = " ".join(toks)
|
|
341
|
+
forms.setdefault(key, Counter())[name] += 1
|
|
342
|
+
if strong:
|
|
343
|
+
strong_keys.add(key)
|
|
344
|
+
seen.add(key)
|
|
345
|
+
counts.update(seen)
|
|
346
|
+
|
|
347
|
+
def best_form(key: str) -> str:
|
|
348
|
+
def score(item: tuple[str, int]):
|
|
349
|
+
f, freq = item
|
|
350
|
+
accents = sum(1 for c in f if ord(c) > 127)
|
|
351
|
+
return (bool(_HAS_TITLE_RE.match(f)), accents, len(f), freq)
|
|
352
|
+
return max(forms[key].items(), key=score)[0]
|
|
353
|
+
|
|
354
|
+
ranked = sorted(
|
|
355
|
+
((best_form(k), c, k in strong_keys) for k, c in counts.items()),
|
|
356
|
+
key=lambda t: (-t[1], not t[2], t[0]),
|
|
357
|
+
)
|
|
358
|
+
return [(n, c) for n, c, _ in ranked[:limit]]
|
geo_check/cli.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""geo-check command line interface."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import unicodedata
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__, dryrun, history, perplexity, robots
|
|
11
|
+
from .analysis import competitor_candidates, count_mentions, rank_domains
|
|
12
|
+
from .html_report import render_html
|
|
13
|
+
from .questions import detect_lang, generate_questions
|
|
14
|
+
from .report import render_markdown
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
p = argparse.ArgumentParser(
|
|
19
|
+
prog="geo-check",
|
|
20
|
+
description="Check how AI answer engines mention your brand.",
|
|
21
|
+
)
|
|
22
|
+
p.add_argument("category", help='Business category, e.g. "dentista"')
|
|
23
|
+
p.add_argument("city", help='City, e.g. "Campinas"')
|
|
24
|
+
p.add_argument("--brand", required=True, help="Brand name to look for")
|
|
25
|
+
p.add_argument("--site", help="Site URL; checks robots.txt for AI crawlers")
|
|
26
|
+
p.add_argument(
|
|
27
|
+
"--lang", choices=["pt", "en"], default=None,
|
|
28
|
+
help="Language of questions and report (default: auto-detect from the category)",
|
|
29
|
+
)
|
|
30
|
+
p.add_argument("--runs", type=int, default=3, help="Runs per question (default 3)")
|
|
31
|
+
p.add_argument("--out", default="./reports", help="Output directory (default ./reports)")
|
|
32
|
+
p.add_argument(
|
|
33
|
+
"--format", choices=["md", "html", "json"], default="md",
|
|
34
|
+
help="Report format: md (default), html (single self-contained file) or json",
|
|
35
|
+
)
|
|
36
|
+
p.add_argument(
|
|
37
|
+
"--history-dir", default=None, metavar="DIR",
|
|
38
|
+
help="Append each run to DIR and report changes since the previous run "
|
|
39
|
+
"(same brand/category/city)",
|
|
40
|
+
)
|
|
41
|
+
p.add_argument("--dry-run", action="store_true", help="Simulate without any network")
|
|
42
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
43
|
+
return p
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _slug(text: str) -> str:
|
|
47
|
+
t = unicodedata.normalize("NFKD", text)
|
|
48
|
+
t = "".join(c for c in t if not unicodedata.combining(c))
|
|
49
|
+
return re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-") or "report"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_check(args) -> dict:
|
|
53
|
+
args.lang = args.lang or detect_lang(args.category)
|
|
54
|
+
questions = generate_questions(args.category, args.city, args.lang)
|
|
55
|
+
api_key = None if args.dry_run else perplexity.get_api_key()
|
|
56
|
+
|
|
57
|
+
q_results = []
|
|
58
|
+
all_answers: list[str] = []
|
|
59
|
+
all_citations: list[str] = []
|
|
60
|
+
for q in questions:
|
|
61
|
+
answers = []
|
|
62
|
+
for i in range(args.runs):
|
|
63
|
+
if args.dry_run:
|
|
64
|
+
res = dryrun.simulate(q, args.brand, i)
|
|
65
|
+
else:
|
|
66
|
+
res = perplexity.ask(q, api_key)
|
|
67
|
+
n = count_mentions(res["answer"], args.brand)
|
|
68
|
+
answers.append({"run": i + 1, "mentions": n, **res})
|
|
69
|
+
all_answers.append(res["answer"])
|
|
70
|
+
all_citations += res["citations"]
|
|
71
|
+
q_results.append(
|
|
72
|
+
{
|
|
73
|
+
"question": q,
|
|
74
|
+
"mentions": sum(a["mentions"] for a in answers),
|
|
75
|
+
"answers_with_mention": sum(1 for a in answers if a["mentions"] > 0),
|
|
76
|
+
"answers": answers,
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
total = len(all_answers)
|
|
81
|
+
with_mention = sum(q["answers_with_mention"] for q in q_results)
|
|
82
|
+
result = {
|
|
83
|
+
"tool": f"geo-check {__version__}",
|
|
84
|
+
"brand": args.brand,
|
|
85
|
+
"category": args.category,
|
|
86
|
+
"city": args.city,
|
|
87
|
+
"lang": args.lang,
|
|
88
|
+
"runs": args.runs,
|
|
89
|
+
"engine": "simulated" if args.dry_run else f"perplexity/{perplexity.PRESET}",
|
|
90
|
+
"dry_run": args.dry_run,
|
|
91
|
+
"summary": {
|
|
92
|
+
"total_answers": total,
|
|
93
|
+
"answers_with_mention": with_mention,
|
|
94
|
+
"mention_rate": (with_mention / total) if total else 0.0,
|
|
95
|
+
"total_mentions": sum(q["mentions"] for q in q_results),
|
|
96
|
+
},
|
|
97
|
+
"questions": q_results,
|
|
98
|
+
"cited_domains": [{"domain": d, "count": c} for d, c in rank_domains(all_citations)],
|
|
99
|
+
"competitor_candidates": [
|
|
100
|
+
{"name": n, "count": c}
|
|
101
|
+
for n, c in competitor_candidates(
|
|
102
|
+
all_answers,
|
|
103
|
+
args.brand,
|
|
104
|
+
category=args.category,
|
|
105
|
+
city=args.city,
|
|
106
|
+
platforms=[d for d, _ in rank_domains(all_citations)],
|
|
107
|
+
)
|
|
108
|
+
],
|
|
109
|
+
"site": args.site,
|
|
110
|
+
"robots": None,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if args.site:
|
|
114
|
+
if args.dry_run:
|
|
115
|
+
result["robots"] = {
|
|
116
|
+
"status": "ok",
|
|
117
|
+
"simulated": True,
|
|
118
|
+
"crawlers": robots.check_crawlers(robots.SIMULATED_ROBOTS),
|
|
119
|
+
}
|
|
120
|
+
else:
|
|
121
|
+
f = robots.fetch_robots_status(args.site)
|
|
122
|
+
rb = {"status": f["status"], "simulated": False, "code": f["code"], "error": None}
|
|
123
|
+
if f["status"] == "ok":
|
|
124
|
+
rb["crawlers"] = robots.check_crawlers(f["content"])
|
|
125
|
+
elif f["status"] == "not_found":
|
|
126
|
+
# no robots.txt: nothing is disallowed
|
|
127
|
+
rb["crawlers"] = robots.check_crawlers("")
|
|
128
|
+
else:
|
|
129
|
+
rb["error"] = f["error"] or "unknown error"
|
|
130
|
+
result["robots"] = rb
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main(argv: list[str] | None = None) -> int:
|
|
135
|
+
args = build_parser().parse_args(argv)
|
|
136
|
+
if args.runs < 1:
|
|
137
|
+
print("error: --runs must be >= 1", file=sys.stderr)
|
|
138
|
+
return 2
|
|
139
|
+
try:
|
|
140
|
+
result = run_check(args)
|
|
141
|
+
except perplexity.PerplexityError as e:
|
|
142
|
+
print(f"error: {e}", file=sys.stderr)
|
|
143
|
+
return 1
|
|
144
|
+
|
|
145
|
+
record = None
|
|
146
|
+
if args.history_dir:
|
|
147
|
+
record = history.prepare_run(result, Path(args.history_dir))
|
|
148
|
+
|
|
149
|
+
out_dir = Path(args.out)
|
|
150
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
base = f"geo-{_slug(args.brand)}-{_slug(args.city)}"
|
|
152
|
+
json_path = out_dir / f"{base}.json"
|
|
153
|
+
json_text = json.dumps(result, ensure_ascii=False, indent=2)
|
|
154
|
+
json_path.write_text(json_text, encoding="utf-8")
|
|
155
|
+
|
|
156
|
+
md = html_path = None
|
|
157
|
+
if args.format == "html":
|
|
158
|
+
html_path = out_dir / f"{base}.html"
|
|
159
|
+
html_path.write_text(render_html(result), encoding="utf-8")
|
|
160
|
+
elif args.format != "json":
|
|
161
|
+
md = render_markdown(result)
|
|
162
|
+
(out_dir / f"{base}.md").write_text(md, encoding="utf-8")
|
|
163
|
+
|
|
164
|
+
# History is saved only after every report file is on disk.
|
|
165
|
+
if record:
|
|
166
|
+
history.save_record(Path(args.history_dir), record)
|
|
167
|
+
|
|
168
|
+
if args.format == "json":
|
|
169
|
+
print(json_text)
|
|
170
|
+
elif args.format == "html":
|
|
171
|
+
print(f"HTML: {html_path}\nJSON: {json_path}")
|
|
172
|
+
else:
|
|
173
|
+
print(md)
|
|
174
|
+
print(f"\nJSON: {json_path}")
|
|
175
|
+
print(f"\nJSON: {json_path}")
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
sys.exit(main())
|
geo_check/dryrun.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Deterministic simulated engine for --dry-run (no network)."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import random
|
|
5
|
+
|
|
6
|
+
_COMPETITORS = [
|
|
7
|
+
"Clínica Vida Plena",
|
|
8
|
+
"Centro Médico Aurora",
|
|
9
|
+
"Instituto Bem Estar",
|
|
10
|
+
"Studio Prime",
|
|
11
|
+
"Grupo Saúde Total",
|
|
12
|
+
"Casa Modelo",
|
|
13
|
+
]
|
|
14
|
+
_DOMAINS = [
|
|
15
|
+
"https://www.doctoralia.com.br/busca",
|
|
16
|
+
"https://www.tripadvisor.com/list",
|
|
17
|
+
"https://www.reclameaqui.com.br/empresa",
|
|
18
|
+
"https://www.yelp.com/search",
|
|
19
|
+
"https://pt.wikipedia.org/wiki/Guia",
|
|
20
|
+
"https://www.guiamais.com.br/lista",
|
|
21
|
+
"https://exemplo-blog.com.br/ranking",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def simulate(question: str, brand: str, run: int = 0) -> dict:
|
|
26
|
+
"""Return {'answer', 'citations'} deterministically from question + run."""
|
|
27
|
+
seed = int.from_bytes(hashlib.sha256(f"{question}|{run}".encode()).digest()[:8], "big")
|
|
28
|
+
rng = random.Random(seed)
|
|
29
|
+
comps = rng.sample(_COMPETITORS, 3)
|
|
30
|
+
lines = ["Here are some well-reviewed options:", ""]
|
|
31
|
+
ranked = comps[:]
|
|
32
|
+
if rng.random() < 0.45:
|
|
33
|
+
ranked.insert(rng.randint(0, len(ranked)), brand)
|
|
34
|
+
for i, name in enumerate(ranked, 1):
|
|
35
|
+
lines.append(f"{i}. **{name}** - well reviewed locally.")
|
|
36
|
+
answer = "\n".join(lines)
|
|
37
|
+
citations = rng.sample(_DOMAINS, rng.randint(2, 4))
|
|
38
|
+
return {"answer": answer, "citations": citations}
|