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 ADDED
@@ -0,0 +1,3 @@
1
+ """geo-check: measure brand visibility in AI answer engines."""
2
+
3
+ __version__ = "0.2.1"
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}