diff --git a/.claude/skills/mkt-competitive-brief/SKILL.md b/.claude/skills/mkt-competitive-brief/SKILL.md
index 65e3c177..3c0e2f35 100644
--- a/.claude/skills/mkt-competitive-brief/SKILL.md
+++ b/.claude/skills/mkt-competitive-brief/SKILL.md
@@ -8,6 +8,12 @@ argument-hint: ""
> If you see unfamiliar placeholders or need to check which tools are connected, see [CONNECTORS.md](../../CONNECTORS.md).
+> **LIMITES DE FONTE — leia antes de coletar (spec MUST #3):**
+> - **Meta Ad Library API** é restrita a político/social-issue + somente EU em 2026. Para concorrente **comercial**, criativos são UI-only (`facebook.com/ads/library`) — não existe API pública comercial. Não prometa criativos via API; declare este limite no brief.
+> - **Google Ads Transparency Center** não tem API pública (até v23/jan-2026). Não expõe spend, keyword-trigger nem targeting. Somente UI (`adstransparency.google.com`). Declare o limite.
+> - **Spend e keyword-trigger** não são observáveis em nenhuma fonte pública. Qualquer número é estimativa de terceiros (Semrush/DataForSEO) com faixa de erro de 15-30% — rotule sempre.
+> - **DataForSEO** (SERP features, AI Overview, keyword gap) está disponível internamente via `scripts/competitor_intel.py`. Use quando `DATAFORSEO_LOGIN` + `DATAFORSEO_PASSWORD` estiverem no `.env`.
+
Research competitors and generate a structured competitive analysis comparing positioning, messaging, content strategy, and market presence.
## Trigger
@@ -33,15 +39,43 @@ Gather the following from the user:
- Pricing and packaging (if publicly available)
- Market presence and audience
+## Proveniência obrigatória (gate anti-fabricação)
+
+**Todo dado sobre um concorrente exige fonte + URL + data de coleta.** Sem isso, o dado não entra no brief — vai para a tabela de proveniência com status `não verificado / pesquisar`.
+
+Inclua sempre uma tabela de proveniência no início do brief entregue:
+
+| Dado | Fonte | URL | Data coleta | Status |
+|------|-------|-----|-------------|--------|
+| Tagline homepage | Site oficial | https://... | YYYY-MM-DD | verificado |
+| Keyword gap | DataForSEO Labs | docs.dataforseo.com | YYYY-MM-DD | verificado (±20%) |
+| Criativos Meta | Meta Ad Library UI | facebook.com/ads/library | — | não verificado / pesquisar |
+| Spend estimado | — | — | — | não observável (fonte pública inexistente) |
+
+Regras:
+- **Estimativas de terceiros** (DataForSEO, Semrush, SpyFu): sempre rotule com faixa de erro (tipicamente 15-30% em volume).
+- **"Não observável"**: spend, keyword-trigger, targeting Meta/Google — nunca estime, declare o limite.
+- **Memória do modelo não é fonte**: qualquer afirmação sobre o concorrente que não tenha URL verificável nesta sessão vai para `não verificado`.
+
## Research Process
-For each competitor, research using web search:
+Para cada concorrente, colete nas seguintes prioridades:
-1. **Company website** — homepage messaging, product pages, about page, pricing page
-2. **Recent news** — press releases, funding announcements, product launches, partnerships (last 6 months)
-3. **Content strategy** — blog topics, resource types, social media presence, webinars, podcasts
-4. **Review sites and comparisons** — third-party comparisons, analyst mentions, customer review themes
-5. **Job postings** — hiring signals that indicate strategic direction (optional)
+### Fontes verificáveis com dado real (prioridade 1)
+
+1. **Company website** — homepage messaging, product pages, about page, pricing page (WebFetch direto, não search genérico)
+2. **SEO/SERP/AI-Overview** — via `scripts/competitor_intel.py` quando `DATAFORSEO_LOGIN`/`DATAFORSEO_PASSWORD` disponíveis:
+ - Keyword gap: `python3 scripts/competitor_intel.py --gap --target competitor.com.br --self cliente.com.br`
+ - SERP features + AI Overview: `python3 scripts/competitor_intel.py --keywords "keyword1,keyword2"`
+ - Se DataForSEO indisponível: marcar `N/A — DataForSEO não configurado` na tabela de proveniência.
+3. **Recent news** — press releases, funding, product launches (últimos 6 meses)
+4. **Review sites** — G2, Capterra, TrustRadius: WebFetch da página do concorrente, extrair temas de elogio/reclamação com citação
+5. **Job postings** — sinais estratégicos (novo produto, expansão de mercado)
+
+### Fontes sem API — coleta manual (prioridade 2, declarar limite)
+
+6. **Meta Ad Library** — `facebook.com/ads/library` (UI pública; API comercial inexistente). Pesquisar manualmente e registrar URL + data na tabela de proveniência. Se não coletado: marcar `não verificado — UI-only, requer coleta manual`.
+7. **Google Ads Transparency Center** — `adstransparency.google.com` (sem API; não mostra spend). Mesma lógica.
### Research Sources
@@ -61,7 +95,7 @@ Gather intelligence from these categories of sources:
- **Analyst reports**: Gartner, Forrester, IDC — market positioning and category placement
- **News coverage**: TechCrunch, industry publications — funding, partnerships, narrative
- **Social listening**: mentions, sentiment, share of voice across social platforms
-- **SEO tools**: keyword rankings, organic traffic estimates, content gaps
+- **SEO tools**: keyword rankings, organic traffic estimates, content gaps — **use `scripts/competitor_intel.py` para DataForSEO real (SERP features, AI Overview, keyword gap domain-vs-domain) quando disponível; estimativas de terceiros sempre com faixa de erro declarada**
- **Financial filings**: revenue, growth rate, investment areas (for public companies)
- **Community forums**: community forums (e.g. Reddit, Discourse), industry chat groups (e.g. Slack communities) — user sentiment
@@ -104,7 +138,7 @@ For each competitor:
- Content types produced (ebooks, webinars, case studies, tools)
- Social media presence and engagement approach
- Thought leadership themes
-- SEO strategy observations (what terms they appear to target)
+- SEO strategy observations — **não inferir, coletar**: use `scripts/competitor_intel.py --gap --target DOMINIO_CONCORRENTE --self DOMINIO_CLIENTE` para keywords reais com volume + rank. Se DataForSEO indisponível, declarar `N/A — requer DataForSEO` e não estimar.
#### Strengths
- What they do well
@@ -320,6 +354,10 @@ Questions competitors might encourage prospects to ask you, with prepared respon
## Output
+O brief deve sempre começar com a **Tabela de Proveniência** (ver seção "Proveniência obrigatória") antes de qualquer análise. Dados sem fonte verificável nesta sessão ficam marcados como `não verificado` — nunca são apresentados como fatos.
+
+Salvar em `workspace/marketing/{cliente}/[C]competitive-brief-{concorrente}-{YYYY-MM-DD}.md`.
+
Present the full competitive brief with clear formatting. Note the date of the research so the user knows the freshness of the data.
After the brief, ask:
diff --git a/.claude/skills/mkt-competitive-brief/scripts/competitor_intel.py b/.claude/skills/mkt-competitive-brief/scripts/competitor_intel.py
new file mode 100644
index 00000000..d1307eb7
--- /dev/null
+++ b/.claude/skills/mkt-competitive-brief/scripts/competitor_intel.py
@@ -0,0 +1,286 @@
+#!/usr/bin/env python3
+"""
+competitor_intel.py — coletor de inteligência competitiva REAL (MUST #2 da spec).
+
+Reutiliza o `dataforseo_client.py` que JÁ EXISTE em `mkt-seo-ops` (infra já paga e
+autenticada) via importlib, sem duplicar credencial nem cliente HTTP. Cobre dois eixos:
+
+ 1. SERP features + AI Overview presence + intent por keyword (DataForSEOClient.keyword_intel)
+ 2. Keyword gap domínio-vs-domínio (DataForSEO Labs domain_intersection)
+
+REGRA DURA — anti-fabricação (spec MUST #1):
+ Toda saída carrega `source`, `url` (quando aplicável) e `collected_at`.
+ Sem credencial → status "N/A" com instrução. Erro de rede/API → "N/A" com razão.
+ O LLM consumidor DEVE tratar N/A como "não verificado / pesquisar", nunca como zero.
+
+LIMITES DE FONTE (spec MUST #3 — não observável por este script):
+ - Spend do concorrente, keyword que disparou o anúncio, targeting Meta/Google.
+ - Criativos Meta: Ad Library API restrita a político/social-issue + só EU em 2026;
+ uso comercial é UI-only (facebook.com/ads/library) — NÃO existe API comercial.
+ - Criativos Google: Ads Transparency Center sem API pública (até v23/jan-2026);
+ não mostra spend, keyword-trigger nem targeting.
+ Esses eixos exigem coletor de UI (SHOULD #4/#5 da spec) — fora do escopo aqui.
+
+Uso:
+ python3 competitor_intel.py --check
+ python3 competitor_intel.py --keywords "usinagem cnc,retifica industrial" --location br
+ python3 competitor_intel.py --gap --target competitor.com.br --self mycliente.com.br
+"""
+
+import os
+import sys
+import json
+import argparse
+import importlib.util
+from datetime import datetime, timezone
+from pathlib import Path
+
+
+# ─────────────────────────────────────────────
+# Reuso do dataforseo_client.py de mkt-seo-ops
+# ─────────────────────────────────────────────
+
+def _candidate_paths():
+ """
+ Candidatos ao dataforseo_client.py de mkt-seo-ops. Resolve o diretório real
+ do script (segue symlink) antes de subir a árvore procurando por
+ .claude/skills/mkt-seo-ops/dataforseo_client.py.
+ """
+ here = Path(__file__).resolve().parent # .../mkt-competitive-brief/scripts
+ return [
+ base / ".claude" / "skills" / "mkt-seo-ops" / "dataforseo_client.py"
+ for base in [here, *here.parents]
+ ]
+
+
+def _load_client():
+ """
+ Importa DataForSEOClient do mkt-seo-ops via importlib (padrão seo_ops.py).
+ Retorna (client, None) ou (None, motivo_str).
+ """
+ for path in _candidate_paths():
+ if not path.exists():
+ continue
+ try:
+ spec = importlib.util.spec_from_file_location("dataforseo_client", str(path))
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod.DataForSEOClient(), None
+ except Exception as e: # noqa: BLE001
+ return None, f"falha ao carregar dataforseo_client.py em {path}: {e}"
+ return None, (
+ "dataforseo_client.py não encontrado — verifique se mkt-seo-ops está instalado "
+ "em .claude/skills/mkt-seo-ops/."
+ )
+
+
+def _today():
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d")
+
+
+# ─────────────────────────────────────────────
+# Eixo 1 — SERP features + AI Overview + intent
+# ─────────────────────────────────────────────
+
+def serp_intel(keywords: list, location="br", language="pt-BR") -> dict:
+ """
+ SERP features / AI Overview presence / intent por keyword via DataForSEO SERP API.
+ Cada resultado carrega source + collected_at (proveniência obrigatória).
+ N/A graceful sem fabricar dado.
+ """
+ client, reason = _load_client()
+ if client is None:
+ return {"status": "N/A", "reason": reason, "collected_at": _today()}
+ if not client.available():
+ return {
+ "status": "N/A",
+ "reason": (
+ "DATAFORSEO_LOGIN/DATAFORSEO_PASSWORD ausentes no .env — "
+ "SERP features, AI Overview presence e intent indisponíveis. "
+ "Configure para habilitar (Labs ~$0.025/1k kw)."
+ ),
+ "collected_at": _today(),
+ }
+ # location não-mapeada → N/A explícito, nunca default BR silencioso (anti-fabricação).
+ if location.lower() not in _LOC:
+ return {
+ "status": "N/A",
+ "reason": (
+ f"location '{location}' não mapeada (suportadas: {', '.join(sorted(_LOC))}). "
+ "Default silencioso para BR fabricaria SERP de outro país."
+ ),
+ "collected_at": _today(),
+ }
+ truncated = len(keywords) > 50
+ kw_slice = keywords[:50]
+ try:
+ raw = client.keyword_intel(kw_slice, location=location, language=language)
+ except Exception as e: # noqa: BLE001
+ return {
+ "status": "N/A",
+ "reason": f"erro DataForSEO (keyword_intel): {e}",
+ "collected_at": _today(),
+ }
+ if raw.get("status") != "OK":
+ raw.setdefault("collected_at", _today())
+ return raw
+ out = {
+ "status": "OK",
+ "source": "DataForSEO SERP API (serp/google/organic/live/advanced + Labs search_intent/live)",
+ "source_url": "https://docs.dataforseo.com/v3/serp/google/organic/live/advanced/",
+ "collected_at": _today(),
+ "location": location,
+ "language": language,
+ "keywords": raw.get("keywords", []),
+ }
+ if truncated:
+ out["truncated"] = True
+ out["truncated_note"] = (
+ f"{len(keywords)} keywords recebidas; processadas as primeiras 50. "
+ "As demais NÃO foram consultadas."
+ )
+ return out
+
+
+# ─────────────────────────────────────────────
+# Eixo 2 — keyword gap domínio-vs-domínio
+# ─────────────────────────────────────────────
+
+# Tabelas de location/language (replicadas aqui para não depender de acesso a
+# atributos privados do módulo carregado via importlib).
+_LOC = {"br": 2076, "us": 2840, "pt": 2620}
+_LANG = {"pt-br": "pt", "pt": "pt", "en": "en", "en-us": "en"}
+
+
+def keyword_gap(target_domain: str, self_domain: str,
+ location="br", language="pt-BR", limit=50) -> dict:
+ """
+ Keywords que target_domain (concorrente) rankeia e self_domain (cliente) não.
+ Usa DataForSEO Labs domain_intersection com intersections=False.
+ Reusa _post/_auth_header do client existente — sem duplicar credencial.
+ Faixa de erro típica 15-30% (estimativa DataForSEO, não dado exato).
+ """
+ client, reason = _load_client()
+ if client is None:
+ return {"status": "N/A", "reason": reason, "collected_at": _today()}
+ if not client.available():
+ return {
+ "status": "N/A",
+ "reason": "DATAFORSEO_LOGIN/DATAFORSEO_PASSWORD ausentes no .env.",
+ "collected_at": _today(),
+ }
+
+ # location não-mapeada → N/A explícito, nunca default BR silencioso (anti-fabricação).
+ if location.lower() not in _LOC:
+ return {
+ "status": "N/A",
+ "reason": (
+ f"location '{location}' não mapeada (suportadas: {', '.join(sorted(_LOC))}). "
+ "Default silencioso para BR fabricaria gap de outro país."
+ ),
+ "collected_at": _today(),
+ }
+ loc_code = _LOC[location.lower()]
+ lang_code = _LANG.get(language.lower(), "pt")
+
+ payload = [{
+ "target1": target_domain,
+ "target2": self_domain,
+ "location_code": loc_code,
+ "language_code": lang_code,
+ "intersections": False, # False = target1 tem, target2 não tem
+ "limit": min(limit, 100),
+ "order_by": ["keyword_data.keyword_info.search_volume,desc"],
+ }]
+ try:
+ resp = client._post("dataforseo_labs/google/domain_intersection/live", payload)
+ except Exception as e: # noqa: BLE001
+ return {
+ "status": "N/A",
+ "reason": f"erro DataForSEO Labs (domain_intersection): {e}",
+ "collected_at": _today(),
+ }
+
+ gaps = []
+ for t in (resp.get("tasks") or []):
+ for res in (t.get("result") or []):
+ for item in (res.get("items") or []):
+ kd = item.get("keyword_data") or {}
+ ki = kd.get("keyword_info") or {}
+ first = item.get("first_domain_serp_element") or {}
+ gaps.append({
+ "keyword": kd.get("keyword", ""),
+ "search_volume": ki.get("search_volume"),
+ "competitor_rank": first.get("rank_absolute"),
+ "competitor_url": first.get("url"),
+ })
+
+ return {
+ "status": "OK",
+ "source": "DataForSEO Labs domain_intersection/live",
+ "source_url": "https://docs.dataforseo.com/v3/dataforseo_labs/google/domain_intersection/live/",
+ "collected_at": _today(),
+ "target_competitor": target_domain,
+ "self_domain": self_domain,
+ "location": location,
+ "note": (
+ "Estimativa 3rd-party (DataForSEO) — faixa de erro típica 15-30% em volume; "
+ "rank é snapshot da SERP na data de coleta, não histórico."
+ ),
+ "gap_keywords": gaps,
+ }
+
+
+# ─────────────────────────────────────────────
+# CLI
+# ─────────────────────────────────────────────
+
+def main():
+ p = argparse.ArgumentParser(
+ description="Coletor de inteligência competitiva REAL via DataForSEO (reuso mkt-seo-ops)"
+ )
+ p.add_argument("--check", action="store_true",
+ help="Verifica disponibilidade do DataForSEO client")
+ p.add_argument("--keywords",
+ help="CSV de keywords para SERP features/AI-Overview/intent")
+ p.add_argument("--gap", action="store_true",
+ help="Keyword gap domínio-vs-domínio (exige --target e --self)")
+ p.add_argument("--target", help="Domínio do concorrente")
+ p.add_argument("--self", dest="self_domain", help="Domínio do cliente")
+ p.add_argument("--location", default="br")
+ p.add_argument("--language", default="pt-BR")
+ args = p.parse_args()
+
+ if args.check:
+ client, reason = _load_client()
+ print(json.dumps({
+ "client_loaded": client is not None,
+ "available": bool(client and client.available()),
+ "reason": reason,
+ }, indent=2, ensure_ascii=False))
+ return
+
+ if args.gap:
+ if not (args.target and args.self_domain):
+ print(json.dumps({"status": "ERROR",
+ "reason": "--gap exige --target e --self"}, ensure_ascii=False))
+ sys.exit(2)
+ print(json.dumps(
+ keyword_gap(args.target, args.self_domain, args.location, args.language),
+ indent=2, ensure_ascii=False
+ ))
+ return
+
+ if args.keywords:
+ kws = [k.strip() for k in args.keywords.split(",") if k.strip()]
+ print(json.dumps(
+ serp_intel(kws, args.location, args.language),
+ indent=2, ensure_ascii=False
+ ))
+ return
+
+ p.print_help()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.claude/skills/mkt-competitive-brief/tests/conftest.py b/.claude/skills/mkt-competitive-brief/tests/conftest.py
new file mode 100644
index 00000000..f08aa8a5
--- /dev/null
+++ b/.claude/skills/mkt-competitive-brief/tests/conftest.py
@@ -0,0 +1,97 @@
+"""conftest.py — pytest config para mkt-competitive-brief.
+
+Bloqueia toda rede real (urllib.request autouse).
+Carrega competitor_intel.py via importlib (não é package instalado).
+
+competitor_intel.py usa importlib internamente para carregar dataforseo_client.py
+de mkt-seo-ops. Os testes mocam _load_client() diretamente no módulo carregado
+(monkeypatch) — padrão mais simples e robusto que mocar o importlib.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import sys
+import types
+import urllib.error
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+
+_TESTS_DIR = Path(__file__).resolve().parent
+_SKILL_ROOT = _TESTS_DIR.parent
+_SCRIPTS_DIR = _SKILL_ROOT / "scripts"
+
+
+def _load(name: str):
+ """Carrega módulo de scripts/ via importlib (skill scripts não são package)."""
+ spec = importlib.util.spec_from_file_location(name, _SCRIPTS_DIR / f"{name}.py")
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ sys.modules[name] = mod
+ return mod
+
+
+# Carrega o módulo uma vez por sessão (tests são stateless via monkeypatch).
+_ci_mod = _load("competitor_intel")
+
+
+@pytest.fixture(autouse=True)
+def block_network(monkeypatch):
+ """Bloqueia TODA rede real para todos os testes.
+
+ Substitui urllib.request.urlopen por uma função que levanta URLError.
+ Testes que precisam de comportamento específico usam mock_client.
+ """
+
+ def _deny(*args, **kwargs):
+ raise urllib.error.URLError(
+ "PROIBIDO: teste tentou abrir conexão de rede real. "
+ "Use mock_client fixture para configurar respostas stub."
+ )
+
+ monkeypatch.setattr("urllib.request.urlopen", _deny)
+ yield
+
+
+@pytest.fixture()
+def ci_mod():
+ """Retorna o módulo competitor_intel carregado."""
+ return _ci_mod
+
+
+def _make_client(available: bool = True, keyword_intel_resp=None, post_resp=None):
+ """Cria um mock de DataForSEOClient com comportamento configurável."""
+ client = MagicMock()
+ client.available.return_value = available
+ if keyword_intel_resp is not None:
+ client.keyword_intel.return_value = keyword_intel_resp
+ if post_resp is not None:
+ client._post.return_value = post_resp
+ return client
+
+
+@pytest.fixture()
+def mock_client(monkeypatch):
+ """
+ Helper para mockar _load_client() em competitor_intel.
+
+ Uso:
+ def test_foo(mock_client, ci_mod):
+ client = mock_client(available=True, keyword_intel_resp={...})
+ result = ci_mod.serp_intel(["kw1"])
+ ...
+
+ Retorna o client mock configurado.
+ """
+
+ def _setup(available=True, keyword_intel_resp=None, post_resp=None, load_error=None):
+ if load_error:
+ monkeypatch.setattr(_ci_mod, "_load_client", lambda: (None, load_error))
+ return None
+ client = _make_client(available, keyword_intel_resp, post_resp)
+ monkeypatch.setattr(_ci_mod, "_load_client", lambda: (client, None))
+ return client
+
+ return _setup
diff --git a/.claude/skills/mkt-competitive-brief/tests/test_competitor_intel.py b/.claude/skills/mkt-competitive-brief/tests/test_competitor_intel.py
new file mode 100644
index 00000000..798f9f8f
--- /dev/null
+++ b/.claude/skills/mkt-competitive-brief/tests/test_competitor_intel.py
@@ -0,0 +1,480 @@
+"""Testes para competitor_intel.py — mkt-competitive-brief.
+
+Cobertura:
+ - N/A graceful: sem credencial DataForSEO (client carregado mas indisponível)
+ - N/A graceful: dataforseo_client.py não encontrado (load_error)
+ - N/A graceful: location não mapeada → nunca default BR silencioso (anti-fabricação)
+ - Truncation >50 keywords: processa só as 50 primeiras, sinaliza truncated=True
+ - Erro de rede (URLError) → N/A com razão, nunca exception não tratada
+ - Parsing de keyword-gap: campos extraídos corretamente de payload domain_intersection
+ - Anti-fabricação: N/A nunca contém dado inventado
+ - Smoke CLI: --check sem credencial retorna JSON válido
+ - serp_intel OK path: status OK, source e collected_at presentes
+ - keyword_gap OK path: gap_keywords com estrutura correta
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from io import StringIO
+from unittest.mock import MagicMock
+
+import pytest
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Helpers
+# ─────────────────────────────────────────────────────────────────────────────
+
+def _gap_payload(items: list) -> dict:
+ """Monta resposta DataForSEO Labs domain_intersection."""
+ return {
+ "tasks": [{
+ "result": [{
+ "items": [
+ {
+ "keyword_data": {
+ "keyword": item["keyword"],
+ "keyword_info": {"search_volume": item.get("sv", 1000)},
+ },
+ "first_domain_serp_element": {
+ "rank_absolute": item.get("rank", 3),
+ "url": item.get("url", f"https://competitor.com.br/{item['keyword']}"),
+ },
+ }
+ for item in items
+ ]
+ }]
+ }]
+ }
+
+
+def _serp_payload(kw: str) -> dict:
+ """Resposta mínima de serp/google/organic/live/advanced."""
+ return {
+ "tasks": [{
+ "result": [{
+ "items": [
+ {"type": "organic"},
+ {"type": "featured_snippet"},
+ ]
+ }]
+ }]
+ }
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# serp_intel — N/A paths
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestSerpIntelNAGraceful:
+ """serp_intel() deve retornar N/A sem fabricar dado."""
+
+ def test_na_when_client_not_loaded(self, mock_client, ci_mod):
+ """dataforseo_client.py não encontrado → N/A com reason."""
+ mock_client(load_error="dataforseo_client.py não encontrado")
+ result = ci_mod.serp_intel(["usinagem cnc"])
+ assert result["status"] == "N/A"
+ assert "reason" in result
+ assert "collected_at" in result
+ # Anti-fabricação: não deve ter keywords inventadas
+ assert "keywords" not in result or result.get("keywords") == []
+
+ def test_na_when_credentials_absent(self, mock_client, ci_mod):
+ """Client carregado mas sem credenciais → N/A."""
+ mock_client(available=False)
+ result = ci_mod.serp_intel(["usinagem cnc"])
+ assert result["status"] == "N/A"
+ assert "reason" in result
+ assert "collected_at" in result
+
+ def test_na_reason_mentions_dataforseo(self, mock_client, ci_mod):
+ """Mensagem de N/A deve mencionar DataForSEO (instrução para usuário)."""
+ mock_client(available=False)
+ result = ci_mod.serp_intel(["usinagem cnc"])
+ assert "dataforseo" in result["reason"].lower() or "DATAFORSEO" in result["reason"]
+
+ def test_na_unmapped_location(self, mock_client, ci_mod):
+ """Location não mapeada (ex: 'jp') → N/A explícito, nunca default BR."""
+ mock_client(available=True)
+ result = ci_mod.serp_intel(["usinagem cnc"], location="jp")
+ assert result["status"] == "N/A"
+ assert "jp" in result["reason"]
+ assert "collected_at" in result
+
+ def test_na_unmapped_location_does_not_fallback(self, mock_client, ci_mod):
+ """Location inválida NÃO deve silenciosamente usar BR (anti-fabricação)."""
+ # O mock não deve ter sido chamado com keyword_intel se location é inválida
+ client = mock_client(available=True)
+ ci_mod.serp_intel(["usinagem"], location="xx")
+ # keyword_intel não deve ter sido chamado (verificado via mock)
+ if client is not None:
+ client.keyword_intel.assert_not_called()
+
+ def test_na_on_network_error(self, mock_client, ci_mod):
+ """Erro de rede (URLError via keyword_intel) → N/A, não exception."""
+ import urllib.error
+ client = mock_client(available=True)
+ client.keyword_intel.side_effect = urllib.error.URLError("timeout")
+ result = ci_mod.serp_intel(["usinagem cnc"])
+ assert result["status"] == "N/A"
+ assert "reason" in result
+
+ def test_na_on_api_error(self, mock_client, ci_mod):
+ """Exceção genérica em keyword_intel → N/A, não propaga."""
+ client = mock_client(available=True)
+ client.keyword_intel.side_effect = ValueError("unexpected API response")
+ result = ci_mod.serp_intel(["usinagem cnc"])
+ assert result["status"] == "N/A"
+ assert "reason" in result
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# serp_intel — OK path
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestSerpIntelOKPath:
+ """serp_intel() com client disponível e dados reais."""
+
+ def _ok_resp(self, kws):
+ return {
+ "status": "OK",
+ "keywords": [
+ {"keyword": kw, "intent": "informational",
+ "serp_features": ["featured_snippet"], "ai_overview": False}
+ for kw in kws
+ ]
+ }
+
+ def test_ok_status_and_provenance_fields(self, mock_client, ci_mod):
+ """status OK deve carregar source, source_url e collected_at."""
+ kws = ["usinagem cnc", "retifica industrial"]
+ mock_client(available=True, keyword_intel_resp=self._ok_resp(kws))
+ result = ci_mod.serp_intel(kws)
+ assert result["status"] == "OK"
+ assert "source" in result
+ assert "source_url" in result
+ assert "collected_at" in result
+
+ def test_ok_keywords_passthrough(self, mock_client, ci_mod):
+ """keywords retornadas devem estar no output."""
+ kws = ["torno cnc", "fresadora"]
+ mock_client(available=True, keyword_intel_resp=self._ok_resp(kws))
+ result = ci_mod.serp_intel(kws)
+ assert result["status"] == "OK"
+ returned_kws = [k["keyword"] for k in result.get("keywords", [])]
+ for kw in kws:
+ assert kw in returned_kws
+
+ def test_location_passed_to_client(self, mock_client, ci_mod):
+ """location deve ser repassada ao keyword_intel."""
+ kws = ["usinagem"]
+ client = mock_client(available=True, keyword_intel_resp=self._ok_resp(kws))
+ ci_mod.serp_intel(kws, location="br")
+ client.keyword_intel.assert_called_once()
+ call_kwargs = client.keyword_intel.call_args
+ assert call_kwargs[1].get("location", None) == "br" or \
+ (call_kwargs[0] and call_kwargs[0][1] == "br")
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# serp_intel — Truncation >50 keywords
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestSerpIntelTruncation:
+ """Mais de 50 keywords → só 50 processadas, flag truncated=True."""
+
+ def _ok_resp_n(self, n):
+ return {
+ "status": "OK",
+ "keywords": [
+ {"keyword": f"kw{i}", "intent": "-",
+ "serp_features": [], "ai_overview": False}
+ for i in range(min(n, 50))
+ ]
+ }
+
+ def test_51_keywords_sets_truncated(self, mock_client, ci_mod):
+ """51 keywords → truncated=True no output."""
+ kws = [f"kw{i}" for i in range(51)]
+ client = mock_client(available=True, keyword_intel_resp=self._ok_resp_n(50))
+ result = ci_mod.serp_intel(kws)
+ assert result.get("truncated") is True
+
+ def test_truncated_note_present(self, mock_client, ci_mod):
+ """truncated=True deve vir com truncated_note explicativo."""
+ kws = [f"kw{i}" for i in range(60)]
+ mock_client(available=True, keyword_intel_resp=self._ok_resp_n(50))
+ result = ci_mod.serp_intel(kws)
+ assert "truncated_note" in result
+ assert "50" in result["truncated_note"]
+
+ def test_51_keywords_calls_with_50(self, mock_client, ci_mod):
+ """keyword_intel deve ser chamado com máximo 50 keywords."""
+ kws = [f"kw{i}" for i in range(51)]
+ client = mock_client(available=True, keyword_intel_resp=self._ok_resp_n(50))
+ ci_mod.serp_intel(kws)
+ called_kws = client.keyword_intel.call_args[0][0]
+ assert len(called_kws) == 50
+
+ def test_50_keywords_no_truncated(self, mock_client, ci_mod):
+ """Exatamente 50 keywords → truncated NÃO deve estar presente."""
+ kws = [f"kw{i}" for i in range(50)]
+ mock_client(available=True, keyword_intel_resp=self._ok_resp_n(50))
+ result = ci_mod.serp_intel(kws)
+ assert result.get("truncated") is not True
+
+ def test_49_keywords_no_truncated(self, mock_client, ci_mod):
+ """49 keywords → sem truncação."""
+ kws = [f"kw{i}" for i in range(49)]
+ mock_client(available=True, keyword_intel_resp=self._ok_resp_n(49))
+ result = ci_mod.serp_intel(kws)
+ assert result.get("truncated") is not True
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# keyword_gap — N/A paths
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestKeywordGapNAGraceful:
+ """keyword_gap() deve retornar N/A sem fabricar dado."""
+
+ def test_na_when_client_not_loaded(self, mock_client, ci_mod):
+ mock_client(load_error="mkt-seo-ops não instalado")
+ result = ci_mod.keyword_gap("competitor.com.br", "cliente.com.br")
+ assert result["status"] == "N/A"
+ assert "reason" in result
+ assert "collected_at" in result
+
+ def test_na_when_credentials_absent(self, mock_client, ci_mod):
+ mock_client(available=False)
+ result = ci_mod.keyword_gap("competitor.com.br", "cliente.com.br")
+ assert result["status"] == "N/A"
+ assert "collected_at" in result
+
+ def test_na_unmapped_location(self, mock_client, ci_mod):
+ """location não mapeada → N/A com o nome da location na mensagem."""
+ mock_client(available=True)
+ result = ci_mod.keyword_gap("competitor.com.br", "cliente.com.br", location="jp")
+ assert result["status"] == "N/A"
+ assert "jp" in result["reason"]
+
+ def test_na_unmapped_location_no_post_call(self, mock_client, ci_mod):
+ """_post NÃO deve ser chamado para location inválida."""
+ client = mock_client(available=True)
+ ci_mod.keyword_gap("competitor.com.br", "cliente.com.br", location="invalid")
+ client._post.assert_not_called()
+
+ def test_na_on_post_exception(self, mock_client, ci_mod):
+ """Exceção em _post → N/A, não propaga."""
+ client = mock_client(available=True)
+ client._post.side_effect = RuntimeError("API offline")
+ result = ci_mod.keyword_gap("competitor.com.br", "cliente.com.br")
+ assert result["status"] == "N/A"
+ assert "reason" in result
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# keyword_gap — OK path e parsing
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestKeywordGapParsing:
+ """Parsing correto do payload domain_intersection."""
+
+ def _setup_gap(self, mock_client, ci_mod, items):
+ client = mock_client(available=True)
+ client._post.return_value = _gap_payload(items)
+ return client
+
+ def test_ok_status_and_provenance_fields(self, mock_client, ci_mod):
+ """status OK deve ter source, source_url, collected_at."""
+ self._setup_gap(mock_client, ci_mod, [
+ {"keyword": "usinagem cnc", "sv": 2400, "rank": 1}
+ ])
+ result = ci_mod.keyword_gap("competitor.com.br", "cliente.com.br")
+ assert result["status"] == "OK"
+ assert "source" in result
+ assert "source_url" in result
+ assert "collected_at" in result
+
+ def test_gap_keywords_list_present(self, mock_client, ci_mod):
+ """gap_keywords deve ser uma lista."""
+ self._setup_gap(mock_client, ci_mod, [
+ {"keyword": "retifica industrial", "sv": 1100, "rank": 2}
+ ])
+ result = ci_mod.keyword_gap("competitor.com.br", "cliente.com.br")
+ assert "gap_keywords" in result
+ assert isinstance(result["gap_keywords"], list)
+
+ def test_gap_keywords_fields(self, mock_client, ci_mod):
+ """Cada gap_keyword deve ter keyword, search_volume, competitor_rank, competitor_url."""
+ items = [{"keyword": "torno cnc", "sv": 3000, "rank": 1,
+ "url": "https://competitor.com.br/torno-cnc"}]
+ self._setup_gap(mock_client, ci_mod, items)
+ result = ci_mod.keyword_gap("competitor.com.br", "cliente.com.br")
+ assert result["status"] == "OK"
+ gaps = result["gap_keywords"]
+ assert len(gaps) == 1
+ g = gaps[0]
+ assert g["keyword"] == "torno cnc"
+ assert g["search_volume"] == 3000
+ assert g["competitor_rank"] == 1
+ assert "competitor.com.br" in g["competitor_url"]
+
+ def test_gap_keywords_multiple_items(self, mock_client, ci_mod):
+ """Múltiplos items devem ser todos extraídos."""
+ items = [
+ {"keyword": f"kw{i}", "sv": 1000 - i * 10, "rank": i + 1}
+ for i in range(5)
+ ]
+ self._setup_gap(mock_client, ci_mod, items)
+ result = ci_mod.keyword_gap("competitor.com.br", "cliente.com.br")
+ assert len(result["gap_keywords"]) == 5
+
+ def test_gap_target_and_self_in_result(self, mock_client, ci_mod):
+ """target_competitor e self_domain devem estar no resultado."""
+ self._setup_gap(mock_client, ci_mod, [{"keyword": "kw1"}])
+ result = ci_mod.keyword_gap("competitor.com.br", "cliente.com.br")
+ assert result["target_competitor"] == "competitor.com.br"
+ assert result["self_domain"] == "cliente.com.br"
+
+ def test_gap_note_mentions_error_range(self, mock_client, ci_mod):
+ """Note deve mencionar faixa de erro (anti-fabricação — rotulagem obrigatória)."""
+ self._setup_gap(mock_client, ci_mod, [{"keyword": "kw1"}])
+ result = ci_mod.keyword_gap("competitor.com.br", "cliente.com.br")
+ note = result.get("note", "")
+ # 15% ou 30% ou "15-30" deve aparecer
+ assert "15" in note or "30" in note
+
+ def test_post_called_with_correct_structure(self, mock_client, ci_mod):
+ """_post deve ser chamado com domain_intersection endpoint."""
+ client = self._setup_gap(mock_client, ci_mod, [])
+ ci_mod.keyword_gap("competitor.com.br", "cliente.com.br", location="br")
+ assert client._post.called
+ call_args = client._post.call_args[0]
+ assert "domain_intersection" in call_args[0]
+
+ def test_empty_result_from_api(self, mock_client, ci_mod):
+ """API retorna tasks vazias → gap_keywords = []."""
+ client = mock_client(available=True)
+ client._post.return_value = {"tasks": []}
+ result = ci_mod.keyword_gap("competitor.com.br", "cliente.com.br")
+ assert result["status"] == "OK"
+ assert result["gap_keywords"] == []
+
+ def test_limit_passed_to_payload(self, mock_client, ci_mod):
+ """limit deve ser repassado ao payload (limitado a 100)."""
+ client = mock_client(available=True)
+ client._post.return_value = {"tasks": []}
+ ci_mod.keyword_gap("competitor.com.br", "cliente.com.br", limit=30)
+ payload = client._post.call_args[0][1]
+ assert payload[0]["limit"] == 30
+
+ def test_limit_capped_at_100(self, mock_client, ci_mod):
+ """limit > 100 deve ser capeado em 100 antes de enviar."""
+ client = mock_client(available=True)
+ client._post.return_value = {"tasks": []}
+ ci_mod.keyword_gap("competitor.com.br", "cliente.com.br", limit=200)
+ payload = client._post.call_args[0][1]
+ assert payload[0]["limit"] <= 100
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Anti-fabricação — invariantes gerais
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestAntiFabricacao:
+ """Invariantes anti-fabricação: N/A nunca contém dado inventado."""
+
+ def test_serp_na_no_keywords_in_output(self, mock_client, ci_mod):
+ """serp_intel N/A (sem credencial) não deve ter lista keywords populada."""
+ mock_client(available=False)
+ result = ci_mod.serp_intel(["usinagem cnc"])
+ assert result["status"] == "N/A"
+ # keywords não deve existir ou deve ser vazia
+ kws = result.get("keywords", [])
+ assert kws == [] or kws is None
+
+ def test_gap_na_no_gap_keywords_in_output(self, mock_client, ci_mod):
+ """keyword_gap N/A (sem credencial) não deve ter gap_keywords."""
+ mock_client(available=False)
+ result = ci_mod.keyword_gap("competitor.com.br", "cliente.com.br")
+ assert result["status"] == "N/A"
+ assert "gap_keywords" not in result
+
+ def test_serp_na_always_has_collected_at(self, mock_client, ci_mod):
+ """Todo N/A deve ter collected_at (proveniência mínima)."""
+ mock_client(load_error="not found")
+ result = ci_mod.serp_intel(["kw1"])
+ assert "collected_at" in result
+
+ def test_gap_na_always_has_collected_at(self, mock_client, ci_mod):
+ mock_client(load_error="not found")
+ result = ci_mod.keyword_gap("a.com", "b.com")
+ assert "collected_at" in result
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# CLI smoke — --check
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestCLISmoke:
+ """CLI --check deve retornar JSON válido sem rede."""
+
+ def test_check_without_credentials(self, mock_client, ci_mod, capsys):
+ """--check sem credenciais: retorna JSON com client_loaded e available."""
+ import os
+
+ # Garante que as variáveis de env não estão presentes
+ orig_login = os.environ.pop("DATAFORSEO_LOGIN", None)
+ orig_pass = os.environ.pop("DATAFORSEO_PASSWORD", None)
+ try:
+ # Não usar mock_client: testa o _load_client real (que vai buscar o arquivo)
+ # mas como dataforseo_client.py pode ou não existir no caminho,
+ # verificamos só que o output é JSON válido
+ sys.argv = ["competitor_intel.py", "--check"]
+ try:
+ ci_mod.main()
+ except SystemExit:
+ pass
+ out = capsys.readouterr().out.strip()
+ data = json.loads(out)
+ assert "client_loaded" in data
+ assert "available" in data
+ finally:
+ if orig_login is not None:
+ os.environ["DATAFORSEO_LOGIN"] = orig_login
+ if orig_pass is not None:
+ os.environ["DATAFORSEO_PASSWORD"] = orig_pass
+
+ def test_check_json_schema(self, mock_client, ci_mod, capsys):
+ """--check sempre retorna client_loaded (bool) e available (bool)."""
+ import os
+ os.environ.pop("DATAFORSEO_LOGIN", None)
+ os.environ.pop("DATAFORSEO_PASSWORD", None)
+ sys.argv = ["competitor_intel.py", "--check"]
+ try:
+ ci_mod.main()
+ except SystemExit:
+ pass
+ out = capsys.readouterr().out.strip()
+ data = json.loads(out)
+ assert isinstance(data["client_loaded"], bool)
+ assert isinstance(data["available"], bool)
+
+ def test_gap_missing_target_exits_nonzero(self, mock_client, ci_mod):
+ """--gap sem --target ou --self deve sair com erro (sem travar)."""
+ sys.argv = ["competitor_intel.py", "--gap"]
+ with pytest.raises(SystemExit) as exc_info:
+ ci_mod.main()
+ assert exc_info.value.code != 0
+
+ def test_no_args_prints_help(self, mock_client, ci_mod, capsys):
+ """Sem argumentos deve imprimir help (sem exception não tratada)."""
+ sys.argv = ["competitor_intel.py"]
+ try:
+ ci_mod.main()
+ except SystemExit:
+ pass
+ # Não deve levantar exception não tratada — sucesso se chegou aqui
diff --git a/.claude/skills/mkt-seo-ops/dataforseo_client.py b/.claude/skills/mkt-seo-ops/dataforseo_client.py
new file mode 100644
index 00000000..3390053f
--- /dev/null
+++ b/.claude/skills/mkt-seo-ops/dataforseo_client.py
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+"""
+DataForSEO client (SHOULD) — intent + SERP features + AI Overview parse.
+
+Maior alavanca custo-benefício da spec (Labs ~$0.025/1k kw). Cobre:
+ - SERP intent classification (S5)
+ - SERP feature targeting / PAA / featured snippet (S4)
+ - AI Overview presence parse (S2 — measurement gap de 2026)
+
+DEGRADAÇÃO GRACIOSA (regra do projeto): se DATAFORSEO_LOGIN/DATAFORSEO_PASSWORD
+não existirem no ambiente, `available()` retorna False e os consumidores reportam
+N/A com instrução — NUNCA fabrica dado nem quebra o pipeline.
+
+Auth: DataForSEO usa HTTP Basic (login + password da conta).
+
+Uso:
+ from dataforseo_client import DataForSEOClient
+ c = DataForSEOClient()
+ if c.available():
+ intel = c.keyword_intel(["usinagem", "retífica"], location="br", language="pt-BR")
+"""
+
+import os
+import json
+import base64
+import urllib.request
+import urllib.error
+
+
+# Mapas país→location_code / lang→language_code do DataForSEO.
+# Cobre o essencial BR; ampliável. Ausência → cai em fallback global.
+_LOCATION_CODES = {
+ "br": 2076, # Brazil
+ "us": 2840, # United States
+ "pt": 2620, # Portugal
+}
+_LANGUAGE_CODES = {
+ "pt-br": "pt",
+ "pt": "pt",
+ "en": "en",
+ "en-us": "en",
+}
+
+_API_BASE = "https://api.dataforseo.com/v3"
+
+
+class DataForSEOClient:
+ def __init__(self, login=None, password=None):
+ self.login = login or os.environ.get("DATAFORSEO_LOGIN", "")
+ self.password = password or os.environ.get("DATAFORSEO_PASSWORD", "")
+
+ def available(self) -> bool:
+ """True só se ambas credenciais estiverem presentes."""
+ return bool(self.login and self.password)
+
+ def _auth_header(self) -> str:
+ raw = f"{self.login}:{self.password}".encode("utf-8")
+ return "Basic " + base64.b64encode(raw).decode("ascii")
+
+ def _post(self, path: str, payload: list) -> dict:
+ url = f"{_API_BASE}/{path}"
+ data = json.dumps(payload).encode("utf-8")
+ req = urllib.request.Request(url, data=data, method="POST")
+ req.add_header("Authorization", self._auth_header())
+ req.add_header("Content-Type", "application/json")
+ with urllib.request.urlopen(req, timeout=60) as resp:
+ return json.loads(resp.read().decode("utf-8"))
+
+ def keyword_intel(self, keywords, location="br", language="pt-BR") -> dict:
+ """
+ Retorna intent + SERP features + AI Overview presence por keyword.
+ Em qualquer erro de rede/API → status N/A com a razão (não fabrica).
+ """
+ if not self.available():
+ return {"status": "N/A", "reason": "sem DATAFORSEO_LOGIN/PASSWORD"}
+ if not keywords:
+ return {"status": "OK", "keywords": []}
+
+ loc = _LOCATION_CODES.get(location.lower(), 2076)
+ lang = _LANGUAGE_CODES.get(language.lower(), "pt")
+
+ out = []
+ # 1) Intent via DataForSEO Labs (batch)
+ intent_map = {}
+ try:
+ payload = [{
+ "keywords": keywords,
+ "location_code": loc,
+ "language_code": lang,
+ }]
+ resp = self._post("dataforseo_labs/google/search_intent/live", payload)
+ tasks = resp.get("tasks", []) or []
+ for t in tasks:
+ for res in (t.get("result") or []):
+ for item in (res.get("items") or []):
+ kw = item.get("keyword", "")
+ ki = item.get("keyword_intent") or {}
+ intent_map[kw] = ki.get("label", "-")
+ except (urllib.error.URLError, urllib.error.HTTPError, ValueError, KeyError) as e:
+ return {"status": "N/A", "reason": f"erro DataForSEO Labs (intent): {e}"}
+
+ # 2) SERP features + AI Overview — uma tarefa por keyword (live advanced)
+ # Mantém barato: só as keywords passadas (já limitadas pelo chamador).
+ for kw in keywords:
+ feats, ai_ov = [], False
+ try:
+ payload = [{
+ "keyword": kw,
+ "location_code": loc,
+ "language_code": lang,
+ "device": "desktop",
+ }]
+ resp = self._post("serp/google/organic/live/advanced", payload)
+ for t in (resp.get("tasks") or []):
+ for res in (t.get("result") or []):
+ seen = set()
+ for item in (res.get("items") or []):
+ itype = item.get("type", "")
+ if itype and itype not in seen:
+ seen.add(itype)
+ if itype == "ai_overview":
+ ai_ov = True
+ # features = tipos de bloco != organic/paid
+ feats = sorted(t_ for t_ in seen
+ if t_ not in ("organic", "paid"))
+ except (urllib.error.URLError, urllib.error.HTTPError, ValueError, KeyError):
+ # falha numa keyword não derruba o lote — marca features vazias
+ feats, ai_ov = [], False
+ out.append({
+ "keyword": kw,
+ "intent": intent_map.get(kw, "-"),
+ "serp_features": feats,
+ "ai_overview": ai_ov,
+ })
+
+ return {"status": "OK", "keywords": out}
+
+
+def main():
+ import argparse
+ parser = argparse.ArgumentParser(description="DataForSEO client (SHOULD)")
+ parser.add_argument("--check", action="store_true", help="Checa disponibilidade")
+ parser.add_argument("--keywords", help="CSV de keywords para teste")
+ parser.add_argument("--location", default="br")
+ parser.add_argument("--language", default="pt-BR")
+ args = parser.parse_args()
+
+ c = DataForSEOClient()
+ if args.check or not args.keywords:
+ print(json.dumps({"available": c.available()}, indent=2))
+ return
+ kws = [k.strip() for k in args.keywords.split(",") if k.strip()]
+ print(json.dumps(c.keyword_intel(kws, args.location, args.language),
+ indent=2, ensure_ascii=False))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.env.example b/.env.example
index 11610d8e..1ebe24fd 100644
--- a/.env.example
+++ b/.env.example
@@ -108,6 +108,13 @@ META_APP_SECRET=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
+# ── License — headless auto-activation ───────────────
+# Set this to the email used in your first manual license registration.
+# On startup, EvoNexus calls /v1/register/auto silently and skips the manual
+# setup screen. Falls back to manual setup if the email isn't registered yet.
+# Leave empty (or unset) to keep the default behavior.
+# EVOLUTION_OPERATOR_EMAIL=operator@example.com
+
# ── Evolution API ────────────────────────────────────
# Your Evolution API instance URL and global API key
EVOLUTION_API_URL=
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f4fd5855..8070b35b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -12,7 +12,7 @@ Harassment, discrimination, or abusive behavior will not be tolerated.
### Reporting Bugs
-1. Check existing [issues](https://github.com/EvolutionAPI/evo-nexus/issues)
+1. Check existing [issues](https://github.com/evolution-foundation/evo-nexus/issues)
to avoid duplicates
2. Open a new issue with:
- Clear, descriptive title
diff --git a/README.md b/README.md
index 73e204c7..70be4e38 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@
-
+
@@ -46,7 +46,7 @@ It turns a single CLI installation into a team of **38 specialized agents** orga
## Part of the Evolution Foundation ecosystem
-EvoNexus is one of the projects maintained by Evolution Foundation. It is the operating layer that orchestrates the Foundation's own work — including the development of [Evo CRM Community](https://github.com/EvolutionAPI/evo-crm-community), [Evolution API](https://github.com/EvolutionAPI/evolution-api) and [Evolution Go](https://github.com/EvolutionAPI/evolution-go).
+EvoNexus is one of the projects maintained by Evolution Foundation. It is the operating layer that orchestrates the Foundation's own work — including the development of [Evo CRM Community](https://github.com/evolution-foundation/evo-crm-community), [Evolution API](https://github.com/evolution-foundation/evolution-api) and [Evolution Go](https://github.com/evolution-foundation/evolution-go).
### Why EvoNexus?
@@ -101,7 +101,7 @@ EvoNexus is one of the projects maintained by Evolution Foundation. It is the op
### Method 1 — Docker (no setup, runs anywhere)
```bash
-curl -O https://raw.githubusercontent.com/EvolutionAPI/evo-nexus/main/docker-compose.hub.yml
+curl -O https://raw.githubusercontent.com/evolution-foundation/evo-nexus/main/docker-compose.hub.yml
docker compose -f docker-compose.hub.yml up -d
open http://localhost:8080
```
@@ -117,7 +117,7 @@ npx @evoapi/evo-nexus
### Method 3 — Manual clone (developers / contributors)
```bash
-git clone --depth 1 https://github.com/EvolutionAPI/evo-nexus.git
+git clone --depth 1 https://github.com/evolution-foundation/evo-nexus.git
cd evo-nexus
# Interactive setup wizard
diff --git a/dashboard/backend/licensing.py b/dashboard/backend/licensing.py
index 60ed26f2..9677cf24 100644
--- a/dashboard/backend/licensing.py
+++ b/dashboard/backend/licensing.py
@@ -4,12 +4,14 @@
Protocol:
POST /v1/register/direct — register with email/name, receive api_key
+ POST /v1/register/auto — headless register by email (must exist server-side)
POST /v1/activate — validate existing api_key on startup
GET /api/geo — geo-lookup from client IP
"""
import hashlib
import hmac as hmac_mod
+import os
import socket
import uuid
import logging
@@ -155,6 +157,24 @@ def direct_register(email: str, name: str, instance_id: str,
return _post("/v1/register/direct", payload)
+# ── Auto Registration (email-only, headless) ──
+
+def auto_register(email: str, instance_id: str) -> dict:
+ """Headless registration using only the operator email.
+
+ The customer must already exist on the licensing server (one prior manual
+ registration). Used by the EVOLUTION_OPERATOR_EMAIL env-var flow.
+
+ Returns {api_key, customer_id, tier, status}.
+ """
+ return _post("/v1/register/auto", {
+ "email": email,
+ "tier": TIER,
+ "instance_id": instance_id,
+ "version": VERSION,
+ })
+
+
# ── Activation (startup with existing api_key) ──
def activate(instance_id: str, api_key: str) -> bool:
@@ -260,8 +280,54 @@ def initialize_runtime():
# ── Auto-register for existing installs ──────
+def try_auto_register_from_env(instance_id: str) -> bool:
+ """Headless activation via EVOLUTION_OPERATOR_EMAIL env var.
+
+ Requires the email to already exist on the licensing server (one prior
+ manual registration). Returns True on success.
+
+ Failures are silent — caller falls back to the existing admin-based or
+ manual setup flow.
+ """
+ email = os.environ.get("EVOLUTION_OPERATOR_EMAIL", "").strip()
+ if not email:
+ return False
+
+ try:
+ result = auto_register(email=email, instance_id=instance_id)
+ except requests.HTTPError as e:
+ status = e.response.status_code if e.response is not None else "?"
+ if status == 404:
+ logger.info("Auto-activation skipped — email not registered yet (first time?).")
+ else:
+ logger.warning(f"Auto-activation rejected ({status}): falling back to manual flow.")
+ return False
+ except Exception as e:
+ logger.warning(f"Auto-activation skipped — {e}")
+ return False
+
+ api_key = result.get("api_key")
+ if not api_key:
+ logger.warning("Auto-activation response missing api_key")
+ return False
+
+ set_runtime_config("api_key", api_key)
+ set_runtime_config("tier", result.get("tier", TIER))
+ if result.get("customer_id"):
+ set_runtime_config("customer_id", str(result["customer_id"]))
+ set_runtime_config("version", VERSION)
+ set_runtime_config("registered_at", datetime.now(timezone.utc).isoformat())
+
+ ctx = get_context()
+ ctx.api_key = api_key
+ ctx.instance_id = instance_id
+ logger.info("License activated automatically via EVOLUTION_OPERATOR_EMAIL")
+ return True
+
+
def auto_register_if_needed():
- """If users exist but no license, register retroactively."""
+ """If no license yet, try EVOLUTION_OPERATOR_EMAIL first, then fall back to
+ the admin-based retroactive flow."""
try:
instance_id = get_runtime_config("instance_id")
api_key = get_runtime_config("api_key")
@@ -270,6 +336,15 @@ def auto_register_if_needed():
initialize_runtime()
return
+ if not instance_id:
+ instance_id = generate_instance_id()
+ set_runtime_config("instance_id", instance_id)
+
+ # First-class path: silent activation from env var.
+ if try_auto_register_from_env(instance_id):
+ return
+
+ # Fallback: if there's an admin user already, register retroactively.
from models import User
if User.query.count() == 0:
return
@@ -278,10 +353,6 @@ def auto_register_if_needed():
if not admin or not admin.email:
return
- if not instance_id:
- instance_id = generate_instance_id()
- set_runtime_config("instance_id", instance_id)
-
setup_perform(
email=admin.email or "",
name=admin.display_name or admin.username,