@danhachuel/prodmaker 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,45 @@
3
3
  Todas as versões seguem a etiqueta visual `MAJOR.MINOR.PATCH` com patch de dois dígitos
4
4
  (o registry NPM normaliza `0.1.00` → `0.1.0`).
5
5
 
6
+ ## 0.1.06 — 2026-10-26
7
+
8
+ - **Parte 5 — AdsTracking: Minhas Visitas (AdsPixel) funcional, layout AdsX 1:1.**
9
+ - Página completa sobre a tabela `visitas`: barra superior com período (90 dias) e contagens;
10
+ **alerta de POSSÍVEL DRENAGEM DE ORÇAMENTO** (IPs com ≥ 4 cliques repetidos) com botão
11
+ "Copiar Todos (N IPs)"; cards de visitantes (IP, ID, cliques, dispositivo, último acesso) com
12
+ **modal do visitante** (badge Humano, detalhes, abas IDs de Rastreamento / Payload JSON com
13
+ copiar, e últimos eventos).
14
+ - Métricas: Visitas Humanas, Cliques/CTR, Checkouts, Vendas (zerados até chegarem webhooks —
15
+ nunca inventados); 3 donuts CSS (Origem do Tráfego, Dispositivos, Sistemas Operacionais —
16
+ parser de User-Agent local, sem serviços externos); **Fluxo de Tráfego ao Vivo** (SVG por
17
+ hora) e **Funil de Conversão** (Visitas → Cliques → Checkouts → Vendas).
18
+ - Backend: `services/tracking.py` (agregações + parser UA + visitor_id por hash de IP) e
19
+ `api/tracking.py` (`GET /api/tracking/dashboard`, `GET /api/tracking/visitante/{ip}`).
20
+ - Estado vazio honesto com instruções (configurar URL do `/track` via túnel + publicar páginas).
21
+
22
+ ## 0.1.05 — 2026-10-26
23
+
24
+ - **Parte 4 — AdsPages & AdsHosting (geração de páginas, clonagem, pixel, domínios e publicação).**
25
+ - **AdsPages funcional:** pre-sell de afiliado e página de vendas (12 seções) geradas por LLM
26
+ (templates do seed, idioma selecionável, dados preenchidos a partir do produto vinculado);
27
+ **clonagem de páginas com higienização** (BeautifulSoup remove scripts, iframes, noscript e
28
+ pixels/analytics do original); pré-visualização local em `/pages/{id}`, editor de HTML
29
+ integrado e download.
30
+ - **Pixel próprio (AdsPixel) embutido** nas páginas geradas quando a URL pública do `/track`
31
+ está configurada (card "Pixel de Rastreamento" em Chaves de API): pageview, clique em CTA e
32
+ tempo na página, capturando gclid/utm/ref — endpoint `/track` (GET/POST + CORS) grava na
33
+ tabela `visitas` (a UI de Minhas Visitas vem na sequência). Sem URL configurada, nenhuma
34
+ tag é embutida (a UI não inventa dados).
35
+ - **AdsHosting — Domínios:** cards no layout AdsX com status pendente/online; verificação por
36
+ DNS público (NS/A via dnspython) e, com API Token Cloudflare configurado, status da zona
37
+ via API; name servers exibidos com botão copiar; "+ Tags, Pixels and Scripts (Opcional)"
38
+ por domínio; adicionar/remover domínios.
39
+ - **Páginas Criadas:** tabela com preview, download, edição de HTML, status rascunho/publicada
40
+ e **Publicar 🚀 via GitHub Contents API** (commit do HTML no repositório configurado em
41
+ Chaves de API; Cloudflare Pages deploy automaticamente quando o repositório está conectado) —
42
+ com link do commit e URL pública.
43
+ - Novas tabelas: `paginas`, `dominios`, `visitas`. requirements.txt: + beautifulsoup4, dnspython.
44
+
6
45
  ## 0.1.04 — 2026-10-25
7
46
 
8
47
  - Favicon 🚀 (SVG) nas guias do navegador: app (SPA), login e páginas `/legal`.
@@ -16,7 +16,12 @@ Esta lista cresce a cada fase conforme novas integrações são ativadas.
16
16
  - **google-genai (SDK oficial Google Gemini)** — Apache-2.0 — https://github.com/googleapis/python-genai
17
17
  - **Markdown** — BSD-3-Clause — https://github.com/Python-Markdown/markdown
18
18
  - **xhtml2pdf** (inclui ReportLab) — MIT/Apache-2.0 — https://github.com/xhtml2pdf/xhtml2pdf
19
- - **httpx** (transitivo do openai; usado pelo cliente Gamma) — BSD-3-Clause — https://github.com/encode/httpx
19
+ - **httpx** (transitivo do openai; usado pelos clientes GitHub/Cloudflare/clone) — BSD-3-Clause — https://github.com/encode/httpx
20
+
21
+ ## Runtime (Parte 4)
22
+
23
+ - **Beautiful Soup 4** — MIT License — https://www.crummy.com/software/BeautifulSoup/ (higienização de páginas clonadas)
24
+ - **dnspython** — ISC License — https://github.com/rbarrois/dnspython (verificação NS/A de domínios)
20
25
 
21
26
  ## Integrações planejadas (fases futuras)
22
27
 
@@ -0,0 +1,92 @@
1
+ import json
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+ from pydantic import BaseModel
5
+
6
+ from ..services.database import get_db, now_str, row_to_dict
7
+ from ..services.dominios import verificar_dominio
8
+ from ..services.llm import LLMError
9
+
10
+ router = APIRouter(prefix="/api/dominios", tags=["dominios"])
11
+
12
+
13
+ class DominioBody(BaseModel):
14
+ nome: str
15
+
16
+
17
+ class TagsBody(BaseModel):
18
+ tags: str = ""
19
+
20
+
21
+ @router.get("")
22
+ def list_dominios():
23
+ with get_db() as conn:
24
+ rows = conn.execute("SELECT * FROM dominios ORDER BY id DESC").fetchall()
25
+ return [row_to_dict(row) for row in rows]
26
+
27
+
28
+ @router.post("")
29
+ def create_dominio(body: DominioBody):
30
+ nome = (body.nome or "").strip().lower().rstrip(".")
31
+ if "." not in nome:
32
+ raise HTTPException(status_code=400, detail="Informe um domínio válido (ex.: meusite.com).")
33
+ with get_db() as conn:
34
+ existing = conn.execute("SELECT id FROM dominios WHERE nome = ?", (nome,)).fetchone()
35
+ if existing:
36
+ raise HTTPException(status_code=400, detail="Este domínio já está cadastrado.")
37
+ timestamp = now_str()
38
+ cursor = conn.execute(
39
+ "INSERT INTO dominios (nome, status, created_at, updated_at) VALUES (?, 'pendente', ?, ?)",
40
+ (nome, timestamp, timestamp),
41
+ )
42
+ row = conn.execute("SELECT * FROM dominios WHERE id = ?", (cursor.lastrowid,)).fetchone()
43
+ return row_to_dict(row)
44
+
45
+
46
+ @router.post("/{dominio_id}/verificar")
47
+ def verificar(dominio_id: int):
48
+ record = _get_or_404(dominio_id)
49
+ try:
50
+ resultado = verificar_dominio(record["nome"])
51
+ except LLMError as exc:
52
+ raise HTTPException(status_code=400, detail=str(exc))
53
+ with get_db() as conn:
54
+ conn.execute(
55
+ "UPDATE dominios SET status = ?, ns_data = ?, a_data = ?, cf_zone_id = ?, updated_at = ? WHERE id = ?",
56
+ (
57
+ resultado["status"],
58
+ json.dumps({"ns": resultado["ns"], "cf_name_servers": resultado["cf_name_servers"]}, ensure_ascii=False),
59
+ json.dumps({"a": resultado["a"]}, ensure_ascii=False),
60
+ resultado["cf_zone_id"],
61
+ now_str(),
62
+ dominio_id,
63
+ ),
64
+ )
65
+ row = conn.execute("SELECT * FROM dominios WHERE id = ?", (dominio_id,)).fetchone()
66
+ record = row_to_dict(row)
67
+ record["verificacao"] = resultado
68
+ return record
69
+
70
+
71
+ @router.put("/{dominio_id}/tags")
72
+ def update_tags(dominio_id: int, body: TagsBody):
73
+ _get_or_404(dominio_id)
74
+ with get_db() as conn:
75
+ conn.execute("UPDATE dominios SET tags = ?, updated_at = ? WHERE id = ?", (body.tags, now_str(), dominio_id))
76
+ return _get_or_404(dominio_id)
77
+
78
+
79
+ @router.delete("/{dominio_id}")
80
+ def delete_dominio(dominio_id: int):
81
+ _get_or_404(dominio_id)
82
+ with get_db() as conn:
83
+ conn.execute("DELETE FROM dominios WHERE id = ?", (dominio_id,))
84
+ return {"ok": True}
85
+
86
+
87
+ def _get_or_404(dominio_id: int):
88
+ with get_db() as conn:
89
+ row = conn.execute("SELECT * FROM dominios WHERE id = ?", (dominio_id,)).fetchone()
90
+ if not row:
91
+ raise HTTPException(status_code=404, detail="Domínio não encontrado.")
92
+ return row_to_dict(row)
@@ -0,0 +1,155 @@
1
+ from fastapi import APIRouter, HTTPException
2
+ from fastapi.responses import FileResponse
3
+ from pydantic import BaseModel
4
+
5
+ from ..services.database import get_db, now_str, row_to_dict
6
+ from ..services.llm import LLMError
7
+ from ..services.pages import gerar_pagina, salvar_html
8
+ from ..services.publish import publicar_pagina
9
+ from ..services.clone import clonar_url
10
+ from ..services.store import STORAGE
11
+
12
+ router = APIRouter(prefix="/api/paginas", tags=["paginas"])
13
+
14
+
15
+ class PaginaBody(BaseModel):
16
+ tipo: str # presell | vendas | clone
17
+ produto_id: int | None = None
18
+ titulo: str = ""
19
+ idioma: str = "Português"
20
+ afiliado_link: str = ""
21
+ checkout_link: str = ""
22
+ avatar: str = ""
23
+ resultado: str = ""
24
+ tempo: str = ""
25
+ dor: str = ""
26
+ prompt: str = ""
27
+ clone_url: str = ""
28
+
29
+
30
+ def _get_produto(produto_id: int | None):
31
+ if not produto_id:
32
+ return None
33
+ with get_db() as conn:
34
+ row = conn.execute("SELECT * FROM produtos WHERE id = ?", (produto_id,)).fetchone()
35
+ return row_to_dict(row) if row else None
36
+
37
+
38
+ @router.post("")
39
+ def create_pagina(body: PaginaBody):
40
+ if not body.titulo.strip():
41
+ raise HTTPException(status_code=400, detail="Informe o título da página.")
42
+ timestamp = now_str()
43
+ with get_db() as conn:
44
+ cursor = conn.execute(
45
+ "INSERT INTO paginas (titulo, tipo, produto_id, idioma, html_path, status, created_at, updated_at) VALUES (?, ?, ?, ?, '', 'rascunho', ?, ?)",
46
+ (body.titulo.strip(), body.tipo, body.produto_id, body.idioma, timestamp, timestamp),
47
+ )
48
+ page_id = cursor.lastrowid
49
+
50
+ try:
51
+ if body.tipo == "clone":
52
+ html, _cloned_title = clonar_url(body.clone_url)
53
+ provider = "clone"
54
+ else:
55
+ produto = _get_produto(body.produto_id)
56
+ result = gerar_pagina(body.tipo, produto, body.model_dump())
57
+ html = result["html"]
58
+ provider = result["provider"]
59
+ salvar_html(page_id, html)
60
+ rel_path = f"pages/{page_id}/index.html"
61
+ with get_db() as conn:
62
+ conn.execute("UPDATE paginas SET html_path = ?, updated_at = ? WHERE id = ?", (rel_path, now_str(), page_id))
63
+ row = conn.execute("SELECT * FROM paginas WHERE id = ?", (page_id,)).fetchone()
64
+ record = row_to_dict(row)
65
+ record["preview_url"] = f"/pages/{page_id}"
66
+ record["provider"] = provider
67
+ return record
68
+ except (LLMError, RuntimeError) as exc:
69
+ with get_db() as conn:
70
+ conn.execute("DELETE FROM paginas WHERE id = ?", (page_id,))
71
+ raise HTTPException(status_code=400, detail=str(exc))
72
+
73
+
74
+ @router.get("")
75
+ def list_paginas():
76
+ with get_db() as conn:
77
+ rows = conn.execute("SELECT * FROM paginas ORDER BY id DESC").fetchall()
78
+ result = []
79
+ for row in rows:
80
+ record = row_to_dict(row)
81
+ record["preview_url"] = f"/pages/{record['id']}"
82
+ result.append(record)
83
+ return result
84
+
85
+
86
+ @router.get("/{page_id}/html")
87
+ def get_html(page_id: int):
88
+ record = _get_or_404(page_id)
89
+ path = STORAGE / record["html_path"] if record["html_path"] else None
90
+ if not path or not path.exists():
91
+ raise HTTPException(status_code=404, detail="HTML não encontrado.")
92
+ return {"html": path.read_text(encoding="utf-8")}
93
+
94
+
95
+ @router.put("/{page_id}/html")
96
+ def update_html(page_id: int, payload: dict):
97
+ _get_or_404(page_id)
98
+ html = payload.get("html")
99
+ if not html or not html.strip():
100
+ raise HTTPException(status_code=400, detail="HTML vazio.")
101
+ salvar_html(page_id, html)
102
+ with get_db() as conn:
103
+ conn.execute("UPDATE paginas SET updated_at = ? WHERE id = ?", (now_str(), page_id))
104
+ return {"ok": True}
105
+
106
+
107
+ @router.get("/{page_id}/download")
108
+ def download_pagina(page_id: int):
109
+ record = _get_or_404(page_id)
110
+ path = STORAGE / record["html_path"] if record["html_path"] else None
111
+ if not path or not path.exists():
112
+ raise HTTPException(status_code=404, detail="HTML não encontrado.")
113
+ return FileResponse(path, filename=f"{record['titulo']}.html", media_type="text/html")
114
+
115
+
116
+ @router.post("/{page_id}/publicar")
117
+ def publicar(page_id: int):
118
+ record = _get_or_404(page_id)
119
+ path = STORAGE / record["html_path"] if record["html_path"] else None
120
+ if not path or not path.exists():
121
+ raise HTTPException(status_code=404, detail="HTML não encontrado.")
122
+ try:
123
+ result = publicar_pagina(page_id, record["titulo"], path.read_text(encoding="utf-8"))
124
+ except LLMError as exc:
125
+ raise HTTPException(status_code=400, detail=str(exc))
126
+ with get_db() as conn:
127
+ conn.execute(
128
+ "UPDATE paginas SET status = 'publicada', published_url = ?, updated_at = ? WHERE id = ?",
129
+ (result["pages_url"], now_str(), page_id),
130
+ )
131
+ updated = _get_or_404(page_id)
132
+ updated["publish_result"] = result
133
+ return updated
134
+
135
+
136
+ @router.delete("/{page_id}")
137
+ def delete_pagina(page_id: int):
138
+ record = _get_or_404(page_id)
139
+ if record["html_path"]:
140
+ folder = (STORAGE / record["html_path"]).parent
141
+ if folder.exists():
142
+ for item in folder.iterdir():
143
+ item.unlink()
144
+ folder.rmdir()
145
+ with get_db() as conn:
146
+ conn.execute("DELETE FROM paginas WHERE id = ?", (page_id,))
147
+ return {"ok": True}
148
+
149
+
150
+ def _get_or_404(page_id: int):
151
+ with get_db() as conn:
152
+ row = conn.execute("SELECT * FROM paginas WHERE id = ?", (page_id,)).fetchone()
153
+ if not row:
154
+ raise HTTPException(status_code=404, detail="Página não encontrada.")
155
+ return row_to_dict(row)
@@ -0,0 +1,17 @@
1
+ from fastapi import APIRouter, HTTPException, Query
2
+
3
+ from ..services import tracking
4
+
5
+ router = APIRouter(prefix="/api/tracking", tags=["tracking"])
6
+
7
+
8
+ @router.get("/dashboard")
9
+ def dashboard(days: int = Query(90, ge=1, le=365)):
10
+ """Agregações do AdsPixel: métricas, drenagem, visitantes, donuts, funil e fluxo."""
11
+ return tracking.dashboard(days)
12
+
13
+
14
+ @router.get("/visitante/{ip}")
15
+ def visitante(ip: str):
16
+ """Detalhes de um visitante (histórico de eventos + payload)."""
17
+ return tracking.visitante(ip)
package/app/main.py CHANGED
@@ -15,7 +15,7 @@ from .services.database import ensure_db, get_db, now_str, row_to_dict
15
15
  from .services.llm import LLMError, analyze_product, list_providers
16
16
  from .services.store import PRODMAKER_HOME, STORAGE, STATE, ensure_state_dir, get_settings, save_settings
17
17
 
18
- APP_VERSION = "0.1.04"
18
+ APP_VERSION = "0.1.06"
19
19
  COOKIE_NAME = "prodmaker_session"
20
20
  SESSION_DAYS = 30
21
21
 
@@ -26,9 +26,15 @@ app = FastAPI(title="ProdMaker", version=APP_VERSION)
26
26
 
27
27
  from .api import infoprodutos as infoprodutos_router # noqa: E402
28
28
  from .api import llm as llm_router # noqa: E402
29
+ from .api import paginas as paginas_router # noqa: E402
30
+ from .api import dominios as dominios_router # noqa: E402
31
+ from .api import tracking as tracking_router # noqa: E402
29
32
 
30
33
  app.include_router(llm_router.router)
31
34
  app.include_router(infoprodutos_router.router)
35
+ app.include_router(paginas_router.router)
36
+ app.include_router(dominios_router.router)
37
+ app.include_router(tracking_router.router)
32
38
 
33
39
  ensure_db()
34
40
 
@@ -225,7 +231,7 @@ def health():
225
231
 
226
232
  @app.get("/api/version")
227
233
  def version():
228
- return {"version": APP_VERSION, "canonical": "0.1.4"}
234
+ return {"version": APP_VERSION, "canonical": "0.1.6"}
229
235
 
230
236
 
231
237
  @app.get("/api/settings")
@@ -238,6 +244,52 @@ def update_settings(data: dict):
238
244
  return save_settings(data)
239
245
 
240
246
 
247
+ # ---------------------------------------------------------------------------
248
+ # Pixel próprio (AdsPixel) — recebe eventos das páginas geradas/publicadas
249
+ # ---------------------------------------------------------------------------
250
+
251
+
252
+ @app.options("/track")
253
+ def track_options():
254
+ return Response(
255
+ status_code=204,
256
+ headers={
257
+ "Access-Control-Allow-Origin": "*",
258
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
259
+ "Access-Control-Allow-Headers": "*",
260
+ },
261
+ )
262
+
263
+
264
+ @app.api_route("/track", methods=["GET", "POST"])
265
+ def track(request: Request):
266
+ params = dict(request.query_params)
267
+ forwarded = request.headers.get("x-forwarded-for", "")
268
+ ip = forwarded.split(",")[0].strip() or (request.client.host if request.client else "")
269
+ known = {"event", "page", "gclid", "utm_source", "utm_medium", "utm_campaign", "ref"}
270
+ try:
271
+ with get_db() as conn:
272
+ conn.execute(
273
+ "INSERT INTO visitas (event, page, gclid, utm_source, utm_medium, utm_campaign, ref, ip, user_agent, extra, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
274
+ (
275
+ params.get("event", "pageview"),
276
+ params.get("page", ""),
277
+ params.get("gclid", ""),
278
+ params.get("utm_source", ""),
279
+ params.get("utm_medium", ""),
280
+ params.get("utm_campaign", ""),
281
+ params.get("ref", ""),
282
+ ip,
283
+ request.headers.get("user-agent", ""),
284
+ json.dumps({k: v for k, v in params.items() if k not in known}, ensure_ascii=False),
285
+ now_str(),
286
+ ),
287
+ )
288
+ except Exception:
289
+ pass # o pixel nunca deve quebrar a página do visitante
290
+ return Response(status_code=204, headers={"Access-Control-Allow-Origin": "*"})
291
+
292
+
241
293
  # ---------------------------------------------------------------------------
242
294
  # Estáticos: /legal, frontend/dist (SPA fallback) e páginas geradas
243
295
  # ---------------------------------------------------------------------------
@@ -0,0 +1,43 @@
1
+ """Clonagem de páginas com higienização — remove scripts, pixels e iframes do original."""
2
+ import httpx
3
+ from bs4 import BeautifulSoup
4
+
5
+ from .llm import LLMError
6
+
7
+
8
+ def clonar_url(url: str) -> tuple[str, str]:
9
+ """Baixa a URL e devolve (HTML higienizado, título) — scripts/pixels/iframes removidos."""
10
+ if not url.strip().startswith(("http://", "https://")):
11
+ raise LLMError("Informe uma URL válida (http/https) para clonar.")
12
+ try:
13
+ response = httpx.get(
14
+ url,
15
+ timeout=45.0,
16
+ follow_redirects=True,
17
+ headers={"User-Agent": "Mozilla/5.0 (compatible; ProdMaker/0.1; +local)"},
18
+ )
19
+ except httpx.HTTPError as exc:
20
+ raise LLMError(f"Falha ao baixar a página: {exc}") from exc
21
+ if response.status_code >= 400:
22
+ raise LLMError(f"A página respondeu {response.status_code} — confira a URL.")
23
+
24
+ soup = BeautifulSoup(response.text, "html.parser")
25
+
26
+ # Higienização: remove rastreadores e código de terceiros
27
+ for tag in soup.find_all(["script", "noscript", "iframe"]):
28
+ tag.decompose()
29
+ for pattern in ("facebook", "pixel", "analytics", "googletagmanager", "doubleclick", "hotjar", "clarity"):
30
+ for img in soup.find_all("img", src=lambda src: src and pattern in src.lower()):
31
+ img.decompose()
32
+ for link in soup.find_all("link", href=lambda href: href and pattern in href.lower()):
33
+ link.decompose()
34
+ for comment in soup.find_all(string=lambda text: isinstance(text, str) and text.strip().startswith(("<!--", "<!["))):
35
+ comment.extract()
36
+
37
+ title = soup.title.get_text(strip=True) if soup.title else url
38
+ meta = soup.new_tag("meta")
39
+ meta.attrs["name"] = "generator"
40
+ meta.attrs["content"] = "ProdMaker (clone higienizado)"
41
+ if soup.head:
42
+ soup.head.append(meta)
43
+ return str(soup), title
@@ -48,6 +48,55 @@ def ensure_db() -> None:
48
48
  )
49
49
  """
50
50
  )
51
+ conn.execute(
52
+ """
53
+ CREATE TABLE IF NOT EXISTS paginas (
54
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55
+ titulo TEXT NOT NULL,
56
+ tipo TEXT NOT NULL,
57
+ produto_id INTEGER,
58
+ idioma TEXT NOT NULL DEFAULT 'Portugues',
59
+ html_path TEXT NOT NULL DEFAULT '',
60
+ status TEXT NOT NULL DEFAULT 'rascunho',
61
+ published_url TEXT NOT NULL DEFAULT '',
62
+ created_at TEXT NOT NULL,
63
+ updated_at TEXT NOT NULL
64
+ )
65
+ """
66
+ )
67
+ conn.execute(
68
+ """
69
+ CREATE TABLE IF NOT EXISTS dominios (
70
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
71
+ nome TEXT NOT NULL,
72
+ status TEXT NOT NULL DEFAULT 'pendente',
73
+ cf_zone_id TEXT NOT NULL DEFAULT '',
74
+ ns_data TEXT NOT NULL DEFAULT '',
75
+ a_data TEXT NOT NULL DEFAULT '',
76
+ tags TEXT NOT NULL DEFAULT '',
77
+ created_at TEXT NOT NULL,
78
+ updated_at TEXT NOT NULL
79
+ )
80
+ """
81
+ )
82
+ conn.execute(
83
+ """
84
+ CREATE TABLE IF NOT EXISTS visitas (
85
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
86
+ event TEXT NOT NULL DEFAULT '',
87
+ page TEXT NOT NULL DEFAULT '',
88
+ gclid TEXT NOT NULL DEFAULT '',
89
+ utm_source TEXT NOT NULL DEFAULT '',
90
+ utm_medium TEXT NOT NULL DEFAULT '',
91
+ utm_campaign TEXT NOT NULL DEFAULT '',
92
+ ref TEXT NOT NULL DEFAULT '',
93
+ ip TEXT NOT NULL DEFAULT '',
94
+ user_agent TEXT NOT NULL DEFAULT '',
95
+ extra TEXT NOT NULL DEFAULT '',
96
+ created_at TEXT NOT NULL
97
+ )
98
+ """
99
+ )
51
100
  columns = [row["name"] for row in conn.execute("PRAGMA table_info(produtos)")]
52
101
  if "analysis" not in columns:
53
102
  conn.execute("ALTER TABLE produtos ADD COLUMN analysis TEXT NOT NULL DEFAULT ''")
@@ -0,0 +1,78 @@
1
+ """AdsHosting — verificação de domínios via Cloudflare API + DNS público."""
2
+ import json
3
+
4
+ import httpx
5
+
6
+ from .llm import LLMError
7
+
8
+ CF_BASE = "https://api.cloudflare.com/client/v4"
9
+
10
+
11
+ def _cloudflare_token() -> str:
12
+ from .store import get_settings
13
+
14
+ keys = (get_settings().get("api_keys") or {}).get("cloudflare") or {}
15
+ return (keys.get("api_token") or "").strip()
16
+
17
+
18
+ def _dns_query_ns(nome: str) -> list[str]:
19
+ try:
20
+ import dns.resolver
21
+
22
+ answers = dns.resolver.Resolver().resolve(nome, "NS")
23
+ return sorted(str(rdata.target).rstrip(".").lower() for rdata in answers)
24
+ except Exception:
25
+ return []
26
+
27
+
28
+ def _dns_query_a(nome: str) -> list[str]:
29
+ try:
30
+ import dns.resolver
31
+
32
+ answers = dns.resolver.Resolver().resolve(nome, "A")
33
+ return sorted(str(rdata) for rdata in answers)
34
+ except Exception:
35
+ return []
36
+
37
+
38
+ def verificar_dominio(nome: str) -> dict:
39
+ """Verifica NS/A via DNS público e status na Cloudflare (se token configurado).
40
+
41
+ Nunca inventa dados: sem token, usa apenas DNS; sem DNS, marca como pendente.
42
+ """
43
+ nome = (nome or "").strip().lower().rstrip(".")
44
+ if not nome or "." not in nome:
45
+ raise LLMError("Informe um domínio válido (ex.: meusite.com).")
46
+
47
+ resultado = {
48
+ "ns": _dns_query_ns(nome),
49
+ "a": _dns_query_a(nome),
50
+ "ns_cloudflare": False,
51
+ "cf_status": "",
52
+ "cf_zone_id": "",
53
+ "cf_name_servers": [],
54
+ }
55
+ resultado["ns_cloudflare"] = any("ns.cloudflare.com" in ns for ns in resultado["ns"])
56
+
57
+ token = _cloudflare_token()
58
+ if token:
59
+ try:
60
+ with httpx.Client(timeout=30.0) as client:
61
+ response = client.get(f"{CF_BASE}/zones", headers={"Authorization": f"Bearer {token}"}, params={"name": nome})
62
+ if response.status_code == 200:
63
+ payload = response.json()
64
+ zones = (payload.get("result") or [])
65
+ if zones:
66
+ zone = zones[0]
67
+ resultado["cf_status"] = zone.get("status") or ""
68
+ resultado["cf_zone_id"] = zone.get("id") or ""
69
+ resultado["cf_name_servers"] = zone.get("name_servers") or []
70
+ resultado["ns_cloudflare"] = resultado["ns_cloudflare"] or bool(resultado["cf_name_servers"]) and zone.get("status") == "active"
71
+ elif response.status_code in (401, 403):
72
+ raise LLMError("Cloudflare rejeitou o token (401/403) — confira o API Token em Chaves de API.")
73
+ except httpx.HTTPError as exc:
74
+ raise LLMError(f"Falha de conexão com a Cloudflare: {exc}") from exc
75
+
76
+ online = resultado["ns_cloudflare"] or resultado["cf_status"] == "active"
77
+ resultado["status"] = "online" if online else "pendente"
78
+ return resultado