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 @@

- Latest version + Latest version License: Apache 2.0 Documentation Community @@ -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,