@danhachuel/prodmaker 0.1.3 → 0.1.5

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,33 @@
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.05 — 2026-10-26
7
+
8
+ - **Parte 4 — AdsPages & AdsHosting (geração de páginas, clonagem, pixel, domínios e publicação).**
9
+ - **AdsPages funcional:** pre-sell de afiliado e página de vendas (12 seções) geradas por LLM
10
+ (templates do seed, idioma selecionável, dados preenchidos a partir do produto vinculado);
11
+ **clonagem de páginas com higienização** (BeautifulSoup remove scripts, iframes, noscript e
12
+ pixels/analytics do original); pré-visualização local em `/pages/{id}`, editor de HTML
13
+ integrado e download.
14
+ - **Pixel próprio (AdsPixel) embutido** nas páginas geradas quando a URL pública do `/track`
15
+ está configurada (card "Pixel de Rastreamento" em Chaves de API): pageview, clique em CTA e
16
+ tempo na página, capturando gclid/utm/ref — endpoint `/track` (GET/POST + CORS) grava na
17
+ tabela `visitas` (a UI de Minhas Visitas vem na sequência). Sem URL configurada, nenhuma
18
+ tag é embutida (a UI não inventa dados).
19
+ - **AdsHosting — Domínios:** cards no layout AdsX com status pendente/online; verificação por
20
+ DNS público (NS/A via dnspython) e, com API Token Cloudflare configurado, status da zona
21
+ via API; name servers exibidos com botão copiar; "+ Tags, Pixels and Scripts (Opcional)"
22
+ por domínio; adicionar/remover domínios.
23
+ - **Páginas Criadas:** tabela com preview, download, edição de HTML, status rascunho/publicada
24
+ e **Publicar 🚀 via GitHub Contents API** (commit do HTML no repositório configurado em
25
+ Chaves de API; Cloudflare Pages deploy automaticamente quando o repositório está conectado) —
26
+ com link do commit e URL pública.
27
+ - Novas tabelas: `paginas`, `dominios`, `visitas`. requirements.txt: + beautifulsoup4, dnspython.
28
+
29
+ ## 0.1.04 — 2026-10-25
30
+
31
+ - Favicon 🚀 (SVG) nas guias do navegador: app (SPA), login e páginas `/legal`.
32
+
6
33
  ## 0.1.03 — 2026-10-25
7
34
 
8
35
  - **Parte 3 — Motor LLM + Chaves de API + Análise de Produto por IA + Studio de PDF.**
@@ -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)
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.03"
18
+ APP_VERSION = "0.1.05"
19
19
  COOKIE_NAME = "prodmaker_session"
20
20
  SESSION_DAYS = 30
21
21
 
@@ -26,9 +26,13 @@ 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
29
31
 
30
32
  app.include_router(llm_router.router)
31
33
  app.include_router(infoprodutos_router.router)
34
+ app.include_router(paginas_router.router)
35
+ app.include_router(dominios_router.router)
32
36
 
33
37
  ensure_db()
34
38
 
@@ -225,7 +229,7 @@ def health():
225
229
 
226
230
  @app.get("/api/version")
227
231
  def version():
228
- return {"version": APP_VERSION, "canonical": "0.1.3"}
232
+ return {"version": APP_VERSION, "canonical": "0.1.5"}
229
233
 
230
234
 
231
235
  @app.get("/api/settings")
@@ -238,6 +242,52 @@ def update_settings(data: dict):
238
242
  return save_settings(data)
239
243
 
240
244
 
245
+ # ---------------------------------------------------------------------------
246
+ # Pixel próprio (AdsPixel) — recebe eventos das páginas geradas/publicadas
247
+ # ---------------------------------------------------------------------------
248
+
249
+
250
+ @app.options("/track")
251
+ def track_options():
252
+ return Response(
253
+ status_code=204,
254
+ headers={
255
+ "Access-Control-Allow-Origin": "*",
256
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
257
+ "Access-Control-Allow-Headers": "*",
258
+ },
259
+ )
260
+
261
+
262
+ @app.api_route("/track", methods=["GET", "POST"])
263
+ def track(request: Request):
264
+ params = dict(request.query_params)
265
+ forwarded = request.headers.get("x-forwarded-for", "")
266
+ ip = forwarded.split(",")[0].strip() or (request.client.host if request.client else "")
267
+ known = {"event", "page", "gclid", "utm_source", "utm_medium", "utm_campaign", "ref"}
268
+ try:
269
+ with get_db() as conn:
270
+ conn.execute(
271
+ "INSERT INTO visitas (event, page, gclid, utm_source, utm_medium, utm_campaign, ref, ip, user_agent, extra, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
272
+ (
273
+ params.get("event", "pageview"),
274
+ params.get("page", ""),
275
+ params.get("gclid", ""),
276
+ params.get("utm_source", ""),
277
+ params.get("utm_medium", ""),
278
+ params.get("utm_campaign", ""),
279
+ params.get("ref", ""),
280
+ ip,
281
+ request.headers.get("user-agent", ""),
282
+ json.dumps({k: v for k, v in params.items() if k not in known}, ensure_ascii=False),
283
+ now_str(),
284
+ ),
285
+ )
286
+ except Exception:
287
+ pass # o pixel nunca deve quebrar a página do visitante
288
+ return Response(status_code=204, headers={"Access-Control-Allow-Origin": "*"})
289
+
290
+
241
291
  # ---------------------------------------------------------------------------
242
292
  # Estáticos: /legal, frontend/dist (SPA fallback) e páginas geradas
243
293
  # ---------------------------------------------------------------------------
@@ -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
@@ -0,0 +1,102 @@
1
+ """Geração de landing pages via LLM — pre-sell (afiliado) e página de vendas (12 seções)."""
2
+ import re
3
+ from pathlib import Path
4
+
5
+ from .llm import LLMError, generate
6
+ from .store import STORAGE
7
+
8
+ SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed"
9
+
10
+
11
+ def _pixel_snippet() -> str:
12
+ """Pixel do ProdMaker embutido nas páginas geradas (se configurado)."""
13
+ from .store import get_settings
14
+
15
+ settings = get_settings()
16
+ pixel_url = (((settings.get("api_keys") or {}).get("tracking") or {}).get("pixel_url") or "").strip()
17
+ if not pixel_url:
18
+ return ""
19
+ base = pixel_url.rstrip("/")
20
+ return (
21
+ "\n<!-- ProdMaker Pixel -->\n<script>\n"
22
+ "(function(){\n"
23
+ f" var base = \"{base}\";\n"
24
+ " var q = new URLSearchParams(window.location.search);\n"
25
+ " var params = \"event=pageview&page=\" + encodeURIComponent(location.pathname) +\n"
26
+ " \"&ref=\" + encodeURIComponent(document.referrer || \"\") +\n"
27
+ " \"&gclid=\" + encodeURIComponent(q.get(\"gclid\") || \"\") +\n"
28
+ " \"&utm_source=\" + encodeURIComponent(q.get(\"utm_source\") || \"\") +\n"
29
+ " \"&utm_medium=\" + encodeURIComponent(q.get(\"utm_medium\") || \"\") +\n"
30
+ " \"&utm_campaign=\" + encodeURIComponent(q.get(\"utm_campaign\") || \"\");\n"
31
+ " try { navigator.sendBeacon(base + \"?\" + params); } catch (e) {}\n"
32
+ " document.addEventListener(\"click\", function (e) {\n"
33
+ " var a = e.target.closest ? e.target.closest(\"a[href]\") : null;\n"
34
+ " if (a) { try { navigator.sendBeacon(base + \"?event=cta_click&href=\" + encodeURIComponent(a.href)); } catch (err) {} }\n"
35
+ " }, true);\n"
36
+ " window.addEventListener(\"beforeunload\", function () {\n"
37
+ " try { navigator.sendBeacon(base + \"?event=unload&seconds=\" + Math.round(performance.now() / 1000)); } catch (e) {}\n"
38
+ " });\n"
39
+ "})();\n</script>\n"
40
+ )
41
+
42
+
43
+ def _load_template(nome: str) -> str:
44
+ path = SEED_DIR / "prompts" / nome
45
+ return path.read_text(encoding="utf-8") if path.exists() else ""
46
+
47
+
48
+ def _fill(template: str, values: dict) -> str:
49
+ for key, value in values.items():
50
+ template = template.replace("{" + key + "}", str(value or ""))
51
+ return template
52
+
53
+
54
+ def _ensure_single_html(html: str) -> str:
55
+ """Extrai o primeiro documento HTML completo da resposta do LLM."""
56
+ match = re.search(r"<!DOCTYPE html>.*</html>", html, re.DOTALL | re.IGNORECASE)
57
+ return (match.group(0) if match else html).strip()
58
+
59
+
60
+ def gerar_pagina(tipo: str, produto: dict | None, form: dict) -> dict:
61
+ """Gera o HTML da página. tipo: 'presell' | 'vendas'."""
62
+ if tipo not in ("presell", "vendas"):
63
+ raise LLMError("Tipo de página inválido.")
64
+ produto = produto or {}
65
+ afiliado = (form.get("afiliado_link") or produto.get("link_afiliado") or "").strip()
66
+ checkout = (form.get("checkout_link") or afiliado or "#").strip()
67
+ template = _load_template("pre-sell.md" if tipo == "presell" else "pagina-vendas.md")
68
+ prompt = _fill(
69
+ template,
70
+ {
71
+ "NOME_DO_PRODUTO": form.get("titulo") or produto.get("nome") or "Produto",
72
+ "NOME": produto.get("nome") or form.get("titulo") or "Produto",
73
+ "RESUMO": produto.get("analysis") and "ver análise" or form.get("prompt") or "",
74
+ "PRECO": produto.get("preco") or "",
75
+ "AVATAR": form.get("avatar") or "",
76
+ "RESULTADO": form.get("resultado") or "",
77
+ "TEMPO": form.get("tempo") or "poucos dias",
78
+ "DOR": form.get("dor") or produto.get("nicho") or "",
79
+ "LINK_AFILIADO": afiliado or checkout,
80
+ "LINK_CHECKOUT": checkout,
81
+ "PIXEL_SNIPPET": _pixel_snippet(),
82
+ },
83
+ )
84
+ idioma = form.get("idioma") or "Português"
85
+ system = (
86
+ f"Escreva TODO o conteúdo da página em {idioma}. "
87
+ "Você é um copywriter de direct response especialista em páginas de alta conversão. "
88
+ "Retorne APENAS o HTML completo em um único arquivo (CSS e JS embutidos), sem comentários fora do código."
89
+ )
90
+ result = generate(prompt, system=system, max_tokens=8000)
91
+ html = _ensure_single_html(result["text"])
92
+ if "</html>" not in html.lower():
93
+ html = f"<!DOCTYPE html><html lang=\"pt-BR\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>{form.get('titulo') or 'Página'}</title></head><body>{html}</body></html>"
94
+ return {"html": html, "provider": result["provider"]}
95
+
96
+
97
+ def salvar_html(page_id: int, html: str) -> Path:
98
+ folder = STORAGE / "pages" / str(page_id)
99
+ folder.mkdir(parents=True, exist_ok=True)
100
+ path = folder / "index.html"
101
+ path.write_text(html, encoding="utf-8")
102
+ return path