@danhachuel/prodmaker 0.1.4 → 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 +23 -0
- package/THIRD-PARTY-NOTICES.md +6 -1
- package/app/api/dominios.py +92 -0
- package/app/api/paginas.py +155 -0
- package/app/main.py +52 -2
- package/app/services/clone.py +43 -0
- package/app/services/database.py +49 -0
- package/app/services/dominios.py +78 -0
- package/app/services/pages.py +102 -0
- package/app/services/publish.py +74 -0
- package/frontend/dist/assets/{index-DZBtvZhA.css → index-DQDUzYIV.css} +1 -1
- package/frontend/dist/assets/index-LbTb9NYj.js +67 -0
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/requirements.txt +3 -0
- package/scripts/cli.mjs +1 -1
- package/frontend/dist/assets/index-BMQemvVJ.js +0 -67
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,29 @@
|
|
|
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
|
+
|
|
6
29
|
## 0.1.04 — 2026-10-25
|
|
7
30
|
|
|
8
31
|
- Favicon 🚀 (SVG) nas guias do navegador: app (SPA), login e páginas `/legal`.
|
package/THIRD-PARTY-NOTICES.md
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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
|
package/app/services/database.py
CHANGED
|
@@ -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
|