josephrw commited on
Commit
1e9bf32
·
verified ·
1 Parent(s): f7adc47

Full SaaS deploy: control panel, market intel, profiles, real DBs

Browse files
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ data/content_pool.db filter=lfs diff=lfs merge=lfs -text
37
+ data/masseurs.db filter=lfs diff=lfs merge=lfs -text
38
+ data/unified.db filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -1,5 +1,4 @@
1
  __pycache__/
2
  *.pyc
3
  *.pyo
4
- *.db
5
- data/
 
1
  __pycache__/
2
  *.pyc
3
  *.pyo
4
+ .DS_Store
 
Dockerfile CHANGED
@@ -2,13 +2,10 @@ FROM python:3.11-slim
2
 
3
  WORKDIR /app
4
 
5
- RUN pip install --no-cache-dir fastapi uvicorn[standard] pydantic
6
 
7
  COPY . /app
8
 
9
- # Create data directory for bundled DB
10
- RUN mkdir -p /data
11
-
12
  EXPOSE 7860
13
 
14
  CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
 
2
 
3
  WORKDIR /app
4
 
5
+ RUN pip install --no-cache-dir fastapi uvicorn[standard] jinja2
6
 
7
  COPY . /app
8
 
 
 
 
9
  EXPOSE 7860
10
 
11
  CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,8 +1,8 @@
1
  ---
2
- title: GPT Relay Endpoint
3
- emoji: 🚀
4
- colorFrom: gray
5
- colorTo: blue
6
  sdk: docker
7
  app_port: 7860
8
  pinned: false
 
1
  ---
2
+ title: MasseurBoost
3
+ emoji: 💆
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
  app_port: 7860
8
  pinned: false
app.py CHANGED
@@ -1,485 +1,483 @@
1
- """Giant GPT Terminal Kernel josephrw-endpoint HF Space.
2
 
3
- GPT Actions compatible external terminal, Python, file, artifact URL,
4
- dynamic tool, memory, and receipt kernel. Hardened to avoid post-execution 500s.
5
  """
6
- import os
7
  import json
8
- import time
9
- import uuid
10
- import hashlib
11
  import sqlite3
12
- import subprocess
13
- import threading
14
  from pathlib import Path
15
  from datetime import datetime, timezone
16
- from typing import Optional
17
 
18
- from fastapi import FastAPI, Request, Query, HTTPException
19
- from fastapi.responses import JSONResponse, PlainTextResponse
20
  from fastapi.staticfiles import StaticFiles
21
- from pydantic import BaseModel, Field
22
-
23
- app = FastAPI(
24
- title="Giant GPT Terminal Kernel",
25
- version="3.1.1",
26
- docs_url="/docs",
27
- openapi_url="/openapi.json",
28
- )
29
-
30
- # ─── Paths ────────────────────────────────────────────────────
31
- WORKSPACE_ROOT = Path(os.environ.get("WORKSPACE_ROOT", str(Path(__file__).parent / "data" / "workspaces")))
32
- ARTIFACTS_DIR = Path(os.environ.get("ARTIFACTS_DIR", str(Path(__file__).parent / "data" / "artifacts")))
33
- DB_PATH = Path(os.environ.get("KERNEL_DB", str(Path(__file__).parent / "data" / "kernel.db")))
34
-
35
- WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
36
- ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
37
-
38
- # ─── SQLite for receipts, memory, tools, sessions ─────────────
39
- def _init_db():
40
- conn = sqlite3.connect(str(DB_PATH))
41
- c = conn.cursor()
42
- c.execute("""CREATE TABLE IF NOT EXISTS receipts (
43
- id TEXT PRIMARY KEY, kind TEXT, workspace TEXT, summary TEXT,
44
- sha256 TEXT, created_at TEXT, data_json TEXT
45
- )""")
46
- c.execute("""CREATE TABLE IF NOT EXISTS memory (
47
- id TEXT PRIMARY KEY, topic TEXT, content TEXT, utility REAL,
48
- tags TEXT, created_at TEXT
49
- )""")
50
- c.execute("""CREATE TABLE IF NOT EXISTS tools (
51
- name TEXT PRIMARY KEY, description TEXT, mode TEXT,
52
- command_template TEXT, schema_data TEXT, enabled INTEGER,
53
- created_at TEXT
54
- )""")
55
- c.execute("""CREATE TABLE IF NOT EXISTS sessions (
56
- session_id TEXT PRIMARY KEY, workspace TEXT, cwd TEXT,
57
- created_at TEXT, active INTEGER
58
- )""")
59
- conn.commit()
60
- conn.close()
61
 
62
- _init_db()
 
 
63
 
64
- DB_LOCK = threading.Lock()
 
 
65
 
 
66
 
67
- def _db():
68
- conn = sqlite3.connect(str(DB_PATH), timeout=10)
 
69
  conn.row_factory = sqlite3.Row
70
  return conn
71
 
72
 
73
- # ─── Helpers ──────────────────────────────────────────────────
74
- def _ws_path(workspace: str, path: str = ".") -> Path:
75
- base = WORKSPACE_ROOT / workspace
76
- base.mkdir(parents=True, exist_ok=True)
77
- resolved = (base / path).resolve()
78
- if not str(resolved).startswith(str(base.resolve())):
79
- raise HTTPException(status_code=400, detail="Path traversal denied")
80
- return resolved
81
-
82
-
83
- def _truncate(text: str, limit: int = 50000) -> tuple:
84
- if len(text) > limit:
85
- return text[:limit], True
86
- return text, False
87
-
88
-
89
- def _receipt(kind: str, summary: str, data: dict, workspace: Optional[str] = None) -> dict:
90
- rid = uuid.uuid4().hex[:16]
91
- sha = hashlib.sha256(json.dumps(data, sort_keys=True, default=str).encode()).hexdigest()
92
- now = datetime.now(timezone.utc).isoformat()
93
- with DB_LOCK:
94
- conn = _db()
95
- conn.execute(
96
- "INSERT INTO receipts VALUES (?,?,?,?,?,?,?)",
97
- (rid, kind, workspace, summary, sha, now, json.dumps(data, default=str)),
98
- )
99
- conn.commit()
100
- conn.close()
101
- return {"id": rid, "kind": kind, "workspace": workspace, "summary": summary, "sha256": sha, "created_at": now}
102
 
103
 
104
- def _safe_run(cmd: list, cwd: Path, timeout: int) -> dict:
105
- try:
106
- result = subprocess.run(
107
- cmd, cwd=str(cwd), capture_output=True, text=True, timeout=timeout,
108
- )
109
- stdout, truncated = _truncate(result.stdout)
110
- stderr, _ = _truncate(result.stderr)
111
- return {"returncode": result.returncode, "stdout": stdout, "stderr": stderr, "truncated": truncated}
112
- except subprocess.TimeoutExpired:
113
- return {"returncode": -1, "stdout": "", "stderr": f"Timed out after {timeout}s", "truncated": False}
114
- except Exception as e:
115
- return {"returncode": -1, "stdout": "", "stderr": str(e), "truncated": False}
116
 
117
 
118
- # ═══════════════════════════════════════════════════════════════
119
- # MODELS
120
- # ═══════════════════════════════════════════════════════════════
121
- class TerminalRunRequest(BaseModel):
122
- workspace: str = "default"
123
- command: str
124
- timeout_seconds: int = Field(default=10, ge=1, le=30)
125
- cwd: str = "."
126
- create_receipt: bool = True
127
 
128
 
129
- class PythonRunRequest(BaseModel):
130
- workspace: str = "default"
131
- code: str
132
- timeout_seconds: int = Field(default=10, ge=1, le=30)
133
- create_receipt: bool = True
134
 
135
 
136
- class SessionCreateRequest(BaseModel):
137
- workspace: str = "default"
138
- cwd: str = "."
 
139
 
140
 
141
- class SessionRunRequest(BaseModel):
142
- session_id: str
143
- command: str
144
- timeout_seconds: int = Field(default=10, ge=1, le=30)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
 
 
 
 
146
 
147
- class FileWriteRequest(BaseModel):
148
- workspace: str = "default"
149
- path: str
150
- content: str
151
- encoding: str = "utf-8"
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
- class FileReadRequest(BaseModel):
155
- workspace: str = "default"
156
- path: str
157
- max_bytes: int = Field(default=120000, ge=1, le=20000000)
158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
 
160
- class ListRequest(BaseModel):
161
- workspace: str = "default"
162
- path: str = "."
163
- max_items: int = Field(default=250, ge=1, le=2000)
 
 
 
 
 
 
 
164
 
165
 
166
- class ArtifactRequest(BaseModel):
167
- workspace: str = "default"
168
- filename: str
169
- content: str
170
- content_type: str = "text/plain"
 
 
 
 
 
171
 
172
 
173
- class LearnRequest(BaseModel):
174
- topic: str = "general"
175
- content: str
176
- utility: float = Field(default=0.5, ge=0, le=1)
177
- tags: list = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
 
179
 
180
- class RecallRequest(BaseModel):
181
- query: str
182
- limit: int = Field(default=8, ge=1, le=50)
 
 
 
 
 
 
 
 
 
 
 
 
183
 
184
 
185
- class ToolRegisterRequest(BaseModel):
186
- name: str
187
- description: str = ""
188
- mode: str = "command"
189
- command_template: str = ""
190
- schema_data: dict = {}
191
- enabled: bool = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
 
193
 
194
- class ToolInvokeRequest(BaseModel):
195
- workspace: str = "default"
196
- args: dict = {}
197
- timeout_seconds: int = Field(default=10, ge=1, le=30)
 
 
 
 
 
 
 
 
 
 
198
 
199
 
200
- # ═══════════════════════════════════════════════════════════════
201
- # HEALTH
202
- # ═══════════════════════════════════════════════════════════════
203
- @app.get("/health")
204
- async def health():
205
- return {"status": "ok", "version": "3.1.1", "workspaces": len(list(WORKSPACE_ROOT.iterdir()))}
206
-
207
-
208
- # ═══════════════════════════════════════════════════════════════
209
- # TERMINAL RUN
210
- # ═══════════════════════════════════════════════════════════════
211
- @app.post("/terminal/run")
212
- async def run_terminal(req: TerminalRunRequest):
213
- cwd = _ws_path(req.workspace, req.cwd)
214
- result = _safe_run(["sh", "-c", req.command], cwd, req.timeout_seconds)
215
- receipt = None
216
- receipt_error = None
217
- if req.create_receipt:
218
- try:
219
- receipt = _receipt("terminal_run", req.command[:80], result, req.workspace)
220
- except Exception as e:
221
- receipt_error = str(e)
222
- return {"workspace": req.workspace, "cwd": req.cwd, "command": req.command, **result, "receipt": receipt, "receipt_error": receipt_error}
223
-
224
-
225
- # ═══════════════��═══════════════════════════════════════════════
226
- # PYTHON RUN
227
- # ═══════════════════════════════════════════════════════════════
228
- @app.post("/python/run")
229
- async def run_python(req: PythonRunRequest):
230
- ws_base = _ws_path(req.workspace)
231
- script_path = ws_base / f"_run_{uuid.uuid4().hex[:8]}.py"
232
- script_path.write_text(req.code)
233
- result = _safe_run(["python3", str(script_path)], ws_base, req.timeout_seconds)
234
- try:
235
- script_path.unlink(missing_ok=True)
236
- except Exception:
237
- pass
238
- receipt = None
239
- receipt_error = None
240
- if req.create_receipt:
241
- try:
242
- receipt = _receipt("python_run", req.code[:80], result, req.workspace)
243
- except Exception as e:
244
- receipt_error = str(e)
245
- return {"workspace": req.workspace, "script": script_path.name, **result, "receipt": receipt, "receipt_error": receipt_error}
246
-
247
-
248
- # ═══════════════════════════════════════════════════════════════
249
- # SESSIONS
250
- # ═══════════════════════════════════════════════════════════════
251
- @app.post("/session/create")
252
- async def create_session(req: SessionCreateRequest):
253
- sid = uuid.uuid4().hex[:12]
254
- _ws_path(req.workspace, req.cwd)
255
- now = datetime.now(timezone.utc).isoformat()
256
- with DB_LOCK:
257
- conn = _db()
258
- conn.execute("INSERT INTO sessions VALUES (?,?,?,?,1)", (sid, req.workspace, req.cwd, now))
259
- conn.commit()
260
- conn.close()
261
- return {"session_id": sid, "workspace": req.workspace, "cwd": req.cwd, "created_at": now}
262
 
263
 
264
- @app.post("/session/run")
265
- async def run_session_command(req: SessionRunRequest):
266
- with DB_LOCK:
267
- conn = _db()
268
- row = conn.execute("SELECT * FROM sessions WHERE session_id=? AND active=1", (req.session_id,)).fetchone()
269
- conn.close()
270
- if not row:
271
- raise HTTPException(status_code=404, detail="Session not found or inactive")
272
- cwd = _ws_path(row["workspace"], row["cwd"])
273
- result = _safe_run(["sh", "-c", req.command], cwd, req.timeout_seconds)
274
- return {"workspace": row["workspace"], "cwd": row["cwd"], "command": req.command, **result, "receipt": _receipt("session_run", req.command[:80], result, row["workspace"]), "receipt_error": None}
275
-
276
-
277
- # ═══════════════════════════════════════════════════════════════
278
- # FILES
279
- # ═══════════════════════════════════════════════════════════════
280
- @app.post("/files/write")
281
- async def write_file(req: FileWriteRequest):
282
- target = _ws_path(req.workspace, req.path)
283
- target.parent.mkdir(parents=True, exist_ok=True)
284
- target.write_text(req.content, encoding=req.encoding)
285
- receipt = _receipt("file_write", req.path, {"bytes": len(req.content)}, req.workspace)
286
- return {"status": "written", "path": req.path, "bytes": len(req.content), "receipt": receipt}
287
-
288
-
289
- @app.post("/files/read")
290
- async def read_file(req: FileReadRequest):
291
- target = _ws_path(req.workspace, req.path)
292
- if not target.exists():
293
- raise HTTPException(status_code=404, detail="File not found")
294
- if target.is_dir():
295
- raise HTTPException(status_code=400, detail="Path is a directory")
296
- data = target.read_bytes()[:req.max_bytes]
297
- try:
298
- text = data.decode("utf-8")
299
- return {"path": req.path, "content": text, "bytes": len(data), "truncated": target.stat().st_size > req.max_bytes}
300
- except UnicodeDecodeError:
301
- import base64
302
- return {"path": req.path, "content_base64": base64.b64encode(data).decode(), "bytes": len(data), "truncated": target.stat().st_size > req.max_bytes}
303
-
304
-
305
- @app.post("/files/list")
306
- async def list_files(req: ListRequest):
307
- target = _ws_path(req.workspace, req.path)
308
- if not target.exists():
309
- return {"path": req.path, "entries": []}
310
- entries = []
311
- if target.is_dir():
312
- for item in sorted(target.iterdir())[:req.max_items]:
313
- entries.append({"name": item.name, "type": "dir" if item.is_dir() else "file", "size": item.stat().st_size if item.is_file() else None})
314
- return {"path": req.path, "entries": entries}
315
-
316
-
317
- @app.get("/workspace/tree")
318
- async def get_workspace_tree(workspace: str = "default", max_items: int = 500):
319
- base = _ws_path(workspace)
320
- lines = []
321
- count = 0
322
- for p in sorted(base.rglob("*")):
323
- if count >= max_items:
324
- lines.append("... (truncated)")
325
- break
326
- rel = p.relative_to(base)
327
- indent = " " * (len(rel.parts) - 1)
328
- marker = "/" if p.is_dir() else ""
329
- lines.append(f"{indent}{p.name}{marker}")
330
- count += 1
331
- return PlainTextResponse("\n".join(lines) if lines else "(empty)")
332
-
333
-
334
- # ═══════════════════════════════════════════════════════════════
335
- # ARTIFACTS
336
- # ═══════════════════════════════════════════════════════════════
337
- app.mount("/artifact", StaticFiles(directory=str(ARTIFACTS_DIR)), name="artifacts")
338
-
339
-
340
- @app.post("/artifact/compile")
341
- async def compile_artifact(req: ArtifactRequest):
342
- artifact_hash = hashlib.sha256(req.content.encode()).hexdigest()[:16]
343
- artifact_dir = ARTIFACTS_DIR / artifact_hash
344
- artifact_dir.mkdir(parents=True, exist_ok=True)
345
- (artifact_dir / req.filename).write_text(req.content)
346
- (artifact_dir / "meta.json").write_text(json.dumps({"filename": req.filename, "content_type": req.content_type, "workspace": req.workspace, "sha256": artifact_hash, "created_at": datetime.now(timezone.utc).isoformat()}, indent=2))
347
- receipt = _receipt("artifact", req.filename, {"hash": artifact_hash}, req.workspace)
348
- return {"hash": artifact_hash, "filename": req.filename, "url": f"/artifact/{artifact_hash}/{req.filename}", "receipt": receipt}
349
-
350
-
351
- @app.post("/url/issue")
352
- async def issue_url(req: ArtifactRequest):
353
- artifact_hash = hashlib.sha256(req.content.encode()).hexdigest()[:16]
354
- artifact_dir = ARTIFACTS_DIR / artifact_hash
355
- artifact_dir.mkdir(parents=True, exist_ok=True)
356
- (artifact_dir / req.filename).write_text(req.content)
357
- url = f"https://josephrw-endpoint.hf.space/artifact/{artifact_hash}/{req.filename}"
358
- receipt = _receipt("url_issue", req.filename, {"url": url, "hash": artifact_hash}, req.workspace)
359
- return {"url": url, "hash": artifact_hash, "filename": req.filename, "receipt": receipt}
360
-
361
-
362
- # ═══════════════════════════════════════════════════════════════
363
- # MEMORY
364
- # ═══════════════════════════════════════════════════════════════
365
- @app.post("/learn")
366
- async def learn_memory(req: LearnRequest):
367
- mid = uuid.uuid4().hex[:16]
368
- now = datetime.now(timezone.utc).isoformat()
369
- with DB_LOCK:
370
- conn = _db()
371
- conn.execute("INSERT INTO memory VALUES (?,?,?,?,?,?)", (mid, req.topic, req.content, req.utility, json.dumps(req.tags), now))
372
- conn.commit()
373
- conn.close()
374
- return {"status": "learned", "id": mid, "topic": req.topic}
375
 
376
 
377
- @app.post("/recall")
378
- async def recall_memory(req: RecallRequest):
379
- with DB_LOCK:
380
- conn = _db()
381
- rows = conn.execute("SELECT * FROM memory WHERE content LIKE ? OR topic LIKE ? ORDER BY utility DESC LIMIT ?", (f"%{req.query}%", f"%{req.query}%", req.limit)).fetchall()
382
- conn.close()
383
- return {"results": [{"id": r["id"], "topic": r["topic"], "content": r["content"], "utility": r["utility"], "tags": json.loads(r["tags"]), "created_at": r["created_at"]} for r in rows]}
384
-
385
-
386
- # ═══════════════════════════════════════════════════════════════
387
- # DYNAMIC TOOLS
388
- # ═══════════════════════════════════════════════════════════════
389
- @app.post("/tool/register")
390
- async def register_tool(req: ToolRegisterRequest):
391
- now = datetime.now(timezone.utc).isoformat()
392
- with DB_LOCK:
393
- conn = _db()
394
- conn.execute("INSERT OR REPLACE INTO tools VALUES (?,?,?,?,?,?,?)", (req.name, req.description, req.mode, req.command_template, json.dumps(req.schema_data), int(req.enabled), now))
395
- conn.commit()
396
- conn.close()
397
- return {"status": "registered", "name": req.name, "mode": req.mode}
398
 
399
 
400
- @app.get("/tools")
401
- async def list_tools():
402
- with DB_LOCK:
403
- conn = _db()
404
- rows = conn.execute("SELECT * FROM tools WHERE enabled=1").fetchall()
405
- conn.close()
406
- return {"tools": [{"name": r["name"], "description": r["description"], "mode": r["mode"], "command_template": r["command_template"], "schema": json.loads(r["schema_data"])} for r in rows]}
 
 
 
 
 
 
 
 
 
 
 
407
 
408
 
409
- @app.post("/tool/{name}")
410
- async def invoke_tool(name: str, req: ToolInvokeRequest):
411
- with DB_LOCK:
412
- conn = _db()
413
- row = conn.execute("SELECT * FROM tools WHERE name=? AND enabled=1", (name,)).fetchone()
414
- conn.close()
415
- if not row:
416
- raise HTTPException(status_code=404, detail=f"Tool '{name}' not found")
417
- template = row["command_template"]
418
- cmd = template
419
- for k, v in req.args.items():
420
- cmd = cmd.replace(f"{{{{{k}}}}}", str(v))
421
- if row["mode"] == "python":
422
- ws_base = _ws_path(req.workspace)
423
- script = ws_base / f"_tool_{uuid.uuid4().hex[:8]}.py"
424
- script.write_text(cmd)
425
- result = _safe_run(["python3", str(script)], ws_base, req.timeout_seconds)
426
- script.unlink(missing_ok=True)
427
- else:
428
- cwd = _ws_path(req.workspace)
429
- result = _safe_run(["sh", "-c", cmd], cwd, req.timeout_seconds)
430
- receipt = _receipt("tool_invoke", name, result, req.workspace)
431
- return {"tool": name, **result, "receipt": receipt}
432
-
433
-
434
- # ═══════════════════════════════════════════════════════════════
435
- # RECEIPTS / LEDGER
436
- # ═══════════════════════════════════════════════════════════════
437
- @app.get("/ledger/recent")
438
- async def get_recent_ledger(limit: int = 50):
439
- with DB_LOCK:
440
- conn = _db()
441
- rows = conn.execute("SELECT id, kind, workspace, summary, sha256, created_at FROM receipts ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()
442
- conn.close()
443
- return {"receipts": [{"id": r["id"], "kind": r["kind"], "workspace": r["workspace"], "summary": r["summary"], "sha256": r["sha256"], "created_at": r["created_at"]} for r in rows]}
444
 
445
 
446
- @app.get("/receipt/{receipt_id}")
447
- async def get_receipt(receipt_id: str):
448
- with DB_LOCK:
449
- conn = _db()
450
- row = conn.execute("SELECT * FROM receipts WHERE id=?", (receipt_id,)).fetchone()
451
- conn.close()
452
- if not row:
453
- raise HTTPException(status_code=404, detail="Receipt not found")
454
- return {"id": row["id"], "kind": row["kind"], "workspace": row["workspace"], "summary": row["summary"], "sha256": row["sha256"], "created_at": row["created_at"], "data": json.loads(row["data_json"])}
 
 
 
 
 
 
455
 
456
 
457
- # ═══════════════════════════════════════════════════════════════
458
- # ROOT
459
- # ═══════════════════════════════════════════════════════════════
460
- @app.get("/")
461
- async def root():
 
 
 
 
 
 
462
  return {
463
- "service": "Giant GPT Terminal Kernel",
464
- "version": "3.1.1",
465
- "endpoints": [
466
- "GET /health",
467
- "POST /terminal/run",
468
- "POST /python/run",
469
- "POST /session/create",
470
- "POST /session/run",
471
- "POST /files/write",
472
- "POST /files/read",
473
- "POST /files/list",
474
- "GET /workspace/tree",
475
- "POST /artifact/compile",
476
- "POST /url/issue",
477
- "POST /learn",
478
- "POST /recall",
479
- "POST /tool/register",
480
- "GET /tools",
481
- "POST /tool/{name}",
482
- "GET /ledger/recent",
483
- "GET /receipt/{receipt_id}",
484
- ],
485
  }
 
 
 
 
 
 
1
+ """MasseurBoost Full SaaS deployed on Hugging Face Spaces.
2
 
3
+ Self-contained: embeds real scraped databases, serves market intelligence,
4
+ profile pages, orchestrator status (read-only), and control panel UI.
5
  """
 
6
  import json
7
+ import os
 
 
8
  import sqlite3
 
 
9
  from pathlib import Path
10
  from datetime import datetime, timezone
 
11
 
12
+ from fastapi import FastAPI, Request, Query
13
+ from fastapi.responses import HTMLResponse, JSONResponse
14
  from fastapi.staticfiles import StaticFiles
15
+ from fastapi.templating import Jinja2Templates
16
+
17
+ BASE_DIR = Path(__file__).parent
18
+ TEMPLATES_DIR = BASE_DIR / "templates"
19
+ STATIC_DIR = BASE_DIR / "static"
20
+ DATA_DIR = BASE_DIR / "data"
21
+
22
+ app = FastAPI(title="MasseurBoost", docs_url="/api/docs")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
+ templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
25
+ if STATIC_DIR.exists():
26
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
27
 
28
+ MASSEURS_DB = DATA_DIR / "masseurs.db"
29
+ UNIFIED_DB = DATA_DIR / "unified.db"
30
+ CONTENT_POOL_DB = DATA_DIR / "content_pool.db"
31
 
32
+ RENTMASSEUR_BASE = "https://rentmasseur.com"
33
 
34
+
35
+ def get_masseurs_db():
36
+ conn = sqlite3.connect(str(MASSEURS_DB))
37
  conn.row_factory = sqlite3.Row
38
  return conn
39
 
40
 
41
+ def get_unified_db():
42
+ conn = sqlite3.connect(str(UNIFIED_DB))
43
+ conn.row_factory = sqlite3.Row
44
+ return conn
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
 
47
+ def get_content_pool_db():
48
+ conn = sqlite3.connect(str(CONTENT_POOL_DB))
49
+ conn.row_factory = sqlite3.Row
50
+ return conn
 
 
 
 
 
 
 
 
51
 
52
 
53
+ # ─── Landing page ───────────────────────────────────────────
54
+ @app.get("/", response_class=HTMLResponse)
55
+ async def landing(request: Request):
56
+ return templates.TemplateResponse(request, "landing.html")
 
 
 
 
 
57
 
58
 
59
+ # ─── Dashboard ──────────────────────────────────────────────
60
+ @app.get("/dashboard", response_class=HTMLResponse)
61
+ async def dashboard(request: Request):
62
+ return templates.TemplateResponse(request, "dashboard.html")
 
63
 
64
 
65
+ # ─── Control Panel ──────────────────────────────────────────
66
+ @app.get("/control", response_class=HTMLResponse)
67
+ async def control_panel(request: Request):
68
+ return templates.TemplateResponse(request, "control.html")
69
 
70
 
71
+ # ─── Profile page ───────────────────────────────────────────
72
+ @app.get("/profile/{username}", response_class=HTMLResponse)
73
+ async def profile_page(request: Request, username: str):
74
+ conn = get_masseurs_db()
75
+ c = conn.cursor()
76
+ row = c.execute("SELECT * FROM masseurs WHERE username = ?", (username,)).fetchone()
77
+ if not row:
78
+ conn.close()
79
+ return templates.TemplateResponse(request, "profile.html", {"error": True, "username": username})
80
+ profile = dict(row)
81
+ total = c.execute("SELECT COUNT(*) FROM masseurs").fetchone()[0]
82
+ rank = c.execute("SELECT COUNT(*) FROM masseurs WHERE views_per_day > ?", (profile["views_per_day"],)).fetchone()[0] + 1
83
+ percentile = round((1 - rank / total) * 100, 1)
84
+ city_peers = c.execute(
85
+ "SELECT username, views_per_day, visits FROM masseurs WHERE city = ? AND username != ? ORDER BY views_per_day DESC LIMIT 5",
86
+ (profile["city"], username),
87
+ ).fetchall()
88
+ conn.close()
89
+ return templates.TemplateResponse(request, "profile.html", {
90
+ "error": False,
91
+ "profile": profile,
92
+ "rank": rank,
93
+ "total": total,
94
+ "percentile": percentile,
95
+ "city_peers": [dict(r) for r in city_peers],
96
+ "services": json.loads(profile.get("services", "[]")),
97
+ "rentmasseur_url": f"{RENTMASSEUR_BASE}/{username}",
98
+ })
99
+
100
+
101
+ # ─── API: Market stats ──────────────────────────────────────
102
+ @app.get("/api/market-stats")
103
+ async def market_stats():
104
+ conn = get_masseurs_db()
105
+ c = conn.cursor()
106
+ total = c.execute("SELECT COUNT(*) FROM masseurs").fetchone()[0]
107
+ cities = c.execute("SELECT COUNT(DISTINCT city) FROM masseurs").fetchone()[0]
108
+ gold = c.execute("SELECT COUNT(*) FROM masseurs WHERE is_gold=1").fetchone()[0]
109
+ avg_vpd = c.execute("SELECT AVG(views_per_day) FROM masseurs WHERE views_per_day > 0").fetchone()[0]
110
+ median_vpd = c.execute("SELECT views_per_day FROM masseurs WHERE views_per_day > 0 ORDER BY views_per_day").fetchall()
111
+ median = median_vpd[len(median_vpd)//2][0] if median_vpd else 0
112
 
113
+ top_cities = c.execute(
114
+ "SELECT city, COUNT(*) as cnt, AVG(views_per_day) as avg_vpd FROM masseurs GROUP BY city ORDER BY cnt DESC LIMIT 10"
115
+ ).fetchall()
116
 
117
+ top_masseurs = c.execute(
118
+ "SELECT username, city, views_per_day, visits, rating, reviews_count, is_gold, photo_count FROM masseurs ORDER BY views_per_day DESC LIMIT 10"
119
+ ).fetchall()
 
 
120
 
121
+ conn.close()
122
+ return {
123
+ "total_masseurs": total,
124
+ "total_cities": cities,
125
+ "gold_members": gold,
126
+ "avg_views_per_day": round(avg_vpd or 0, 1),
127
+ "median_views_per_day": median,
128
+ "top_cities": [{"city": r[0], "count": r[1], "avg_vpd": round(r[2] or 0, 1)} for r in top_cities],
129
+ "top_masseurs": [
130
+ {"username": r[0], "city": r[1], "vpd": r[2], "visits": r[3], "rating": r[4],
131
+ "reviews": r[5], "gold": bool(r[6]), "photos": r[7],
132
+ "profile_url": f"{RENTMASSEUR_BASE}/{r[0]}",
133
+ "internal_url": f"/profile/{r[0]}"}
134
+ for r in top_masseurs
135
+ ],
136
+ }
137
 
 
 
 
 
138
 
139
+ # ─── API: Search masseurs ───────────────────────────────────
140
+ @app.get("/api/search")
141
+ async def search_masseurs(
142
+ city: str = Query("", description="Filter by city"),
143
+ q: str = Query("", description="Search by username or city"),
144
+ limit: int = Query(20, le=100),
145
+ offset: int = Query(0),
146
+ sort: str = Query("views_per_day", description="Sort field"),
147
+ ):
148
+ conn = get_masseurs_db()
149
+ c = conn.cursor()
150
+ where = "WHERE 1=1"
151
+ params = []
152
+ if city:
153
+ where += " AND city = ?"
154
+ params.append(city)
155
+ if q:
156
+ where += " AND (username LIKE ? OR city LIKE ?)"
157
+ params += [f"%{q}%", f"%{q}%"]
158
+
159
+ valid_sorts = {"views_per_day", "visits", "reviews_count", "rating", "bio"}
160
+ sort = sort if sort in valid_sorts else "views_per_day"
161
+
162
+ total = c.execute(f"SELECT COUNT(*) FROM masseurs {where}", params).fetchone()[0]
163
+ rows = c.execute(
164
+ f"SELECT username, city, views_per_day, visits, since_date, rating, reviews_count, is_gold, photo_count FROM masseurs {where} ORDER BY {sort} DESC LIMIT ? OFFSET ?",
165
+ params + [limit, offset],
166
+ ).fetchall()
167
 
168
+ conn.close()
169
+ return {
170
+ "total": total,
171
+ "results": [
172
+ {"username": r[0], "city": r[1], "vpd": r[2], "visits": r[3], "since": r[4],
173
+ "rating": r[5], "reviews": r[6], "gold": bool(r[7]), "photos": r[8],
174
+ "profile_url": f"{RENTMASSEUR_BASE}/{r[0]}",
175
+ "internal_url": f"/profile/{r[0]}"}
176
+ for r in rows
177
+ ],
178
+ }
179
 
180
 
181
+ # ─── API: Cities list ───────────────────────────────────────
182
+ @app.get("/api/cities")
183
+ async def cities():
184
+ conn = get_masseurs_db()
185
+ c = conn.cursor()
186
+ rows = c.execute(
187
+ "SELECT city, COUNT(*) as cnt FROM masseurs GROUP BY city ORDER BY cnt DESC"
188
+ ).fetchall()
189
+ conn.close()
190
+ return {"cities": [{"name": r[0], "count": r[1]} for r in rows]}
191
 
192
 
193
+ # ─── API: Orchestrator status (read-only on HF) ─────────────
194
+ @app.get("/api/orchestrator/status")
195
+ async def orch_status():
196
+ return {
197
+ "daemon_running": False,
198
+ "daemon_note": "Running on Hugging Face Spaces — daemon operates from local Mac. Visit local control panel for live daemon control.",
199
+ "tasks": [],
200
+ "log_tail": [],
201
+ }
202
+
203
+
204
+ # ─── API: DB stats ──────────────────────────────────────────
205
+ @app.get("/api/orchestrator/db-stats")
206
+ async def db_stats():
207
+ stats = {}
208
+ if UNIFIED_DB.exists():
209
+ u = get_unified_db()
210
+ stats["visitors"] = u.execute("SELECT COUNT(*) FROM visitors").fetchone()[0]
211
+ stats["visit_log"] = u.execute("SELECT COUNT(*) FROM visit_log").fetchone()[0]
212
+ stats["hourly_kpis"] = u.execute("SELECT COUNT(*) FROM hourly_kpis").fetchone()[0]
213
+ stats["profile_stats"] = u.execute("SELECT COUNT(*) FROM my_profile_stats").fetchone()[0]
214
+ try:
215
+ stats["bio_experiments"] = u.execute("SELECT COUNT(*) FROM bio_experiments").fetchone()[0]
216
+ except Exception:
217
+ stats["bio_experiments"] = 0
218
+ try:
219
+ stats["blog_posts"] = u.execute("SELECT COUNT(*) FROM blog_posts").fetchone()[0]
220
+ except Exception:
221
+ stats["blog_posts"] = 0
222
+ try:
223
+ stats["interview_sets"] = u.execute("SELECT COUNT(*) FROM interview_sets").fetchone()[0]
224
+ except Exception:
225
+ stats["interview_sets"] = 0
226
+ try:
227
+ stats["receipts"] = u.execute("SELECT COUNT(*) FROM my_receipts").fetchone()[0]
228
+ except Exception:
229
+ stats["receipts"] = 0
230
+ row = u.execute("SELECT * FROM hourly_kpis ORDER BY id DESC LIMIT 1").fetchone()
231
+ if row:
232
+ cols = [d[0] for d in u.execute("SELECT * FROM hourly_kpis LIMIT 0").description]
233
+ stats["latest_kpi"] = dict(zip(cols, row))
234
+ u.close()
235
+ if MASSEURS_DB.exists():
236
+ m = get_masseurs_db()
237
+ stats["masseurs_scraped"] = m.execute("SELECT COUNT(*) FROM masseurs").fetchone()[0]
238
+ stats["cities_scraped"] = m.execute("SELECT COUNT(DISTINCT city) FROM masseurs").fetchone()[0]
239
+ m.close()
240
+ if CONTENT_POOL_DB.exists():
241
+ cp = get_content_pool_db()
242
+ stats["content_variants"] = cp.execute("SELECT COUNT(*) FROM content_pool").fetchone()[0]
243
+ stats["competitor_benchmarks"] = cp.execute("SELECT COUNT(*) FROM competitor_benchmarks").fetchone()[0]
244
+ cp.close()
245
+ return stats
246
+
247
+
248
+ # ─── API: Orchestrator logs (read from embedded state) ──────
249
+ @app.get("/api/orchestrator/logs")
250
+ async def orch_logs(limit: int = 100):
251
+ return {"logs": [], "note": "Logs available on local control panel"}
252
+
253
+
254
+ # ─── API: Trigger tasks (disabled on HF) ────────────────────
255
+ @app.post("/api/orchestrator/trigger/{task_name}")
256
+ async def orch_trigger(task_name: str):
257
+ return JSONResponse(
258
+ {"error": "Task triggering is only available on the local control panel (localhost:8000)"},
259
+ status_code=503,
260
+ )
261
+
262
+
263
+ @app.post("/api/orchestrator/daemon/start")
264
+ async def daemon_start():
265
+ return JSONResponse(
266
+ {"error": "Daemon control is only available on the local control panel (localhost:8000)"},
267
+ status_code=503,
268
+ )
269
+
270
+
271
+ @app.post("/api/orchestrator/daemon/stop")
272
+ async def daemon_stop():
273
+ return JSONResponse(
274
+ {"error": "Daemon control is only available on the local control panel (localhost:8000)"},
275
+ status_code=503,
276
+ )
277
+
278
+
279
+ # ─── API: Competitors by city ───────────────────────────────
280
+ @app.get("/api/competitors/{city}")
281
+ async def competitors(city: str, limit: int = Query(20, le=100)):
282
+ conn = get_masseurs_db()
283
+ c = conn.cursor()
284
+ rows = c.execute(
285
+ "SELECT username, city, views_per_day, visits, rating, reviews_count, is_gold, photo_count FROM masseurs WHERE city = ? ORDER BY views_per_day DESC LIMIT ?",
286
+ (city, limit),
287
+ ).fetchall()
288
+ conn.close()
289
+ return {
290
+ "city": city,
291
+ "count": len(rows),
292
+ "competitors": [
293
+ {"username": r[0], "city": r[1], "vpd": r[2], "visits": r[3],
294
+ "rating": r[4], "reviews": r[5], "gold": bool(r[6]), "photos": r[7],
295
+ "profile_url": f"{RENTMASSEUR_BASE}/{r[0]}"}
296
+ for r in rows
297
+ ],
298
+ }
299
 
300
 
301
+ # ─── API: Optimization status ───────────────────────────────
302
+ @app.get("/api/optimization-status")
303
+ async def optimization_status():
304
+ return {
305
+ "engine": "Genetic Algorithm + Reinforcement Learning + LLM",
306
+ "components": [
307
+ {"name": "Bio Optimizer", "status": "active", "description": "GA evolves bio text, RL tracks engagement, LLM generates mutations"},
308
+ {"name": "Blog Generator", "status": "active", "description": "LLM generates SEO-optimized blog posts from winning patterns"},
309
+ {"name": "Photo Rotator", "status": "active", "description": "GA tests photo order combinations for max click-through"},
310
+ {"name": "Availability Engine", "status": "active", "description": "RL optimizes availability windows for booking conversion"},
311
+ {"name": "Interview Rotator", "status": "active", "description": "LLM generates funny Q&A that drive engagement"},
312
+ {"name": "Price Optimizer", "status": "active", "description": "GA tests price points for revenue maximization"},
313
+ ],
314
+ "cycle": "Every 3 hours: collect stats → evaluate fitness → mutate → push winners → repeat",
315
+ }
316
 
317
 
318
+ # ─── API: Blog topics ───────────────────────────────────────
319
+ @app.get("/api/blog/topics")
320
+ async def blog_topics():
321
+ topics = [
322
+ {"id": "perfect_session", "title": "The Perfect Session", "angle": "What makes a 5-star massage experience"},
323
+ {"id": "client_etiquette", "title": "Client Etiquette 101", "angle": "How to be the client therapists love"},
324
+ {"id": "deep_tissue_magic", "title": "Deep Tissue Magic", "angle": "Why deep tissue isn't scary"},
325
+ {"id": "midnight_massage", "title": "Midnight Massage Stories", "angle": "The weird and wonderful night crowd"},
326
+ {"id": "luxury_experience", "title": "Luxury on a Budget", "angle": "Premium feel without the spa price"},
327
+ {"id": "recovery_secrets", "title": "Recovery Secrets", "angle": "Post-workout massage for athletes"},
328
+ {"id": "first_time_guide", "title": "First Time? Here's What to Expect", "angle": "Beginner-friendly guide"},
329
+ {"id": "stress_relief", "title": "Stress Relief That Actually Works", "angle": "Why massage beats meditation"},
330
+ {"id": "weekend_reset", "title": "The Weekend Reset", "angle": "Friday massage = Monday productivity"},
331
+ {"id": "funniest_requests", "title": "Funniest Client Requests", "angle": "Humor drives engagement and bookings"},
332
+ ]
333
+ return {"topics": topics}
334
+
335
+
336
+ # ─── API: Photo strategy ────────────────────────────────────
337
+ @app.get("/api/photo-strategy")
338
+ async def photo_strategy():
339
+ return {
340
+ "strategy": "organism",
341
+ "description": "Your profile photos rotate automatically based on time of day, day of week, and engagement patterns.",
342
+ "schedule": [
343
+ {"time": "Morning (6am-12pm)", "type": "Professional/clinical shots", "reason": "Business professionals browsing before work"},
344
+ {"time": "Afternoon (12pm-6pm)", "type": "Action/massage technique shots", "reason": "Lunch break browsers want to see skills"},
345
+ {"time": "Evening (6pm-12am)", "type": "Lifestyle/relaxation shots", "reason": "After-work crowd wants to envision the experience"},
346
+ {"time": "Late night (12am-6am)", "type": "Mood/atmosphere shots", "reason": "Night owls respond to ambiance over clinical"},
347
+ ],
348
+ "rotation_frequency": "Every 6 hours",
349
+ "ai_optimization": "GA optimizer tests photo order combinations and picks the one with highest click-through rate",
350
+ }
351
 
352
 
353
+ # ─── API: Availability gravity ──────────────────────────────
354
+ @app.get("/api/availability-gravity")
355
+ async def availability_gravity():
356
+ return {
357
+ "feature": "Availability Gravity",
358
+ "description": "Dynamic availability updates that create urgency and drive repeat visits.",
359
+ "tactics": [
360
+ {"name": "Scarcity Windows", "description": "Show limited time slots to create urgency", "impact": "Increases booking conversion by 40-60%"},
361
+ {"name": "Dynamic Status", "description": "Auto-update status to 'Available Now' during peak browsing hours", "impact": "Captures impulse bookings"},
362
+ {"name": "Revisit Triggers", "description": "Rotate availability display so returning visitors see new slots", "impact": "Increases daily return visits by 25%"},
363
+ {"name": "Peak Hour Alignment", "description": "Open availability slots during historically high-traffic periods", "impact": "Maximizes visibility when most clients are browsing"},
364
+ ],
365
+ "rl_optimization": "Reinforcement learning loop tracks which availability patterns drive the most bookings and auto-adjusts.",
366
+ }
367
 
368
 
369
+ # ─── API: Pricing ───────────────────────────────────────────
370
+ @app.get("/api/pricing")
371
+ async def pricing():
372
+ return {
373
+ "plans": [
374
+ {"name": "Starter", "price": "$49/mo", "features": ["AI-optimized bio (monthly refresh)", "2 blog posts per month", "Basic photo rotation", "Market intelligence report", "Email support"]},
375
+ {"name": "Professional", "price": "$149/mo", "popular": True, "features": ["GA/RL bio optimization (weekly A/B testing)", "4 blog posts per month", "Smart photo rotation", "Availability gravity engine", "Interview Q&A rotation", "Competitor benchmarking", "Priority support"]},
376
+ {"name": "Elite", "price": "$399/mo", "features": ["Full autonomous optimization", "Unlimited blog posts + interviews", "Advanced photo rotation (GA-optimized)", "Full availability gravity + price optimization", "Worldwide competitor intelligence", "Custom LLM fine-tuning", "Dedicated account manager", "API access"]},
377
+ ]
378
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
 
380
 
381
+ # ─── API: Bio preview ───────────────────────────────────────
382
+ @app.get("/api/bio-preview")
383
+ async def bio_preview(style: str = Query("balanced")):
384
+ previews = {
385
+ "balanced": "Certified massage therapist with years of experience in deep tissue, Swedish, and therapeutic techniques. Discreet, professional, and dedicated to your relaxation. Available now in Manhattan — call to book your session today.",
386
+ "funny": "I'm not saying my hands are magic, but clients have been known to forget their own name mid-massage. Certified, discreet, and surprisingly funny for someone who works in silence. Available now — your muscles are begging you to call.",
387
+ "luxury": "An exclusive massage experience for the discerning gentleman. Private studio, premium oils, and techniques refined over thousands of sessions. When only the best will do. By appointment only — call now to secure your time slot.",
388
+ "clinical": "Licensed massage therapist specializing in deep tissue recovery, sports massage, and chronic pain management. Evidence-based techniques, professional environment, proven results. Direct booking available — call to schedule your consultation.",
389
+ "mysterious": "Some experiences can't be described — only felt. Step into a world of sensory exploration where every touch tells a story. Discreet, exclusive, unforgettable. The key is in your hands — call to unlock.",
390
+ }
391
+ return {"style": style, "preview": previews.get(style, previews["balanced"])}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
 
393
 
394
+ # ─── API: Health ────────────────────────────────────────────
395
+ @app.get("/api/health")
396
+ async def health():
397
+ return {
398
+ "status": "ok",
399
+ "masseurs_db": str(MASSEURS_DB),
400
+ "masseurs_db_exists": MASSEURS_DB.exists(),
401
+ "unified_db_exists": UNIFIED_DB.exists(),
402
+ "content_pool_db_exists": CONTENT_POOL_DB.exists(),
403
+ }
 
 
 
 
 
 
 
 
 
 
 
404
 
405
 
406
+ # ─── API: Visitors data ─────────────────────────────────────
407
+ @app.get("/api/visitors")
408
+ async def visitors(limit: int = Query(50, le=200)):
409
+ conn = get_unified_db()
410
+ c = conn.cursor()
411
+ rows = c.execute(
412
+ "SELECT username, visit_count, last_online, profile_url, profile_hash FROM visitors ORDER BY visit_count DESC LIMIT ?",
413
+ (limit,),
414
+ ).fetchall()
415
+ conn.close()
416
+ return {
417
+ "count": len(rows),
418
+ "visitors": [
419
+ {"username": r[0], "visit_count": r[1], "last_online": r[2],
420
+ "profile_url": r[3], "profile_hash": r[4]}
421
+ for r in rows
422
+ ],
423
+ }
424
 
425
 
426
+ # ─── API: KPI history ───────────────────────────────────────
427
+ @app.get("/api/kpis")
428
+ async def kpi_history(limit: int = Query(50, le=200)):
429
+ conn = get_unified_db()
430
+ c = conn.cursor()
431
+ rows = c.execute(
432
+ "SELECT id, timestamp, immortality_score, immortality_grade, virality_score, virality_grade, profile_views, contact_clicks, new_visits, views_per_day FROM hourly_kpis ORDER BY id DESC LIMIT ?",
433
+ (limit,),
434
+ ).fetchall()
435
+ conn.close()
436
+ return {
437
+ "count": len(rows),
438
+ "kpis": [
439
+ {"id": r[0], "timestamp": r[1], "immortality_score": r[2], "immortality_grade": r[3],
440
+ "virality_score": r[4], "virality_grade": r[5], "profile_views": r[6],
441
+ "contact_clicks": r[7], "new_visits": r[8], "views_per_day": r[9]}
442
+ for r in rows
443
+ ],
444
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
 
446
 
447
+ # ─── API: Content pool ──────────────────────────────────────
448
+ @app.get("/api/content-pool")
449
+ async def content_pool(limit: int = Query(50, le=200)):
450
+ conn = get_content_pool_db()
451
+ c = conn.cursor()
452
+ rows = c.execute(
453
+ "SELECT * FROM content_pool ORDER BY benchmark_rank ASC LIMIT ?",
454
+ (limit,),
455
+ ).fetchall()
456
+ conn.close()
457
+ cols = [d[0] for d in c.description] if c.description else []
458
+ return {
459
+ "count": len(rows),
460
+ "content": [dict(zip(cols, r)) for r in rows],
461
+ }
462
 
463
 
464
+ # ─── API: Competitor benchmarks ─────────────────────────────
465
+ @app.get("/api/competitor-benchmarks")
466
+ async def competitor_benchmarks(limit: int = Query(50, le=200)):
467
+ conn = get_content_pool_db()
468
+ c = conn.cursor()
469
+ rows = c.execute(
470
+ "SELECT * FROM competitor_benchmarks ORDER BY id DESC LIMIT ?",
471
+ (limit,),
472
+ ).fetchall()
473
+ conn.close()
474
+ cols = [d[0] for d in c.description] if c.description else []
475
  return {
476
+ "count": len(rows),
477
+ "benchmarks": [dict(zip(cols, r)) for r in rows],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
  }
479
+
480
+
481
+ if __name__ == "__main__":
482
+ import uvicorn
483
+ uvicorn.run(app, host="0.0.0.0", port=7860)
data/content_pool.db ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fb715937575fa2e102eaf8f3d93e2ffa743619929cf355a042e3da5246d2644c
3
+ size 225280
data/masseurs.db ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:751f7531cdb8e6d02d1a3d54bc96165a4293acfade1f1df474e5b40fbf6386f5
3
+ size 6619136
data/unified.db ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e47431dbbbf3d37d0a62bc4f65fe6c0c15c452a88cecdbd7a562abd6cce8187a
3
+ size 1658880
templates/control.html ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>MasseurBoost — Orchestrator Control Panel</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0a0e17; --card: #131825; --border: #1e2a3d; --text: #e4e7ee;
10
+ --muted: #6b7488; --accent: #4f9eff; --green: #22c55e; --red: #ef4444;
11
+ --yellow: #eab923; --orange: #f97316; --purple: #a78bfa;
12
+ }
13
+ * { margin: 0; padding: 0; box-sizing: border-box; }
14
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
15
+
16
+ .header { background: var(--card); border-bottom: 1px solid var(--border); padding: 16px 24px; display: flex; align-items: center; justify-content: space-between; }
17
+ .header h1 { font-size: 20px; font-weight: 700; }
18
+ .header h1 span { color: var(--accent); }
19
+ .header nav a { color: var(--muted); text-decoration: none; margin-left: 20px; font-size: 14px; }
20
+ .header nav a:hover { color: var(--text); }
21
+ .header nav a.active { color: var(--accent); }
22
+
23
+ .container { max-width: 1280px; margin: 0 auto; padding: 24px; }
24
+
25
+ /* Daemon status bar */
26
+ .daemon-bar { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 20px 24px; margin-bottom: 24px; display: flex; align-items: center; justify-content: space-between; }
27
+ .daemon-status { display: flex; align-items: center; gap: 12px; }
28
+ .daemon-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--red); transition: background 0.3s; }
29
+ .daemon-dot.running { background: var(--green); box-shadow: 0 0 8px var(--green); animation: pulse 2s infinite; }
30
+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
31
+ .daemon-text { font-size: 16px; font-weight: 600; }
32
+ .daemon-text small { color: var(--muted); font-weight: 400; font-size: 13px; margin-left: 8px; }
33
+ .btn { padding: 8px 18px; border-radius: 8px; border: none; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
34
+ .btn-start { background: var(--green); color: #fff; }
35
+ .btn-stop { background: var(--red); color: #fff; }
36
+ .btn:hover { opacity: 0.85; transform: translateY(-1px); }
37
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
38
+
39
+ /* Stats grid */
40
+ .stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 16px; margin-bottom: 24px; }
41
+ .stat-card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 18px; text-align: center; }
42
+ .stat-card .num { font-size: 28px; font-weight: 700; color: var(--accent); }
43
+ .stat-card .label { font-size: 12px; color: var(--muted); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
44
+
45
+ /* Task table */
46
+ .section-title { font-size: 18px; font-weight: 700; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
47
+ .section-title .badge { font-size: 11px; padding: 2px 8px; border-radius: 4px; background: var(--border); color: var(--muted); }
48
+
49
+ .task-table { background: var(--card); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; margin-bottom: 24px; }
50
+ .task-row { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr 0.8fr 0.8fr; padding: 14px 20px; border-bottom: 1px solid var(--border); align-items: center; font-size: 14px; }
51
+ .task-row:last-child { border-bottom: none; }
52
+ .task-row.header-row { font-weight: 600; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
53
+ .task-row:hover { background: rgba(79, 158, 255, 0.05); }
54
+
55
+ .task-name { font-weight: 600; }
56
+ .task-name .icon { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 8px; }
57
+ .task-name .icon.due { background: var(--yellow); }
58
+ .task-name .icon.not-due { background: var(--green); }
59
+ .task-name .icon.running { background: var(--accent); animation: pulse 1s infinite; }
60
+
61
+ .status-pill { padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; }
62
+ .status-pill.due { background: rgba(234, 185, 35, 0.15); color: var(--yellow); }
63
+ .status-pill.ok { background: rgba(34, 197, 94, 0.15); color: var(--green); }
64
+ .status-pill.running { background: rgba(79, 158, 255, 0.15); color: var(--accent); }
65
+ .status-pill.idle { background: rgba(107, 116, 136, 0.15); color: var(--muted); }
66
+
67
+ .btn-sm { padding: 5px 12px; border-radius: 6px; font-size: 12px; }
68
+ .btn-trigger { background: var(--accent); color: #fff; }
69
+ .btn-trigger:disabled { background: var(--border); color: var(--muted); }
70
+
71
+ /* Log feed */
72
+ .log-feed { background: #0d1117; border: 1px solid var(--border); border-radius: 12px; padding: 16px; max-height: 400px; overflow-y: auto; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
73
+ .log-entry { padding: 4px 0; border-bottom: 1px solid rgba(30, 42, 61, 0.5); display: flex; gap: 12px; }
74
+ .log-entry .ts { color: var(--muted); white-space: nowrap; }
75
+ .log-entry .comp { color: var(--purple); min-width: 140px; }
76
+ .log-entry .status { font-weight: 600; }
77
+ .log-entry .status.success { color: var(--green); }
78
+ .log-entry .status.failed { color: var(--red); }
79
+ .log-entry .status.started { color: var(--accent); }
80
+ .log-entry .status.stopped { color: var(--orange); }
81
+
82
+ /* Run All bar */
83
+ .run-all-bar { display: flex; gap: 12px; margin-bottom: 24px; }
84
+ .btn-run-all { background: var(--purple); color: #fff; padding: 12px 28px; font-size: 15px; }
85
+
86
+ /* Responsive */
87
+ @media (max-width: 768px) {
88
+ .task-row { grid-template-columns: 1fr; gap: 8px; }
89
+ .task-row.header-row { display: none; }
90
+ .stats-grid { grid-template-columns: repeat(2, 1fr); }
91
+ }
92
+ </style>
93
+ </head>
94
+ <body>
95
+
96
+ <div class="header">
97
+ <h1>Masseur<span>Boost</span> Control Panel</h1>
98
+ <nav>
99
+ <a href="/">Home</a>
100
+ <a href="/dashboard">Market Intel</a>
101
+ <a href="/control" class="active">Orchestrator</a>
102
+ </nav>
103
+ </div>
104
+
105
+ <div class="container">
106
+
107
+ <!-- Daemon Status -->
108
+ <div class="daemon-bar">
109
+ <div class="daemon-status">
110
+ <div class="daemon-dot" id="daemonDot"></div>
111
+ <div class="daemon-text" id="daemonText">Checking... <small id="daemonSub"></small></div>
112
+ </div>
113
+ <div>
114
+ <button class="btn btn-start" id="daemonStart" onclick="daemonAction('start')">Start Daemon</button>
115
+ <button class="btn btn-stop" id="daemonStop" onclick="daemonAction('stop')" style="display:none">Stop Daemon</button>
116
+ </div>
117
+ </div>
118
+
119
+ <!-- DB Stats -->
120
+ <div class="section-title">Database Stats</div>
121
+ <div class="stats-grid" id="statsGrid">
122
+ <div class="stat-card"><div class="num">—</div><div class="label">Loading</div></div>
123
+ </div>
124
+
125
+ <!-- Run All -->
126
+ <div class="run-all-bar">
127
+ <button class="btn btn-run-all" onclick="triggerTask('all')">Run Full Pipeline Now</button>
128
+ </div>
129
+
130
+ <!-- Task Schedule -->
131
+ <div class="section-title">Pipeline Tasks <span class="badge" id="taskCount">—</span></div>
132
+ <div class="task-table" id="taskTable">
133
+ <div class="task-row header-row">
134
+ <div>Task</div>
135
+ <div>Interval</div>
136
+ <div>Last Run</div>
137
+ <div>Next In</div>
138
+ <div>Status</div>
139
+ <div>Action</div>
140
+ </div>
141
+ <div id="taskRows">
142
+ <div class="task-row"><div style="color:var(--muted)">Loading tasks...</div></div>
143
+ </div>
144
+ </div>
145
+
146
+ <!-- Live Log Feed -->
147
+ <div class="section-title">Live Orchestrator Log</div>
148
+ <div class="log-feed" id="logFeed">
149
+ <div style="color:var(--muted)">Loading logs...</div>
150
+ </div>
151
+
152
+ </div>
153
+
154
+ <script>
155
+ const API = '/api/orchestrator';
156
+
157
+ async function fetchStatus() {
158
+ try {
159
+ const r = await fetch(`${API}/status`);
160
+ const d = await r.json();
161
+ updateDaemon(d.daemon_running);
162
+ renderTasks(d.tasks);
163
+ renderLogs(d.log_tail);
164
+ } catch (e) {
165
+ updateDaemon(false);
166
+ }
167
+ }
168
+
169
+ async function fetchStats() {
170
+ try {
171
+ const r = await fetch(`${API}/db-stats`);
172
+ const d = await r.json();
173
+ const items = [
174
+ ['masseurs_scraped', 'Masseurs Scraped'],
175
+ ['cities_scraped', 'Cities'],
176
+ ['visitors', 'Visitors Tracked'],
177
+ ['visit_log', 'Visit Actions'],
178
+ ['hourly_kpis', 'KPI Snapshots'],
179
+ ['profile_stats', 'Profile Stats'],
180
+ ['bio_experiments', 'Bio Experiments'],
181
+ ['blog_posts', 'Blog Posts'],
182
+ ['interview_sets', 'Interview Sets'],
183
+ ['content_variants', 'Content Variants'],
184
+ ['competitor_benchmarks', 'Benchmarks'],
185
+ ['receipts', 'Receipts Logged'],
186
+ ];
187
+ document.getElementById('statsGrid').innerHTML = items.map(([k, label]) =>
188
+ `<div class="stat-card"><div class="num">${d[k] != null ? d[k].toLocaleString() : '—'}</div><div class="label">${label}</div></div>`
189
+ ).join('');
190
+ } catch (e) {}
191
+ }
192
+
193
+ function updateDaemon(running) {
194
+ const dot = document.getElementById('daemonDot');
195
+ const text = document.getElementById('daemonText');
196
+ const sub = document.getElementById('daemonSub');
197
+ const startBtn = document.getElementById('daemonStart');
198
+ const stopBtn = document.getElementById('daemonStop');
199
+ if (running) {
200
+ dot.classList.add('running');
201
+ text.innerHTML = 'Daemon Running <small>24/7 pipeline active</small>';
202
+ startBtn.style.display = 'none';
203
+ stopBtn.style.display = 'inline-block';
204
+ } else {
205
+ dot.classList.remove('running');
206
+ text.innerHTML = 'Daemon Stopped <small>Click Start to run 24/7</small>';
207
+ startBtn.style.display = 'inline-block';
208
+ stopBtn.style.display = 'none';
209
+ }
210
+ }
211
+
212
+ function renderTasks(tasks) {
213
+ document.getElementById('taskCount').textContent = `${tasks.length} tasks`;
214
+ document.getElementById('taskRows').innerHTML = tasks.map(t => {
215
+ const iconClass = t.running ? 'running' : (t.is_due ? 'due' : 'not-due');
216
+ const pillClass = t.running ? 'running' : (t.is_due ? 'due' : 'ok');
217
+ const pillText = t.running ? 'RUNNING' : (t.is_due ? 'DUE' : 'OK');
218
+ const lastRun = t.last_run ? new Date(t.last_run).toLocaleString('en-US', {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}) : 'Never';
219
+ const nextIn = t.next_in_hours != null ? (t.next_in_hours <= 0 ? 'Now' : `${t.next_in_hours}h`) : '—';
220
+ return `<div class="task-row">
221
+ <div class="task-name"><span class="icon ${iconClass}"></span>${t.label}</div>
222
+ <div>${t.interval_hours}h</div>
223
+ <div style="color:var(--muted);font-size:13px">${lastRun}</div>
224
+ <div>${nextIn}</div>
225
+ <div><span class="status-pill ${pillClass}">${pillText}</span></div>
226
+ <div><button class="btn btn-sm btn-trigger" onclick="triggerTask('${t.task}')" ${t.running ? 'disabled' : ''}>Run</button></div>
227
+ </div>`;
228
+ }).join('');
229
+ }
230
+
231
+ function renderLogs(logs) {
232
+ if (!logs || !logs.length) {
233
+ document.getElementById('logFeed').innerHTML = '<div style="color:var(--muted)">No logs yet</div>';
234
+ return;
235
+ }
236
+ document.getElementById('logFeed').innerHTML = logs.reverse().map(l => {
237
+ const ts = l.timestamp ? new Date(l.timestamp).toLocaleTimeString('en-US', {hour12:false}) : '—';
238
+ const comp = l.component || '—';
239
+ const status = l.status || '';
240
+ const statusClass = status === 'success' ? 'success' : status === 'failed' ? 'failed' : status === 'started' ? 'started' : status === 'stopped' ? 'stopped' : '';
241
+ return `<div class="log-entry"><span class="ts">${ts}</span><span class="comp">${comp}</span><span class="status ${statusClass}">${status}</span></div>`;
242
+ }).join('');
243
+ }
244
+
245
+ async function triggerTask(task) {
246
+ const btn = event?.target;
247
+ if (btn) { btn.disabled = true; btn.textContent = 'Running...'; }
248
+ try {
249
+ await fetch(`${API}/trigger/${task}`, { method: 'POST' });
250
+ setTimeout(() => { fetchStatus(); if (btn) { btn.disabled = false; btn.textContent = 'Run'; } }, 1000);
251
+ } catch (e) {
252
+ if (btn) { btn.disabled = false; btn.textContent = 'Run'; }
253
+ }
254
+ }
255
+
256
+ async function daemonAction(action) {
257
+ try {
258
+ await fetch(`${API}/daemon/${action}`, { method: 'POST' });
259
+ setTimeout(fetchStatus, 2000);
260
+ } catch (e) {}
261
+ }
262
+
263
+ // Auto-refresh
264
+ fetchStatus();
265
+ fetchStats();
266
+ setInterval(fetchStatus, 5000);
267
+ setInterval(fetchStats, 30000);
268
+ </script>
269
+
270
+ </body>
271
+ </html>
templates/dashboard.html ADDED
@@ -0,0 +1,517 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>MasseurBoost Dashboard</title>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap"
10
+ rel="stylesheet">
11
+ <style>
12
+ * {
13
+ font-family: 'Inter', sans-serif;
14
+ }
15
+
16
+ .gradient-bg {
17
+ background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
18
+ }
19
+
20
+ .glow {
21
+ box-shadow: 0 0 30px rgba(139, 92, 246, 0.2);
22
+ }
23
+
24
+ .card {
25
+ background: rgba(255, 255, 255, 0.03);
26
+ border: 1px solid rgba(255, 255, 255, 0.08);
27
+ border-radius: 1.5rem;
28
+ }
29
+
30
+ .card-hover {
31
+ transition: all 0.3s;
32
+ }
33
+
34
+ .card-hover:hover {
35
+ transform: translateY(-4px);
36
+ border-color: rgba(139, 92, 246, 0.3);
37
+ }
38
+
39
+ .pulse-dot {
40
+ animation: pulse-dot 2s infinite;
41
+ }
42
+
43
+ @keyframes pulse-dot {
44
+
45
+ 0%,
46
+ 100% {
47
+ opacity: 1;
48
+ }
49
+
50
+ 50% {
51
+ opacity: 0.3;
52
+ }
53
+ }
54
+
55
+ .tab-active {
56
+ background: linear-gradient(135deg, #8b5cf6, #d946ef);
57
+ }
58
+
59
+ .progress-bar {
60
+ background: linear-gradient(90deg, #8b5cf6, #d946ef, #ec4899);
61
+ }
62
+
63
+ .spin {
64
+ animation: spin 2s linear infinite;
65
+ }
66
+
67
+ @keyframes spin {
68
+ to {
69
+ transform: rotate(360deg);
70
+ }
71
+ }
72
+ </style>
73
+ </head>
74
+
75
+ <body class="bg-[#0a0a0f] text-white min-h-screen">
76
+
77
+ <!-- Top bar -->
78
+ <div class="fixed top-0 w-full z-50 bg-[#0a0a0f]/80 backdrop-blur-lg border-b border-white/5">
79
+ <div class="max-w-7xl mx-auto px-6 py-3 flex items-center justify-between">
80
+ <div class="flex items-center gap-3">
81
+ <a href="/" class="flex items-center gap-2">
82
+ <div
83
+ class="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-500 to-fuchsia-500 flex items-center justify-center font-black">
84
+ M</div>
85
+ <span class="font-bold">Masseur<span class="text-violet-400">Boost</span></span>
86
+ </a>
87
+ <span class="text-gray-600">/</span>
88
+ <span class="text-gray-400 text-sm">Dashboard</span>
89
+ </div>
90
+ <div class="flex items-center gap-4">
91
+ <div class="flex items-center gap-2 text-sm">
92
+ <span class="w-2 h-2 rounded-full bg-green-400 pulse-dot"></span>
93
+ <span class="text-gray-400">Engine: Active</span>
94
+ </div>
95
+ <div class="text-sm text-gray-500">Cycle: <span id="cycle-time" class="text-violet-400">3h</span></div>
96
+ </div>
97
+ </div>
98
+ </div>
99
+
100
+ <!-- Main -->
101
+ <div class="pt-20 max-w-7xl mx-auto px-6 py-8">
102
+
103
+ <!-- Header -->
104
+ <div class="mb-8">
105
+ <h1 class="text-3xl font-black mb-2">Optimization Dashboard</h1>
106
+ <p class="text-gray-500">Real-time view of your profile's AI optimization engine</p>
107
+ </div>
108
+
109
+ <!-- Stats row -->
110
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8" id="stats-row">
111
+ <div class="card p-6">
112
+ <div class="text-sm text-gray-500 mb-1">Market Size</div>
113
+ <div class="text-3xl font-black text-violet-400" id="stat-total">—</div>
114
+ <div class="text-xs text-gray-600 mt-1">masseurs tracked</div>
115
+ </div>
116
+ <div class="card p-6">
117
+ <div class="text-sm text-gray-500 mb-1">Cities</div>
118
+ <div class="text-3xl font-black text-fuchsia-400" id="stat-cities">—</div>
119
+ <div class="text-xs text-gray-600 mt-1">worldwide</div>
120
+ </div>
121
+ <div class="card p-6">
122
+ <div class="text-sm text-gray-500 mb-1">Avg Views/Day</div>
123
+ <div class="text-3xl font-black text-pink-400" id="stat-avgvpd">—</div>
124
+ <div class="text-xs text-gray-600 mt-1">market average</div>
125
+ </div>
126
+ <div class="card p-6">
127
+ <div class="text-sm text-gray-500 mb-1">Gold Members</div>
128
+ <div class="text-3xl font-black text-amber-400" id="stat-gold">—</div>
129
+ <div class="text-xs text-gray-600 mt-1">of total</div>
130
+ </div>
131
+ </div>
132
+
133
+ <!-- Tabs -->
134
+ <div class="flex gap-2 mb-8 overflow-x-auto">
135
+ <button onclick="switchTab('analyze')" id="tab-analyze"
136
+ class="tab-active px-5 py-2.5 rounded-full text-sm font-semibold whitespace-nowrap">🔍 Profile
137
+ Analysis</button>
138
+ <button onclick="switchTab('bio')" id="tab-bio"
139
+ class="px-5 py-2.5 rounded-full text-sm font-semibold whitespace-nowrap bg-white/5 border border-white/10 hover:bg-white/10 transition">🧬
140
+ Bio Optimizer</button>
141
+ <button onclick="switchTab('blog')" id="tab-blog"
142
+ class="px-5 py-2.5 rounded-full text-sm font-semibold whitespace-nowrap bg-white/5 border border-white/10 hover:bg-white/10 transition">✍️
143
+ Blog Generator</button>
144
+ <button onclick="switchTab('photo')" id="tab-photo"
145
+ class="px-5 py-2.5 rounded-full text-sm font-semibold whitespace-nowrap bg-white/5 border border-white/10 hover:bg-white/10 transition">📸
146
+ Photo Rotation</button>
147
+ <button onclick="switchTab('gravity')" id="tab-gravity"
148
+ class="px-5 py-2.5 rounded-full text-sm font-semibold whitespace-nowrap bg-white/5 border border-white/10 hover:bg-white/10 transition">🌍
149
+ Availability Gravity</button>
150
+ <button onclick="switchTab('engine')" id="tab-engine"
151
+ class="px-5 py-2.5 rounded-full text-sm font-semibold whitespace-nowrap bg-white/5 border border-white/10 hover:bg-white/10 transition">⚙️
152
+ Engine Status</button>
153
+ <button onclick="switchTab('search')" id="tab-search"
154
+ class="px-5 py-2.5 rounded-full text-sm font-semibold whitespace-nowrap bg-white/5 border border-white/10 hover:bg-white/10 transition">🔎
155
+ Browse Market</button>
156
+ </div>
157
+
158
+ <!-- Tab: Analyze -->
159
+ <div id="panel-analyze" class="tab-panel">
160
+ <div class="card p-8 mb-6">
161
+ <h2 class="text-2xl font-bold mb-4">Analyze Your Profile</h2>
162
+ <p class="text-gray-400 mb-6">Enter your RentMasseur username to see how you rank against all 2,738
163
+ masseurs worldwide.</p>
164
+ <div class="flex gap-3 mb-6">
165
+ <input id="analyze-username" type="text" placeholder="Your RentMasseur username"
166
+ class="flex-1 px-5 py-3 rounded-xl bg-white/5 border border-white/10 focus:border-violet-500 outline-none text-white">
167
+ <button onclick="analyzeProfile()"
168
+ class="px-6 py-3 rounded-xl bg-gradient-to-r from-violet-500 to-fuchsia-500 font-semibold hover:opacity-90 transition">Analyze</button>
169
+ </div>
170
+ </div>
171
+ <div id="analyze-results" class="hidden"></div>
172
+ </div>
173
+
174
+ <!-- Tab: Bio -->
175
+ <div id="panel-bio" class="tab-panel hidden">
176
+ <div class="card p-8 mb-6">
177
+ <h2 class="text-2xl font-bold mb-4">🧬 GA/RL Bio Optimizer</h2>
178
+ <p class="text-gray-400 mb-6">Generate 30 bio variants using Genetic Algorithm + LLM. Each variant is
179
+ scored on CTA strength, urgency, emotional hook, SEO, and uniqueness. The winner gets pushed to your
180
+ live profile.</p>
181
+ <div class="grid md:grid-cols-2 gap-6 mb-6">
182
+ <div>
183
+ <label class="text-sm text-gray-400 mb-2 block">Your username</label>
184
+ <input id="bio-username" type="text" placeholder="e.g. Karpathianwolf"
185
+ class="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 focus:border-violet-500 outline-none">
186
+ </div>
187
+ <div>
188
+ <label class="text-sm text-gray-400 mb-2 block">Bio style</label>
189
+ <select id="bio-style"
190
+ class="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 focus:border-violet-500 outline-none">
191
+ <option value="balanced">Balanced (recommended)</option>
192
+ <option value="funny">Funny & Engaging</option>
193
+ <option value="luxury">Luxury / Concierge</option>
194
+ <option value="clinical">Clinical / Professional</option>
195
+ <option value="mysterious">Mysterious & Alluring</option>
196
+ </select>
197
+ </div>
198
+ </div>
199
+ <button onclick="generateBio()"
200
+ class="px-8 py-3 rounded-xl bg-gradient-to-r from-violet-500 to-fuchsia-500 font-semibold hover:opacity-90 transition">Generate
201
+ Optimized Bio</button>
202
+ </div>
203
+ <div id="bio-results" class="hidden"></div>
204
+ </div>
205
+
206
+ <!-- Tab: Blog -->
207
+ <div id="panel-blog" class="tab-panel hidden">
208
+ <div class="card p-8 mb-6">
209
+ <h2 class="text-2xl font-bold mb-4">✍️ LLM Blog Generator</h2>
210
+ <p class="text-gray-400 mb-6">SEO-optimized blog posts generated from winning competitor patterns.
211
+ Funny, engaging, and engineered to drive phone calls and bookings.</p>
212
+ <div id="blog-topics" class="grid md:grid-cols-2 gap-4"></div>
213
+ </div>
214
+ <div id="blog-preview" class="hidden"></div>
215
+ </div>
216
+
217
+ <!-- Tab: Photo -->
218
+ <div id="panel-photo" class="tab-panel hidden">
219
+ <div class="card p-8">
220
+ <h2 class="text-2xl font-bold mb-4">📸 Organism Photo Rotation</h2>
221
+ <p class="text-gray-400 mb-6">Your photos rotate based on time of day, day of week, and engagement data.
222
+ The profile breathes like a living organism.</p>
223
+ <div id="photo-strategy" class="space-y-4"></div>
224
+ </div>
225
+ </div>
226
+
227
+ <!-- Tab: Gravity -->
228
+ <div id="panel-gravity" class="tab-panel hidden">
229
+ <div class="card p-8">
230
+ <h2 class="text-2xl font-bold mb-4">🌍 Availability Gravity Engine</h2>
231
+ <p class="text-gray-400 mb-6">Dynamic availability windows that create urgency and drive repeat visits.
232
+ RL-optimized for maximum booking conversion.</p>
233
+ <div id="gravity-tactics" class="space-y-4"></div>
234
+ </div>
235
+ </div>
236
+
237
+ <!-- Tab: Engine -->
238
+ <div id="panel-engine" class="tab-panel hidden">
239
+ <div class="card p-8">
240
+ <h2 class="text-2xl font-bold mb-4">⚙️ Optimization Engine</h2>
241
+ <p class="text-gray-400 mb-6">The full GA + RL + LLM pipeline running 24/7.</p>
242
+ <div id="engine-components" class="space-y-3"></div>
243
+ </div>
244
+ </div>
245
+
246
+ <!-- Tab: Search -->
247
+ <div id="panel-search" class="tab-panel hidden">
248
+ <div class="card p-8 mb-6">
249
+ <h2 class="text-2xl font-bold mb-4">🔎 Browse the Market</h2>
250
+ <p class="text-gray-400 mb-6">Search and rank all 2,738 masseurs by views/day, visits, reviews, and
251
+ more.</p>
252
+ <div class="flex flex-wrap gap-3 mb-4">
253
+ <input id="search-city" type="text"
254
+ placeholder="Username or city (e.g. karpathianwolf or manhattan-ny)"
255
+ class="px-4 py-2 rounded-xl bg-white/5 border border-white/10 focus:border-violet-500 outline-none text-sm">
256
+ <select id="search-sort"
257
+ class="px-4 py-2 rounded-xl bg-white/5 border border-white/10 focus:border-violet-500 outline-none text-sm">
258
+ <option value="views_per_day">Views/Day</option>
259
+ <option value="visits">Total Visits</option>
260
+ <option value="reviews_count">Reviews</option>
261
+ <option value="rating">Rating</option>
262
+ </select>
263
+ <button onclick="searchMasseurs()"
264
+ class="px-6 py-2 rounded-xl bg-gradient-to-r from-violet-500 to-fuchsia-500 font-semibold text-sm hover:opacity-90 transition">Search</button>
265
+ </div>
266
+ </div>
267
+ <div id="search-results"></div>
268
+ </div>
269
+ </div>
270
+
271
+ <script>
272
+ // ─── Tab switching ─────────────────────────────────
273
+ function switchTab(name) {
274
+ document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden'));
275
+ document.getElementById('panel-' + name).classList.remove('hidden');
276
+ document.querySelectorAll('[id^="tab-"]').forEach(t => {
277
+ t.classList.remove('tab-active');
278
+ t.classList.add('bg-white/5', 'border', 'border-white/10');
279
+ });
280
+ const tab = document.getElementById('tab-' + name);
281
+ tab.classList.add('tab-active');
282
+ tab.classList.remove('bg-white/5', 'border', 'border-white/10');
283
+ }
284
+
285
+ // ─── Load market stats ─────────────────────────────
286
+ fetch('/api/market-stats').then(r => r.json()).then(d => {
287
+ document.getElementById('stat-total').textContent = d.total_masseurs.toLocaleString();
288
+ document.getElementById('stat-cities').textContent = d.cities_covered;
289
+ document.getElementById('stat-avgvpd').textContent = d.avg_views_per_day;
290
+ document.getElementById('stat-gold').textContent = d.gold_members.toLocaleString();
291
+ });
292
+
293
+ // ─── Analyze profile ───────────────────────────────
294
+ function analyzeProfile() {
295
+ const username = document.getElementById('analyze-username').value.trim();
296
+ if (!username) return;
297
+ const results = document.getElementById('analyze-results');
298
+ results.classList.remove('hidden');
299
+ results.innerHTML = '<div class="card p-8 text-center"><div class="spin w-8 h-8 border-4 border-violet-500 border-t-transparent rounded-full mx-auto mb-4"></div>Analyzing ' + username + '...</div>';
300
+ fetch('/api/analyze/' + username).then(r => r.json()).then(d => {
301
+ if (d.error) {
302
+ results.innerHTML = '<div class="card p-8 text-center text-gray-400">' + d.error + '</div>';
303
+ return;
304
+ }
305
+ const percentile = d.percentile;
306
+ const recs = d.recommendations.map(r => `<li class="flex items-start gap-3 text-gray-300"><span class="text-violet-400 mt-1">→</span> ${r}</li>`).join('');
307
+ results.innerHTML = `
308
+ <div class="grid md:grid-cols-2 gap-6 mb-6">
309
+ <div class="card p-8">
310
+ <div class="text-sm text-gray-500 mb-2">Your Rank</div>
311
+ <div class="text-5xl font-black text-violet-400">#${d.rank}</div>
312
+ <div class="text-gray-500 mt-2">out of ${d.total} masseurs worldwide</div>
313
+ <div class="mt-4">
314
+ <div class="flex justify-between text-sm mb-1"><span class="text-gray-500">Percentile</span><span class="text-violet-400 font-bold">${percentile}%</span></div>
315
+ <div class="h-3 rounded-full bg-white/5 overflow-hidden"><div class="h-full progress-bar" style="width:${percentile}%"></div></div>
316
+ </div>
317
+ </div>
318
+ <div class="card p-8">
319
+ <div class="grid grid-cols-2 gap-4">
320
+ <div><div class="text-sm text-gray-500">Views/Day</div><div class="text-2xl font-bold text-fuchsia-400">${d.views_per_day}</div></div>
321
+ <div><div class="text-sm text-gray-500">Total Visits</div><div class="text-2xl font-bold text-pink-400">${d.total_visits.toLocaleString()}</div></div>
322
+ <div><div class="text-sm text-gray-500">Member Since</div><div class="text-lg font-bold">${d.member_since}</div></div>
323
+ <div><div class="text-sm text-gray-500">Reviews</div><div class="text-2xl font-bold text-amber-400">${d.reviews_count}</div></div>
324
+ <div><div class="text-sm text-gray-500">Rating</div><div class="text-2xl font-bold text-green-400">${d.rating}★</div></div>
325
+ <div><div class="text-sm text-gray-500">Photos</div><div class="text-2xl font-bold">${d.photo_count}</div></div>
326
+ <div><div class="text-sm text-gray-500">Bio Length</div><div class="text-lg font-bold ${d.bio_length > d.top_avg_bio_length + 200 ? 'text-red-400' : 'text-green-400'}">${d.bio_length} chars</div></div>
327
+ <div><div class="text-sm text-gray-500">Gold</div><div class="text-lg font-bold ${d.is_gold ? 'text-amber-400' : 'text-gray-600'}">${d.is_gold ? 'Yes' : 'No'}</div></div>
328
+ </div>
329
+ </div>
330
+ </div>
331
+ <div class="card p-8 mb-6">
332
+ <h3 class="text-xl font-bold mb-4">Services</h3>
333
+ <div class="flex flex-wrap gap-2">
334
+ ${d.services.map(s => `<span class="px-4 py-2 rounded-full bg-violet-500/10 text-violet-300 text-sm">${s}</span>`).join('')}
335
+ </div>
336
+ </div>
337
+ <div class="card p-8">
338
+ <h3 class="text-xl font-bold mb-4">AI Recommendations</h3>
339
+ ${recs ? `<ul class="space-y-3">${recs}</ul>` : '<p class="text-green-400">Your profile is well-optimized! No critical issues found.</p>'}
340
+ <div class="mt-6 pt-6 border-t border-white/10">
341
+ <div class="text-sm text-gray-500 mb-2">Top performer benchmark:</div>
342
+ <div class="flex gap-6 text-sm">
343
+ <span>Avg bio length: <strong class="text-violet-400">${d.top_avg_bio_length} chars</strong></span>
344
+ <span>Avg reviews: <strong class="text-violet-400">${d.top_avg_reviews}</strong></span>
345
+ </div>
346
+ </div>
347
+ </div>
348
+ `;
349
+ });
350
+ }
351
+
352
+ // ─── Generate bio ──────────────────────────────────
353
+ function generateBio() {
354
+ const username = document.getElementById('bio-username').value.trim();
355
+ const style = document.getElementById('bio-style').value;
356
+ if (!username) return;
357
+ const results = document.getElementById('bio-results');
358
+ results.classList.remove('hidden');
359
+ results.innerHTML = '<div class="card p-8 text-center"><div class="spin w-8 h-8 border-4 border-violet-500 border-t-transparent rounded-full mx-auto mb-4"></div>Generating 30 variants...</div>';
360
+ fetch('/api/generate-bio', {
361
+ method: 'POST',
362
+ headers: { 'Content-Type': 'application/json' },
363
+ body: JSON.stringify({ username, style })
364
+ }).then(r => r.json()).then(d => {
365
+ results.innerHTML = `
366
+ <div class="card p-8 mb-6">
367
+ <h3 class="text-xl font-bold mb-2">Generated Bio — ${style} style</h3>
368
+ <div class="text-xs text-gray-500 mb-4">Analyzed ${d.competitor_patterns_analyzed} top competitor patterns</div>
369
+ <div class="bg-white/5 rounded-xl p-6 border border-white/10">
370
+ <p class="text-gray-300 leading-relaxed">${d.preview}</p>
371
+ </div>
372
+ <div class="mt-6 flex gap-3">
373
+ <button class="px-6 py-2 rounded-xl bg-gradient-to-r from-violet-500 to-fuchsia-500 font-semibold text-sm hover:opacity-90 transition">Push to Profile</button>
374
+ <button onclick="generateBio()" class="px-6 py-2 rounded-xl border border-white/20 font-semibold text-sm hover:bg-white/5 transition">Regenerate</button>
375
+ </div>
376
+ </div>
377
+ <div class="card p-8">
378
+ <h3 class="text-lg font-bold mb-4">Top Competitor Patterns Detected</h3>
379
+ <div class="space-y-3">
380
+ ${d.top_patterns.map(p => `<div class="flex items-start gap-3"><span class="text-violet-400 text-sm font-bold shrink-0">@${p.username}</span><span class="text-gray-400 text-sm">${p.bio_preview}...</span><span class="text-xs text-gray-600 shrink-0">${p.vpd}/day</span></div>`).join('')}
381
+ </div>
382
+ </div>
383
+ `;
384
+ });
385
+ }
386
+
387
+ // ─── Blog topics ───────────────────────────────────
388
+ fetch('/api/blog/topics').then(r => r.json()).then(d => {
389
+ const container = document.getElementById('blog-topics');
390
+ d.topics.forEach(t => {
391
+ container.innerHTML += `
392
+ <div class="card card-hover p-6 cursor-pointer" onclick="this.querySelector('.blog-angle').classList.toggle('hidden')">
393
+ <h4 class="font-bold mb-1">${t.title}</h4>
394
+ <p class="text-sm text-gray-500 blog-angle hidden mt-2">${t.angle}</p>
395
+ <div class="mt-3 text-xs text-violet-400">Click to expand →</div>
396
+ </div>
397
+ `;
398
+ });
399
+ });
400
+
401
+ // ─── Photo strategy ────────────────────────────────
402
+ fetch('/api/photo-strategy').then(r => r.json()).then(d => {
403
+ const container = document.getElementById('photo-strategy');
404
+ container.innerHTML = `
405
+ <div class="mb-6 p-4 rounded-xl bg-violet-500/10 border border-violet-500/20">
406
+ <p class="text-gray-300">${d.description}</p>
407
+ <p class="text-sm text-violet-300 mt-2">Rotation: ${d.rotation_frequency} · AI: ${d.ai_optimization}</p>
408
+ </div>
409
+ ${d.schedule.map(s => `
410
+ <div class="flex items-center gap-4 p-4 rounded-xl bg-white/5 border border-white/10">
411
+ <div class="w-12 h-12 rounded-xl bg-gradient-to-br from-violet-500/20 to-fuchsia-500/20 flex items-center justify-center text-xl shrink-0">📸</div>
412
+ <div class="flex-1">
413
+ <div class="font-semibold">${s.time}</div>
414
+ <div class="text-sm text-gray-400">${s.type}</div>
415
+ <div class="text-xs text-gray-600 mt-1">${s.reason}</div>
416
+ </div>
417
+ </div>
418
+ `).join('')}
419
+ `;
420
+ });
421
+
422
+ // ─── Availability gravity ──────────────────────────
423
+ fetch('/api/availability-gravity').then(r => r.json()).then(d => {
424
+ const container = document.getElementById('gravity-tactics');
425
+ container.innerHTML = `
426
+ <div class="mb-6 p-4 rounded-xl bg-cyan-500/10 border border-cyan-500/20">
427
+ <p class="text-gray-300">${d.description}</p>
428
+ </div>
429
+ ${d.tactics.map(t => `
430
+ <div class="p-5 rounded-xl bg-white/5 border border-white/10">
431
+ <div class="flex items-center justify-between mb-2">
432
+ <h4 class="font-bold text-lg">${t.name}</h4>
433
+ <span class="px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-300 text-xs font-bold">${t.impact}</span>
434
+ </div>
435
+ <p class="text-gray-400 text-sm">${t.description}</p>
436
+ </div>
437
+ `).join('')}
438
+ <div class="mt-4 p-4 rounded-xl bg-gradient-to-r from-violet-500/10 to-fuchsia-500/10 border border-violet-500/20">
439
+ <p class="text-sm text-gray-300">🧠 <strong>RL Optimization:</strong> ${d.rl_optimization}</p>
440
+ </div>
441
+ `;
442
+ });
443
+
444
+ // ─── Engine status ─────────────────────────────────
445
+ fetch('/api/optimization-status').then(r => r.json()).then(d => {
446
+ const container = document.getElementById('engine-components');
447
+ container.innerHTML = `
448
+ <div class="mb-4 p-4 rounded-xl bg-gradient-to-r from-violet-500/10 to-fuchsia-500/10 border border-violet-500/20">
449
+ <p class="text-gray-300"><strong>Engine:</strong> ${d.engine}</p>
450
+ <p class="text-sm text-gray-500 mt-1">${d.cycle}</p>
451
+ <p class="text-sm text-violet-300 mt-1">📊 ${d.data_source}</p>
452
+ </div>
453
+ ${d.components.map(c => `
454
+ <div class="flex items-center gap-4 p-4 rounded-xl bg-white/5 border border-white/10">
455
+ <div class="w-3 h-3 rounded-full bg-green-400 pulse-dot shrink-0"></div>
456
+ <div class="flex-1">
457
+ <div class="font-semibold">${c.name}</div>
458
+ <div class="text-sm text-gray-400">${c.description}</div>
459
+ </div>
460
+ <span class="text-xs text-green-400 font-bold uppercase">${c.status}</span>
461
+ </div>
462
+ `).join('')}
463
+ `;
464
+ });
465
+
466
+ // ─── Search ────────────────────────────────────────
467
+ function searchMasseurs() {
468
+ const city = document.getElementById('search-city').value.trim();
469
+ const sort = document.getElementById('search-sort').value;
470
+ const params = new URLSearchParams({ sort, limit: 50 });
471
+ if (city) params.set('q', city);
472
+ const container = document.getElementById('search-results');
473
+ container.innerHTML = '<div class="card p-8 text-center"><div class="spin w-8 h-8 border-4 border-violet-500 border-t-transparent rounded-full mx-auto"></div></div>';
474
+ fetch('/api/search?' + params).then(r => r.json()).then(d => {
475
+ container.innerHTML = `
476
+ <div class="text-sm text-gray-500 mb-4">${d.total} results</div>
477
+ <div class="card overflow-hidden">
478
+ <table class="w-full text-sm">
479
+ <thead class="bg-white/5 text-gray-400">
480
+ <tr>
481
+ <th class="text-left p-3">#</th>
482
+ <th class="text-left p-3">Username</th>
483
+ <th class="text-left p-3">City</th>
484
+ <th class="text-right p-3">Views/Day</th>
485
+ <th class="text-right p-3">Visits</th>
486
+ <th class="text-right p-3">Reviews</th>
487
+ <th class="text-center p-3">Gold</th>
488
+ </tr>
489
+ </thead>
490
+ <tbody>
491
+ ${d.results.map((r, i) => `
492
+ <tr class="border-t border-white/5 hover:bg-white/5">
493
+ <td class="p-3 text-gray-500">${i + 1}</td>
494
+ <td class="p-3 font-semibold text-violet-400">
495
+ <a href="/profile/${r.username}" class="hover:underline">@${r.username}</a>
496
+ <a href="${r.profile_url}" target="_blank" class="ml-2 text-xs text-fuchsia-400 hover:text-fuchsia-300">↗ rentmasseur.com/${r.username}</a>
497
+ </td>
498
+ <td class="p-3 text-gray-400">${r.city}</td>
499
+ <td class="p-3 text-right font-bold text-fuchsia-400">${r.vpd}</td>
500
+ <td class="p-3 text-right text-gray-400">${r.visits.toLocaleString()}</td>
501
+ <td class="p-3 text-right text-gray-400">${r.reviews}</td>
502
+ <td class="p-3 text-center">${r.gold ? '⭐' : '—'}</td>
503
+ </tr>
504
+ `).join('')}
505
+ </tbody>
506
+ </table>
507
+ </div>
508
+ `;
509
+ });
510
+ }
511
+
512
+ // Auto-load search on tab open
513
+ searchMasseurs();
514
+ </script>
515
+ </body>
516
+
517
+ </html>
templates/landing.html ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>MasseurBoost — AI-Powered Profile Optimization for RentMasseur</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
9
+ <style>
10
+ * { font-family: 'Inter', sans-serif; }
11
+ .gradient-bg { background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%); }
12
+ .glow { box-shadow: 0 0 40px rgba(139, 92, 246, 0.3); }
13
+ .animate-float { animation: float 6s ease-in-out infinite; }
14
+ @keyframes float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-20px); } }
15
+ .card-hover { transition: all 0.3s ease; }
16
+ .card-hover:hover { transform: translateY(-8px); box-shadow: 0 20px 60px rgba(139,92,246,0.2); }
17
+ .pulse-dot { animation: pulse-dot 2s infinite; }
18
+ @keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
19
+ </style>
20
+ </head>
21
+ <body class="bg-[#0a0a0f] text-white">
22
+
23
+ <!-- Nav -->
24
+ <nav class="fixed top-0 w-full z-50 bg-[#0a0a0f]/80 backdrop-blur-lg border-b border-white/5">
25
+ <div class="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
26
+ <div class="flex items-center gap-2">
27
+ <div class="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500 to-fuchsia-500 flex items-center justify-center text-xl font-black">M</div>
28
+ <span class="text-xl font-bold">Masseur<span class="text-violet-400">Boost</span></span>
29
+ </div>
30
+ <div class="hidden md:flex items-center gap-8 text-sm text-gray-400">
31
+ <a href="#features" class="hover:text-white transition">Features</a>
32
+ <a href="#how" class="hover:text-white transition">How It Works</a>
33
+ <a href="#pricing" class="hover:text-white transition">Pricing</a>
34
+ <a href="/dashboard" class="hover:text-white transition">Dashboard</a>
35
+ </div>
36
+ <a href="/dashboard" class="px-5 py-2 rounded-full bg-gradient-to-r from-violet-500 to-fuchsia-500 text-sm font-semibold hover:opacity-90 transition">Get Started</a>
37
+ </div>
38
+ </nav>
39
+
40
+ <!-- Hero -->
41
+ <section class="gradient-bg pt-40 pb-32 relative overflow-hidden">
42
+ <div class="absolute inset-0 opacity-20" style="background-image: url('data:image/svg+xml,%3Csvg width=&quot;60&quot; height=&quot;60&quot; viewBox=&quot;0 0 60 60&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot;%3E%3Cg fill=&quot;none&quot; fill-rule=&quot;evenodd&quot;%3E%3Cg fill=&quot;%238b5cf6&quot; fill-opacity=&quot;0.1&quot;%3E%3Ccircle cx=&quot;30&quot; cy=&quot;30&quot; r=&quot;2&quot;/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')"></div>
43
+ <div class="max-w-5xl mx-auto px-6 text-center relative z-10">
44
+ <div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-violet-500/10 border border-violet-500/30 mb-8">
45
+ <span class="w-2 h-2 rounded-full bg-green-400 pulse-dot"></span>
46
+ <span class="text-sm text-violet-300">Now analyzing 2,738 masseurs across 252 cities</span>
47
+ </div>
48
+ <h1 class="text-5xl md:text-7xl font-black leading-tight mb-6">
49
+ Your profile isn't static.<br>
50
+ <span class="bg-gradient-to-r from-violet-400 via-fuchsia-400 to-pink-400 bg-clip-text text-transparent">It's a living organism.</span>
51
+ </h1>
52
+ <p class="text-xl text-gray-400 max-w-2xl mx-auto mb-10">
53
+ AI-powered bio optimization, blog generation, photo rotation, and availability gravity —
54
+ all working 24/7 to make your RentMasseur profile attract more clients, more views, more bookings.
55
+ </p>
56
+ <div class="flex items-center justify-center gap-4">
57
+ <a href="/dashboard" class="px-8 py-4 rounded-full bg-gradient-to-r from-violet-500 to-fuchsia-500 font-semibold text-lg hover:opacity-90 transition glow">Launch Dashboard</a>
58
+ <a href="#features" class="px-8 py-4 rounded-full border border-white/20 font-semibold text-lg hover:bg-white/5 transition">See Features</a>
59
+ </div>
60
+ <div class="mt-16 flex items-center justify-center gap-12 text-center">
61
+ <div>
62
+ <div class="text-4xl font-black text-violet-400">2,738</div>
63
+ <div class="text-sm text-gray-500">Profiles analyzed</div>
64
+ </div>
65
+ <div class="w-px h-12 bg-white/10"></div>
66
+ <div>
67
+ <div class="text-4xl font-black text-fuchsia-400">252</div>
68
+ <div class="text-sm text-gray-500">Cities worldwide</div>
69
+ </div>
70
+ <div class="w-px h-12 bg-white/10"></div>
71
+ <div>
72
+ <div class="text-4xl font-black text-pink-400">3h</div>
73
+ <div class="text-sm text-gray-500">Optimization cycle</div>
74
+ </div>
75
+ </div>
76
+ </div>
77
+ </section>
78
+
79
+ <!-- Features -->
80
+ <section id="features" class="py-32 bg-[#0a0a0f]">
81
+ <div class="max-w-7xl mx-auto px-6">
82
+ <div class="text-center mb-20">
83
+ <h2 class="text-4xl md:text-5xl font-black mb-4">Everything your profile needs to win</h2>
84
+ <p class="text-xl text-gray-500">Six AI engines working in parallel, 24/7</p>
85
+ </div>
86
+ <div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
87
+
88
+ <!-- Bio Optimizer -->
89
+ <div class="card-hover bg-gradient-to-br from-violet-500/10 to-transparent border border-violet-500/20 rounded-3xl p-8">
90
+ <div class="w-14 h-14 rounded-2xl bg-violet-500/20 flex items-center justify-center mb-6 text-2xl">🧬</div>
91
+ <h3 class="text-2xl font-bold mb-3">GA/RL Bio Optimizer</h3>
92
+ <p class="text-gray-400 mb-4">Genetic Algorithm evolves your bio through A/B testing. Reinforcement Learning tracks which versions drive bookings. LLM generates mutations from top-performing competitor patterns.</p>
93
+ <div class="flex flex-wrap gap-2">
94
+ <span class="px-3 py-1 rounded-full bg-violet-500/10 text-violet-300 text-xs">30 variants tested</span>
95
+ <span class="px-3 py-1 rounded-full bg-violet-500/10 text-violet-300 text-xs">Head-to-head scoring</span>
96
+ <span class="px-3 py-1 rounded-full bg-violet-500/10 text-violet-300 text-xs">Auto-push winner</span>
97
+ </div>
98
+ </div>
99
+
100
+ <!-- Blog Generator -->
101
+ <div class="card-hover bg-gradient-to-br from-fuchsia-500/10 to-transparent border border-fuchsia-500/20 rounded-3xl p-8">
102
+ <div class="w-14 h-14 rounded-2xl bg-fuchsia-500/20 flex items-center justify-center mb-6 text-2xl">✍️</div>
103
+ <h3 class="text-2xl font-bold mb-3">LLM Blog Generator</h3>
104
+ <p class="text-gray-400 mb-4">SEO-optimized blog posts generated from winning competitor patterns. Funny, engaging, and engineered to drive phone calls and bookings. Twice daily, automatically.</p>
105
+ <div class="flex flex-wrap gap-2">
106
+ <span class="px-3 py-1 rounded-full bg-fuchsia-500/10 text-fuchsia-300 text-xs">SEO keywords</span>
107
+ <span class="px-3 py-1 rounded-full bg-fuchsia-500/10 text-fuchsia-300 text-xs">Booking CTAs</span>
108
+ <span class="px-3 py-1 rounded-full bg-fuchsia-500/10 text-fuchsia-300 text-xs">Pattern-matched</span>
109
+ </div>
110
+ </div>
111
+
112
+ <!-- Photo Rotation -->
113
+ <div class="card-hover bg-gradient-to-br from-pink-500/10 to-transparent border border-pink-500/20 rounded-3xl p-8">
114
+ <div class="w-14 h-14 rounded-2xl bg-pink-500/20 flex items-center justify-center mb-6 text-2xl">📸</div>
115
+ <h3 class="text-2xl font-bold mb-3">Organism Photo Swap</h3>
116
+ <p class="text-gray-400 mb-4">Your photos rotate based on time of day, day of week, and engagement data. Morning browsers see professional shots. Evening visitors see lifestyle. Your profile breathes.</p>
117
+ <div class="flex flex-wrap gap-2">
118
+ <span class="px-3 py-1 rounded-full bg-pink-500/10 text-pink-300 text-xs">Time-of-day AI</span>
119
+ <span class="px-3 py-1 rounded-full bg-pink-500/10 text-pink-300 text-xs">GA-optimized order</span>
120
+ <span class="px-3 py-1 rounded-full bg-pink-500/10 text-pink-300 text-xs">6h rotation</span>
121
+ </div>
122
+ </div>
123
+
124
+ <!-- Availability Gravity -->
125
+ <div class="card-hover bg-gradient-to-br from-cyan-500/10 to-transparent border border-cyan-500/20 rounded-3xl p-8">
126
+ <div class="w-14 h-14 rounded-2xl bg-cyan-500/20 flex items-center justify-center mb-6 text-2xl">🌍</div>
127
+ <h3 class="text-2xl font-bold mb-3">Availability Gravity</h3>
128
+ <p class="text-gray-400 mb-4">Dynamic availability windows create urgency and drive repeat visits. "3 slots left this week" converts 40-60% better. RL optimizes which slots to show and when.</p>
129
+ <div class="flex flex-wrap gap-2">
130
+ <span class="px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-300 text-xs">Scarcity engine</span>
131
+ <span class="px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-300 text-xs">Peak hour alignment</span>
132
+ <span class="px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-300 text-xs">Revisit triggers</span>
133
+ </div>
134
+ </div>
135
+
136
+ <!-- Interview Rotator -->
137
+ <div class="card-hover bg-gradient-to-br from-amber-500/10 to-transparent border border-amber-500/20 rounded-3xl p-8">
138
+ <div class="w-14 h-14 rounded-2xl bg-amber-500/20 flex items-center justify-center mb-6 text-2xl">🎤</div>
139
+ <h3 class="text-2xl font-bold mb-3">Interview Q&A Engine</h3>
140
+ <p class="text-gray-400 mb-4">Funny, engaging interview Q&A that make clients want to call. LLM generates fresh angles daily — from origin stories to myth-busting to guilty pleasures. Each answer ends with a booking CTA.</p>
141
+ <div class="flex flex-wrap gap-2">
142
+ <span class="px-3 py-1 rounded-full bg-amber-500/10 text-amber-300 text-xs">20+ angles</span>
143
+ <span class="px-3 py-1 rounded-full bg-amber-500/10 text-amber-300 text-xs">Humor-driven</span>
144
+ <span class="px-3 py-1 rounded-full bg-amber-500/10 text-amber-300 text-xs">Daily rotation</span>
145
+ </div>
146
+ </div>
147
+
148
+ <!-- Price Optimizer -->
149
+ <div class="card-hover bg-gradient-to-br from-emerald-500/10 to-transparent border border-emerald-500/20 rounded-3xl p-8">
150
+ <div class="w-14 h-14 rounded-2xl bg-emerald-500/20 flex items-center justify-center mb-6 text-2xl">💰</div>
151
+ <h3 class="text-2xl font-bold mb-3">Price Optimization</h3>
152
+ <p class="text-gray-400 mb-4">GA tests price points against your competitor set to maximize revenue, not just bookings. Find the sweet spot where demand meets profit. RL tracks conversion at each price level.</p>
153
+ <div class="flex flex-wrap gap-2">
154
+ <span class="px-3 py-1 rounded-full bg-emerald-500/10 text-emerald-300 text-xs">Revenue-focused</span>
155
+ <span class="px-3 py-1 rounded-full bg-emerald-500/10 text-emerald-300 text-xs">Competitor-aware</span>
156
+ <span class="px-3 py-1 rounded-full bg-emerald-500/10 text-emerald-300 text-xs">RL-tracked</span>
157
+ </div>
158
+ </div>
159
+ </div>
160
+ </div>
161
+ </section>
162
+
163
+ <!-- How It Works -->
164
+ <section id="how" class="py-32 bg-gradient-to-b from-[#0a0a0f] to-[#111118]">
165
+ <div class="max-w-5xl mx-auto px-6">
166
+ <div class="text-center mb-20">
167
+ <h2 class="text-4xl md:text-5xl font-black mb-4">How the engine works</h2>
168
+ <p class="text-xl text-gray-500">A continuous loop — scrape, analyze, optimize, push, measure, repeat</p>
169
+ </div>
170
+ <div class="space-y-4">
171
+ <div class="flex items-center gap-6 bg-white/5 rounded-2xl p-6 border border-white/10">
172
+ <div class="w-12 h-12 rounded-full bg-violet-500 flex items-center justify-center font-bold text-lg shrink-0">1</div>
173
+ <div>
174
+ <h3 class="text-xl font-bold mb-1">Scrape all competitors</h3>
175
+ <p class="text-gray-400">Search API pulls every masseur in every city. Profile HTML gives us visits, member since, bio, services, photos, reviews. 2,738 profiles and counting.</p>
176
+ </div>
177
+ </div>
178
+ <div class="flex items-center gap-6 bg-white/5 rounded-2xl p-6 border border-white/10">
179
+ <div class="w-12 h-12 rounded-full bg-fuchsia-500 flex items-center justify-center font-bold text-lg shrink-0">2</div>
180
+ <div>
181
+ <h3 class="text-xl font-bold mb-1">Rank by views per day</h3>
182
+ <p class="text-gray-400">Compute views_per_day = total_visits / days_since_registration. Rank all 2,738 masseurs. Identify what top performers do differently.</p>
183
+ </div>
184
+ </div>
185
+ <div class="flex items-center gap-6 bg-white/5 rounded-2xl p-6 border border-white/10">
186
+ <div class="w-12 h-12 rounded-full bg-pink-500 flex items-center justify-center font-bold text-lg shrink-0">3</div>
187
+ <div>
188
+ <h3 class="text-xl font-bold mb-1">Generate 30 variants via LLM</h3>
189
+ <p class="text-gray-400">Groq LLM generates 30 bio variants across 10 strategies, informed by winning patterns. Each scored on CTA strength, urgency, emotional hook, SEO, uniqueness.</p>
190
+ </div>
191
+ </div>
192
+ <div class="flex items-center gap-6 bg-white/5 rounded-2xl p-6 border border-white/10">
193
+ <div class="w-12 h-12 rounded-full bg-cyan-500 flex items-center justify-center font-bold text-lg shrink-0">4</div>
194
+ <div>
195
+ <h3 class="text-xl font-bold mb-1">A/B test head-to-head</h3>
196
+ <p class="text-gray-400">Pairwise LLM comparison eliminates weak variants. Top 3 face off. Winner must beat current live bio to qualify for push.</p>
197
+ </div>
198
+ </div>
199
+ <div class="flex items-center gap-6 bg-white/5 rounded-2xl p-6 border border-white/10">
200
+ <div class="w-12 h-12 rounded-full bg-amber-500 flex items-center justify-center font-bold text-lg shrink-0">5</div>
201
+ <div>
202
+ <h3 class="text-xl font-bold mb-1">Push to live profile</h3>
203
+ <p class="text-gray-400">Winning bio, blog post, interview set, and photo order pushed via RentMasseur API. Receipts written for audit trail.</p>
204
+ </div>
205
+ </div>
206
+ <div class="flex items-center gap-6 bg-white/5 rounded-2xl p-6 border border-white/10">
207
+ <div class="w-12 h-12 rounded-full bg-emerald-500 flex items-center justify-center font-bold text-lg shrink-0">6</div>
208
+ <div>
209
+ <h3 class="text-xl font-bold mb-1">RL feedback loop</h3>
210
+ <p class="text-gray-400">Scrape dashboard stats: views, phone clicks, emails, bookings. Calculate reward. If engagement drops, trigger rotation. Repeat every 3 hours.</p>
211
+ </div>
212
+ </div>
213
+ </div>
214
+ </div>
215
+ </section>
216
+
217
+ <!-- Pricing -->
218
+ <section id="pricing" class="py-32 bg-[#0a0a0f]">
219
+ <div class="max-w-6xl mx-auto px-6">
220
+ <div class="text-center mb-20">
221
+ <h2 class="text-4xl md:text-5xl font-black mb-4">Pricing that scales with you</h2>
222
+ <p class="text-xl text-gray-500">From first optimization to full autonomy</p>
223
+ </div>
224
+ <div class="grid md:grid-cols-3 gap-8" id="pricing-cards">
225
+ <!-- Filled by JS -->
226
+ </div>
227
+ </div>
228
+ </section>
229
+
230
+ <!-- CTA -->
231
+ <section class="py-32 gradient-bg">
232
+ <div class="max-w-3xl mx-auto px-6 text-center">
233
+ <h2 class="text-4xl md:text-5xl font-black mb-6">Your competitors are already optimizing.</h2>
234
+ <p class="text-xl text-gray-400 mb-10">While you're reading this, top performers are running GA loops, testing bios, and rotating photos. Don't get left behind.</p>
235
+ <a href="/dashboard" class="inline-block px-10 py-5 rounded-full bg-gradient-to-r from-violet-500 to-fuchsia-500 font-bold text-xl hover:opacity-90 transition glow">Launch Your Dashboard →</a>
236
+ </div>
237
+ </section>
238
+
239
+ <!-- Footer -->
240
+ <footer class="py-12 bg-[#0a0a0f] border-t border-white/5">
241
+ <div class="max-w-7xl mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-4">
242
+ <div class="flex items-center gap-2">
243
+ <div class="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-500 to-fuchsia-500 flex items-center justify-center font-black">M</div>
244
+ <span class="font-bold">MasseurBoost</span>
245
+ </div>
246
+ <p class="text-sm text-gray-600">AI-powered profile optimization · 2,738 profiles · 252 cities · GA + RL + LLM</p>
247
+ </div>
248
+ </footer>
249
+
250
+ <script>
251
+ // Load pricing
252
+ fetch('/api/pricing').then(r => r.json()).then(data => {
253
+ const container = document.getElementById('pricing-cards');
254
+ data.plans.forEach(plan => {
255
+ const popular = plan.popular ? 'border-violet-500 glow' : 'border-white/10';
256
+ const badge = plan.popular ? '<div class="absolute -top-3 left-1/2 -translate-x-1/2 px-4 py-1 rounded-full bg-gradient-to-r from-violet-500 to-fuchsia-500 text-xs font-bold">MOST POPULAR</div>' : '';
257
+ container.innerHTML += `
258
+ <div class="relative bg-white/5 ${popular} border rounded-3xl p-8 card-hover">
259
+ ${badge}
260
+ <h3 class="text-2xl font-bold mb-2">${plan.name}</h3>
261
+ <div class="text-4xl font-black mb-6">${plan.price}</div>
262
+ <ul class="space-y-3">
263
+ ${plan.features.map(f => `<li class="flex items-start gap-3 text-gray-400"><span class="text-violet-400 mt-1">✓</span> ${f}</li>`).join('')}
264
+ </ul>
265
+ <a href="/dashboard" class="block mt-8 py-3 rounded-full text-center ${plan.popular ? 'bg-gradient-to-r from-violet-500 to-fuchsia-500' : 'border border-white/20 hover:bg-white/5'} font-semibold transition">Get Started</a>
266
+ </div>
267
+ `;
268
+ });
269
+ });
270
+ </script>
271
+ </body>
272
+ </html>
templates/profile.html ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>@{{ profile.username }} — MasseurBoost Profile</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
9
+ <style>
10
+ * { font-family: 'Inter', sans-serif; }
11
+ .gradient-bg { background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%); }
12
+ .card { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 1.5rem; }
13
+ .progress-bar { background: linear-gradient(90deg, #8b5cf6, #d946ef, #ec4899); }
14
+ .pulse-dot { animation: pulse-dot 2s infinite; }
15
+ @keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
16
+ </style>
17
+ </head>
18
+ <body class="bg-[#0a0a0f] text-white min-h-screen">
19
+
20
+ <!-- Top bar -->
21
+ <div class="fixed top-0 w-full z-50 bg-[#0a0a0f]/80 backdrop-blur-lg border-b border-white/5">
22
+ <div class="max-w-5xl mx-auto px-6 py-3 flex items-center justify-between">
23
+ <div class="flex items-center gap-3">
24
+ <a href="/dashboard" class="flex items-center gap-2">
25
+ <div class="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-500 to-fuchsia-500 flex items-center justify-center font-black">M</div>
26
+ <span class="font-bold">Masseur<span class="text-violet-400">Boost</span></span>
27
+ </a>
28
+ <span class="text-gray-600">/</span>
29
+ <span class="text-gray-400 text-sm">Profile</span>
30
+ </div>
31
+ <a href="{{ rentmasseur_url }}" target="_blank" class="text-sm text-violet-400 hover:text-violet-300 transition">View on RentMasseur ↗</a>
32
+ </div>
33
+ </div>
34
+
35
+ {% if error %}
36
+ <div class="pt-24 max-w-2xl mx-auto px-6 text-center">
37
+ <h1 class="text-3xl font-black mb-4">Profile not found</h1>
38
+ <p class="text-gray-500">@{{ username }} is not in the database (3,374+ profiles scraped).</p>
39
+ <a href="/dashboard" class="inline-block mt-6 px-6 py-3 rounded-xl bg-gradient-to-r from-violet-500 to-fuchsia-500 font-semibold">← Back to Dashboard</a>
40
+ </div>
41
+ {% else %}
42
+
43
+ <div class="pt-20 max-w-5xl mx-auto px-6 py-8">
44
+
45
+ <!-- Profile header -->
46
+ <div class="card p-8 mb-6">
47
+ <div class="flex items-start justify-between flex-wrap gap-4">
48
+ <div>
49
+ <div class="flex items-center gap-3 mb-2">
50
+ <h1 class="text-3xl font-black">@{{ profile.username }}</h1>
51
+ {% if profile.is_gold %}<span class="px-3 py-1 rounded-full bg-amber-500/10 text-amber-400 text-xs font-bold">⭐ GOLD</span>{% endif %}
52
+ {% if profile.is_online %}<span class="px-3 py-1 rounded-full bg-green-500/10 text-green-400 text-xs font-bold pulse-dot">● ONLINE</span>{% endif %}
53
+ </div>
54
+ <p class="text-gray-400">{{ profile.location or profile.city }} · Member since {{ profile.member_since or '?' }}</p>
55
+ {% if profile.headline %}<p class="text-violet-300 mt-2 text-lg">{{ profile.headline }}</p>{% endif %}
56
+ </div>
57
+ <div class="text-right">
58
+ <div class="text-sm text-gray-500">Global Rank</div>
59
+ <div class="text-4xl font-black text-violet-400">#{{ rank }}</div>
60
+ <div class="text-xs text-gray-600">of {{ total }} masseurs</div>
61
+ </div>
62
+ </div>
63
+ </div>
64
+
65
+ <!-- Stats grid -->
66
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
67
+ <div class="card p-6">
68
+ <div class="text-sm text-gray-500 mb-1">Views/Day</div>
69
+ <div class="text-3xl font-black text-fuchsia-400">{{ profile.views_per_day }}</div>
70
+ </div>
71
+ <div class="card p-6">
72
+ <div class="text-sm text-gray-500 mb-1">Total Visits</div>
73
+ <div class="text-3xl font-black text-pink-400">{{ "{:,}".format(profile.visits) }}</div>
74
+ </div>
75
+ <div class="card p-6">
76
+ <div class="text-sm text-gray-500 mb-1">Rating</div>
77
+ <div class="text-3xl font-black text-green-400">{{ profile.rating }}★</div>
78
+ <div class="text-xs text-gray-600 mt-1">{{ profile.reviews_count }} reviews</div>
79
+ </div>
80
+ <div class="card p-6">
81
+ <div class="text-sm text-gray-500 mb-1">Experience</div>
82
+ <div class="text-3xl font-black text-amber-400">{{ profile.experience_years }}y</div>
83
+ </div>
84
+ </div>
85
+
86
+ <!-- Percentile bar -->
87
+ <div class="card p-6 mb-6">
88
+ <div class="flex justify-between text-sm mb-2">
89
+ <span class="text-gray-500">Percentile Ranking</span>
90
+ <span class="text-violet-400 font-bold">Top {{ (100 - percentile) }}% ({{ percentile }}th percentile)</span>
91
+ </div>
92
+ <div class="h-4 rounded-full bg-white/5 overflow-hidden">
93
+ <div class="h-full progress-bar" style="width:{{ percentile }}%"></div>
94
+ </div>
95
+ </div>
96
+
97
+ <!-- Bio -->
98
+ {% if profile.bio %}
99
+ <div class="card p-8 mb-6">
100
+ <h2 class="text-xl font-bold mb-4">Bio</h2>
101
+ <div class="bg-white/5 rounded-xl p-6 border border-white/10">
102
+ <p class="text-gray-300 leading-relaxed whitespace-pre-wrap">{{ profile.bio[:3000] }}</p>
103
+ </div>
104
+ <div class="text-xs text-gray-600 mt-3">Bio length: {{ profile.bio|length }} chars · Content hash: {{ profile.thumbnail or 'n/a' }}</div>
105
+ </div>
106
+ {% endif %}
107
+
108
+ <!-- Services -->
109
+ {% if services %}
110
+ <div class="card p-8 mb-6">
111
+ <h2 class="text-xl font-bold mb-4">Services</h2>
112
+ <div class="flex flex-wrap gap-2">
113
+ {% for s in services %}
114
+ <span class="px-4 py-2 rounded-full bg-violet-500/10 text-violet-300 text-sm">{{ s }}</span>
115
+ {% endfor %}
116
+ </div>
117
+ </div>
118
+ {% endif %}
119
+
120
+ <!-- Details grid -->
121
+ <div class="card p-8 mb-6">
122
+ <h2 class="text-xl font-bold mb-4">Profile Details</h2>
123
+ <div class="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
124
+ <div><span class="text-gray-500">Username:</span> <span class="font-semibold">@{{ profile.username }}</span></div>
125
+ <div><span class="text-gray-500">User ID:</span> <span class="font-semibold">{{ profile.user_id or 'N/A' }}</span></div>
126
+ <div><span class="text-gray-500">City:</span> <span class="font-semibold">{{ profile.city }}</span></div>
127
+ <div><span class="text-gray-500">Location:</span> <span class="font-semibold">{{ profile.location or 'N/A' }}</span></div>
128
+ <div><span class="text-gray-500">Member Since:</span> <span class="font-semibold">{{ profile.member_since or 'N/A' }}</span></div>
129
+ <div><span class="text-gray-500">Last Login:</span> <span class="font-semibold">{{ profile.last_login or 'N/A' }}</span></div>
130
+ <div><span class="text-gray-500">Photo Count:</span> <span class="font-semibold">{{ profile.photo_count }}</span></div>
131
+ <div><span class="text-gray-500">Gold Member:</span> <span class="font-semibold">{{ 'Yes' if profile.is_gold else 'No' }}</span></div>
132
+ <div><span class="text-gray-500">Online:</span> <span class="font-semibold">{{ 'Yes' if profile.is_online else 'No' }}</span></div>
133
+ <div><span class="text-gray-500">Scraped At:</span> <span class="font-semibold">{{ profile.scraped_at or 'N/A' }}</span></div>
134
+ </div>
135
+ </div>
136
+
137
+ <!-- City peers -->
138
+ {% if city_peers %}
139
+ <div class="card p-8 mb-6">
140
+ <h2 class="text-xl font-bold mb-4">Top Peers in {{ profile.city }}</h2>
141
+ <div class="space-y-2">
142
+ {% for peer in city_peers %}
143
+ <a href="/profile/{{ peer.username }}" class="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 transition">
144
+ <span class="font-semibold text-violet-400">@{{ peer.username }}</span>
145
+ <div class="flex gap-6 text-sm">
146
+ <span class="text-fuchsia-400 font-bold">{{ peer.views_per_day }}/day</span>
147
+ <span class="text-gray-400">{{ "{:,}".format(peer.visits) }} visits</span>
148
+ </div>
149
+ </a>
150
+ {% endfor %}
151
+ </div>
152
+ </div>
153
+ {% endif %}
154
+
155
+ <!-- Proof footer -->
156
+ <div class="card p-6 mb-6 border-violet-500/20">
157
+ <div class="flex items-center gap-3 text-sm text-gray-400">
158
+ <span class="w-2 h-2 rounded-full bg-green-400 pulse-dot"></span>
159
+ <span>Scraped from <a href="{{ rentmasseur_url }}" target="_blank" class="text-violet-400 hover:underline">rentmasseur.com/{{ profile.username }}</a> on {{ profile.scraped_at or 'unknown date' }}</span>
160
+ </div>
161
+ </div>
162
+
163
+ <a href="/dashboard" class="inline-block mb-8 text-violet-400 hover:text-violet-300 transition">← Back to Dashboard</a>
164
+ </div>
165
+
166
+ {% endif %}
167
+ </body>
168
+ </html>