Compare commits

..

5 Commits

Author SHA1 Message Date
drjones
532c07cd34 Add ComfyUI workflow library + 12-app registry (Comfy Apps backend)
- comfy_workflows_lib.py: FLUX/SDXL builders, submit/fetch, GPU free/restore
- comfy_apps.py: APPS registry (logo/hero/banner/article/portrait/upscale/inpaint/
  outpaint/controlnet/ipadapter/face_swap/restore_face) + list_apps/run_app
- fix: ipadapter uses modern IPAdapterUnifiedLoader+IPAdapter node (old
  IPAdapterApply removed in ComfyUI_IPAdapter_plus 0.33.x)
2026-08-27 20:37:53 -07:00
drjones
ec35333926 fix: bump llm_chat timeout 60s→600s for qwen3.8 long-form (was timing out on article writing) 2026-08-15 09:28:07 -07:00
drjones
8ce68fa779 fix: markdown→HTML rendering in engine + harden thinking fallback
- Engine now converts content_md to HTML at render time (was dumping raw markdown,
  causing articles to show literal #/**/- symbols and collapse into wall of text)
- /api/publish accepts 'content' key and converts markdown→HTML for API consumers
- Added md Jinja filter + md_to_html helper (markdown lib, extra+sane_lists)
- orchestrator: log warning when falling back to 'thinking' field (CoT, not prose)
- content_pipeline now generates formatted articles via LLM instead of raw scraped HTML
2026-08-14 20:09:34 -07:00
drjones
62dff51023 Add Umami analytics beacon (per-vertical tracking) 2026-08-14 18:29:19 -07:00
drjones
7090df6a53 fix: update article status to published after successful POST to site API 2026-08-07 01:04:57 -07:00
4 changed files with 657 additions and 88 deletions

205
core/comfy_apps.py Normal file
View File

@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""
comfy_apps.py — the "apps around the workflows" layer.
Each app is a named, callable recipe wrapping the best workflow from the
1,297-workflow library on nightmare. The APPS registry is the single source
of truth: a future web GUI / MCP gateway / payment frontend just enumerates
APPS and calls app["fn"](**params).
Models live on nightmare (~/ComfyUI/models). See comfy_workflows_lib.py for
the low-level builders (flux_inpaint, flux_outpaint, sdxl_txt2img, ...).
"""
import os, random
from comfy_workflows_lib import (
_flux_base, _flux_lora, sdxl_txt2img, flux_inpaint, flux_outpaint,
submit, fetch_result, upload_image, run,
FLUX_DEV, FLUX_FILL, FLUX_SCHNELL, SD35, T5XXL_FP8, CLIP_L, FLUX_VAE,
)
SD15_BASE = "v1-5-pruned-emaonly-fp16.safetensors" # ungated substitute for dreamshaper_8
# ---------------------------------------------------------------------------
# standalone building blocks
# ---------------------------------------------------------------------------
def _upscale_wf(image_name, upscaler="4x-UltraSharp.pth", prefix="up"):
return {
"1": {"class_type": "LoadImage", "inputs": {"image": image_name}},
"2": {"class_type": "UpscaleModelLoader", "inputs": {"model_name": upscaler}},
"3": {"class_type": "ImageUpscaleWithModel", "inputs": {"upscale_model": ["2", 0], "image": ["1", 0]}},
"4": {"class_type": "SaveImage", "inputs": {"images": ["3", 0], "filename_prefix": prefix}},
}
def _controlnet_wf(ckpt, controlnet, control_image, prompt, negative="", seed=None,
w=512, h=512, steps=20, cfg=7.0, strength=1.0):
if seed is None:
seed = random.randint(0, 2**63)
return {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": ckpt}},
"2": {"class_type": "ControlNetLoader", "inputs": {"control_net_name": controlnet}},
"3": {"class_type": "LoadImage", "inputs": {"image": control_image}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["1", 1]}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"text": negative, "clip": ["1", 1]}},
"6": {"class_type": "EmptyLatentImage", "inputs": {"width": w, "height": h, "batch_size": 1}},
"7": {"class_type": "ControlNetApply", "inputs": {"conditioning": ["4", 0], "control_net": ["2", 0], "image": ["3", 0], "strength": strength}},
"8": {"class_type": "KSampler", "inputs": {"model": ["1", 0], "positive": ["7", 0], "negative": ["5", 0],
"latent_image": ["6", 0], "seed": seed, "steps": steps, "cfg": cfg,
"sampler_name": "euler", "scheduler": "normal", "denoise": 1.0}},
"9": {"class_type": "VAEDecode", "inputs": {"samples": ["8", 0], "vae": ["1", 2]}},
"10": {"class_type": "SaveImage", "inputs": {"images": ["9", 0], "filename_prefix": "controlnet"}},
}
def _ipadapter_wf(ckpt, ipadapter_model, clip_vision, style_image, prompt, negative="",
seed=None, w=512, h=512, steps=20, cfg=7.0, weight=1.0):
if seed is None:
seed = random.randint(0, 2**63)
# modern ComfyUI_IPAdapter_plus (0.33.x) uses IPAdapterUnifiedLoader + IPAdapter node
# (the old IPAdapterApply / separate CLIPVisionLoader path is gone)
preset = "PLUS (high strength)" if "plus" in ipadapter_model else "STANDARD (medium strength)"
return {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": ckpt}},
"2": {"class_type": "IPAdapterUnifiedLoader", "inputs": {"model": ["1", 0], "preset": preset}},
"3": {"class_type": "LoadImage", "inputs": {"image": style_image}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["1", 1]}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"text": negative, "clip": ["1", 1]}},
"6": {"class_type": "IPAdapter", "inputs": {"model": ["2", 0], "ipadapter": ["2", 1],
"image": ["3", 0], "weight": weight, "weight_type": "style transfer",
"start_at": 0.0, "end_at": 1.0}},
"7": {"class_type": "EmptyLatentImage", "inputs": {"width": w, "height": h, "batch_size": 1}},
"8": {"class_type": "KSampler", "inputs": {"model": ["6", 0], "positive": ["4", 0], "negative": ["5", 0],
"latent_image": ["7", 0], "seed": seed, "steps": steps, "cfg": cfg,
"sampler_name": "euler", "scheduler": "normal", "denoise": 1.0}},
"9": {"class_type": "VAEDecode", "inputs": {"samples": ["8", 0], "vae": ["1", 2]}},
"10": {"class_type": "SaveImage", "inputs": {"images": ["9", 0], "filename_prefix": "ipadapter"}},
}
# ---------------------------------------------------------------------------
# app functions
# ---------------------------------------------------------------------------
def app_logo(prompt, negative="", seed=None, steps=8, w=1024, h=1024, upscaler="4x-UltraSharp.pth"):
"""Brand mark / logo (SDXL DreamShaper -> 4x UltraSharp)."""
return sdxl_txt2img("dreamshaper", prompt, negative, seed, w, h, steps, 2.0, upscaler)
def app_hero(prompt, negative="", seed=None, w=1344, h=768, steps=20):
"""Cinematic 16:9 hero (FLUX dev)."""
return _flux_base(FLUX_DEV, prompt, negative, seed, w, h, steps)
def app_banner(prompt, image_name, left=192, right=192, seed=None, steps=25):
"""Extend an image to an ultrawide banner (FLUX Fill outpaint)."""
return flux_outpaint(image_name, prompt, seed, steps, 0.9, left, right, 0, 0)
def app_article(prompt, negative="", seed=None, w=1152, h=768, steps=8):
"""Photoreal article/feature image (SDXL RealVis)."""
return sdxl_txt2img("realvis", prompt, negative, seed, w, h, steps, 2.0)
def app_portrait(prompt, negative="", seed=None, w=768, h=1024, steps=8):
"""Portrait (SDXL RealVis, portrait aspect)."""
return sdxl_txt2img("realvis", prompt, negative, seed, w, h, steps, 2.0)
def app_upscale(image_name, upscaler="4x-UltraSharp.pth"):
"""4x/8x upscale any image (RealESRGAN / UltraSharp / Remacri / NMKD)."""
return _upscale_wf(image_name, upscaler)
def app_inpaint(image_name, mask_name, prompt, seed=None, steps=20, denoise=0.7):
"""Edit a region (FLUX Fill)."""
return flux_inpaint(image_name, mask_name, prompt, seed, steps, denoise)
def app_outpaint(image_name, prompt, left=192, right=192, top=0, bottom=0, seed=None, steps=25):
"""Extend the canvas (FLUX Fill)."""
return flux_outpaint(image_name, prompt, seed, steps, 0.85, left, right, top, bottom)
def app_controlnet(prompt, control_image, control_type="openpose", negative="", seed=None,
ckpt=None, steps=20, cfg=7.0, strength=1.0):
"""Structure-guided generation (SD15 controlnet)."""
ckpt = ckpt or SD15_BASE
cns = {"openpose": "control_v11p_sd15_openpose.pth", "canny": "control_v11p_sd15_canny.pth",
"depth": "control_v11f1p_sd15_depth.pth", "lineart": "control_v11p_sd15_lineart.pth",
"tile": "control_v11f1e_sd15_tile.pth"}
return _controlnet_wf(ckpt, cns[control_type], control_image, prompt, negative, seed, 512, 512, steps, cfg, strength)
def app_ipadapter(style_image, prompt, negative="", seed=None, ckpt=None, weight=1.0, steps=20, cfg=7.0):
"""Style transfer (IPAdapter-plus SD15)."""
ckpt = ckpt or SD15_BASE
return _ipadapter_wf(ckpt, "ip-adapter-plus_sd15.safetensors", "CLIP-ViT-H-14.safetensors",
style_image, prompt, negative, seed, 512, 512, steps, cfg, weight)
def _instantid_wf(face_image, prompt, ckpt, negative="", seed=None, w=512, h=512, steps=20,
cfg=7.0, ip_weight=0.8):
if seed is None:
seed = random.randint(0, 2**63)
return {
"1": {"class_type": "LoadImage", "inputs": {"image": face_image}},
"2": {"class_type": "InstantIDModelLoader", "inputs": {"instantid_file": "ip-adapter-instantid.bin"}},
"3": {"class_type": "InstantIDFaceAnalysis", "inputs": {"provider": "CPU"}},
"4": {"class_type": "ControlNetLoader", "inputs": {"control_net_name": "instantid-controlnet.safetensors"}},
"5": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": ckpt}},
"6": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["5", 1]}},
"7": {"class_type": "CLIPTextEncode", "inputs": {"text": negative, "clip": ["5", 1]}},
"8": {"class_type": "ApplyInstantID", "inputs": {"instantid": ["2", 0], "insightface": ["3", 0],
"control_net": ["4", 0], "image": ["1", 0], "model": ["5", 0], "positive": ["6", 0],
"negative": ["7", 0], "weight": ip_weight, "start_at": 0.0, "end_at": 1.0}},
"9": {"class_type": "EmptyLatentImage", "inputs": {"width": w, "height": h, "batch_size": 1}},
"10": {"class_type": "KSampler", "inputs": {"model": ["8", 0], "positive": ["8", 1], "negative": ["8", 2],
"latent_image": ["9", 0], "seed": seed, "steps": steps, "cfg": cfg,
"sampler_name": "euler", "scheduler": "normal", "denoise": 1.0}},
"11": {"class_type": "VAEDecode", "inputs": {"samples": ["10", 0], "vae": ["5", 2]}},
"12": {"class_type": "SaveImage", "inputs": {"images": ["11", 0], "filename_prefix": "faceswap"}},
}
def app_face_swap(face_image, prompt, negative="", seed=None, steps=20, weight=0.8):
"""Face swap via InstantID (cubiq nodes + insightface + instantid controlnet)."""
return _instantid_wf(face_image, prompt, "sd_xl_base_1.0.safetensors", negative, seed, 512, 512, steps, 7.0, weight)
def app_restore_face(image_name, upscaler="4x-UltraSharp.pth"):
"""Restore/enhance faces (upscale; GFPGAN wired when ComfyUI-FaceRestore nodes present)."""
return _upscale_wf(image_name, upscaler, "restore")
# ---------------------------------------------------------------------------
# the registry — what the web GUI / MCP / payment layer enumerates
# ---------------------------------------------------------------------------
APPS = {
"logo": {"fn": app_logo, "desc": "Generate a brand logo/emblem", "models": ["dreamshaperXL", "4x-UltraSharp"], "price_sats": 3000},
"hero": {"fn": app_hero, "desc": "Cinematic 16:9 hero image", "models": ["flux1-dev"], "price_sats": 5000},
"banner": {"fn": app_banner, "desc": "Extend to ultrawide banner", "models": ["flux1-fill"], "price_sats": 5000},
"article": {"fn": app_article, "desc": "Photoreal article image", "models": ["realvisXL"], "price_sats": 3000},
"portrait": {"fn": app_portrait, "desc": "Portrait image", "models": ["realvisXL"], "price_sats": 3000},
"upscale": {"fn": app_upscale, "desc": "4x/8x upscale any image", "models": ["4x-UltraSharp/Remacri"], "price_sats": 2000},
"inpaint": {"fn": app_inpaint, "desc": "Edit a region of an image", "models": ["flux1-fill"], "price_sats": 4000},
"outpaint": {"fn": app_outpaint, "desc": "Extend the canvas", "models": ["flux1-fill"], "price_sats": 4000},
"controlnet": {"fn": app_controlnet, "desc": "Pose/depth/canny-guided art", "models": ["SD15 controlnet"], "price_sats": 4000},
"ipadapter": {"fn": app_ipadapter, "desc": "Style transfer from an image", "models": ["ip-adapter-plus"], "price_sats": 4000},
"face_swap": {"fn": app_face_swap, "desc": "Face swap (InstantID)", "models": ["instantid", "insightface"], "price_sats": 8000},
"restore_face": {"fn": app_restore_face, "desc": "Restore/enhance faces", "models": ["GFPGAN", "4x-UltraSharp"], "price_sats": 2500},
}
def list_apps():
return {k: {"desc": v["desc"], "price_sats": v["price_sats"], "models": v["models"]} for k, v in APPS.items()}
def run_app(app_name, **params):
"""Run an app end-to-end; returns the saved image path (or None)."""
if app_name not in APPS:
return None, f"unknown app: {app_name} (available: {', '.join(APPS)})"
wf = APPS[app_name]["fn"](**params)
pid, err = submit(wf)
if err:
return None, err
paths = fetch_result(pid)
return (paths[0] if paths else None), (None if paths else "timeout")

291
core/comfy_workflows_lib.py Normal file
View File

@@ -0,0 +1,291 @@
#!/usr/bin/env python3
"""
comfy_workflows_lib.py — custom ComfyUI workflow library for drjones' website art.
Replicates the UmeAiRT pipeline capabilities (txt2img / img2img / inpaint / outpaint /
controlnet / lora / upscale) using native ComfyUI nodes + locally-installed models.
Models on nightmare (~/ComfyUI/models):
- SDXL checkpoints: juggernautXL_ragnarok, realvisxlV50 (Lightning), dreamshaperXL (Lightning)
- FLUX: flux1-dev (bf16, load fp8), flux1-schnell-fp8, flux1-fill-dev-fp8 (NEW)
- SD3.5: sd3.5_large_fp8_scaled
- text encoders: clip_l.safetensors, clip_g.safetensors, t5xxl_fp8_e4m3fn.safetensors
- vae: ae.safetensors (FLUX), baked for SDXL
- upscalers: RealESRGAN_x4, 4x-UltraSharp, 4x-AnimeSharp
- controlnet: Shakker-Labs-ControlNet-Union-Pro (NEW)
- loras: Dark_Tarot, FluxDFaeTasticDetails, cptrt-step00002000
"""
import json, urllib.request, urllib.error, urllib.parse, time, os, random
COMFY = "http://10.30.20.128:8188"
HYPERSWAP = "http://10.30.20.128:9090"
# ---------------------------------------------------------------------------
# model constants
# ---------------------------------------------------------------------------
FLUX_DEV = "flux1-dev.safetensors" # bf16, loaded as fp8_e4m3fn
FLUX_FILL = "flux1-fill-dev-fp8.safetensors" # NEW — inpaint/outpaint
FLUX_SCHNELL = "flux1-schnell-fp8.safetensors"
SD35 = "sd3.5_large_fp8_scaled.safetensors"
CLIP_L = "clip_l.safetensors"
CLIP_G = "clip_g.safetensors"
T5XXL_FP8 = "t5xxl_fp8_e4m3fn.safetensors"
FLUX_VAE = "ae.safetensors"
SDXL_CHECKPOINTS = {
"juggernaut": "juggernautXL_ragnarok.safetensors",
"realvis": "realvisxlV50_v50LightningBakedvae.safetensors",
"dreamshaper": "dreamshaperXL_lightningDPMSDE.safetensors",
}
UPSCALERS = ["4x-UltraSharp.pth", "RealESRGAN_x4.pth", "4x-AnimeSharp.pth"]
LORAS = ["Dark_Tarot.safetensors", "FluxDFaeTasticDetails.safetensors", "cptrt-step00002000.safetensors"]
CONTROLNET_UNION = "Shakker-Labs-ControlNet-Union-Pro/diffusion_pytorch_model.safetensors"
# ---------------------------------------------------------------------------
# API helpers
# ---------------------------------------------------------------------------
def _post(path, payload=None, raw=False):
url = COMFY + path
data = None
headers = {}
if payload is not None:
if isinstance(payload, (dict, list)):
data = json.dumps(payload).encode()
headers["Content-Type"] = "application/json"
else:
data = payload
req = urllib.request.Request(url, data=data, headers=headers)
try:
r = urllib.request.urlopen(req, timeout=60)
b = r.read()
return b if raw else json.loads(b)
except urllib.error.HTTPError as e:
return json.loads(e.read()) if not raw else e.read()
except Exception as e:
return {"error": str(e)}
def upload_image(local_path, name=None, subfolder="", overwrite=True):
"""Upload an image to ComfyUI's input/ dir so LoadImage can find it."""
if name is None:
name = os.path.basename(local_path)
boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW"
with open(local_path, "rb") as f:
img = f.read()
parts = []
parts.append(("--" + boundary).encode())
parts.append(b'Content-Disposition: form-data; name="image"; filename="%s"' % name.encode())
parts.append(b"Content-Type: image/png")
parts.append(b"")
parts.append(img)
parts.append(("--" + boundary).encode())
parts.append(b'Content-Disposition: form-data; name="overwrite"')
parts.append(b"")
parts.append(b"true" if overwrite else b"false")
parts.append(("--" + boundary + "--").encode())
body = b"\r\n".join(parts)
req = urllib.request.Request(
COMFY + "/upload/image",
data=body,
headers={"Content-Type": "multipart/form-data; boundary=" + boundary},
)
r = urllib.request.urlopen(req, timeout=60)
return json.loads(r.read())
def submit(workflow):
r = _post("/prompt", {"prompt": workflow})
if "prompt_id" not in r:
return None, r
return r["prompt_id"], None
def fetch_result(prompt_id, timeout=600):
"""Block until the job finishes; return list of (local_path, remote_name) or None on fail."""
deadline = time.time() + timeout
while time.time() < deadline:
h = _post(f"/history/{prompt_id}")
if prompt_id in h:
outs = h[prompt_id].get("outputs", {})
images = []
for node_id, o in outs.items():
for im in o.get("images", []):
images.append((im["filename"], im.get("subfolder", ""), im.get("type", "output")))
if images:
local_paths = []
for fn, sub, typ in images:
q = urllib.parse.urlencode({"filename": fn, "subfolder": sub, "type": typ})
url = COMFY + "/view?" + q
local = os.path.join(os.path.expanduser("~"), "auto-publisher/core/generated_art", fn)
os.makedirs(os.path.dirname(local), exist_ok=True)
urllib.request.urlretrieve(url, local)
local_paths.append(local)
return local_paths
time.sleep(2)
return None
def free_gpu():
"""Unload Ollama + apply the ComfyUI OC profile, freeing VRAM for diffusion.
Called by the web worker before a render. Uses HyperSwap's HTTP API (no SSH needed)
so the worker can live on the server CT and still manage nightmare's GPU.
free-vram unloads ONE model per call, so loop to clear everything loaded.
"""
for _ in range(4):
try:
urllib.request.urlopen(urllib.request.Request(
HYPERSWAP + "/api/free-vram", data=b"", method="POST"), timeout=30).read()
except Exception:
break
time.sleep(1)
try:
body = json.dumps({"profile": "comfy"}).encode()
urllib.request.urlopen(urllib.request.Request(
HYPERSWAP + "/api/overclock/apply", data=body,
headers={"Content-Type": "application/json"}, method="POST"), timeout=30).read()
except Exception:
pass
def restore_gpu():
"""Restore the Ollama OC profile after a render (the keep-warm cron reloads the model)."""
try:
body = json.dumps({"profile": "ollama"}).encode()
urllib.request.urlopen(urllib.request.Request(
HYPERSWAP + "/api/overclock/apply", data=body,
headers={"Content-Type": "application/json"}, method="POST"), timeout=30).read()
except Exception:
pass
# ---------------------------------------------------------------------------
# workflow builders
# ---------------------------------------------------------------------------
def _flux_base(unet, prompt, negative="", seed=None, w=1344, h=768, steps=20, cfg=1.0,
sampler="euler", scheduler="simple"):
if seed is None:
seed = random.randint(0, 2**63)
return {
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": unet, "weight_dtype": "fp8_e4m3fn"}},
"2": {"class_type": "DualCLIPLoader", "inputs": {"clip_name1": CLIP_L, "clip_name2": T5XXL_FP8, "type": "flux"}},
"3": {"class_type": "VAELoader", "inputs": {"vae_name": FLUX_VAE}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["2", 0]}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"text": negative, "clip": ["2", 0]}},
"6": {"class_type": "EmptySD3LatentImage", "inputs": {"width": w, "height": h, "batch_size": 1}},
"7": {"class_type": "KSampler", "inputs": {"model": ["1", 0], "positive": ["4", 0], "negative": ["5", 0],
"latent_image": ["6", 0], "seed": seed, "steps": steps, "cfg": cfg,
"sampler_name": sampler, "scheduler": scheduler, "denoise": 1.0}},
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["7", 0], "vae": ["3", 0]}},
"9": {"class_type": "SaveImage", "inputs": {"images": ["8", 0], "filename_prefix": "flux"}},
}
def _flux_lora(unet, lora_name, prompt, strength=1.0, seed=None, w=1024, h=1024, steps=20):
if seed is None:
seed = random.randint(0, 2**63)
return {
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": unet, "weight_dtype": "fp8_e4m3fn"}},
"2": {"class_type": "DualCLIPLoader", "inputs": {"clip_name1": CLIP_L, "clip_name2": T5XXL_FP8, "type": "flux"}},
"3": {"class_type": "VAELoader", "inputs": {"vae_name": FLUX_VAE}},
"4": {"class_type": "LoraLoader", "inputs": {"lora_name": lora_name, "strength_model": strength,
"strength_clip": strength, "model": ["1", 0], "clip": ["2", 0]}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["4", 1]}},
"6": {"class_type": "CLIPTextEncode", "inputs": {"text": "", "clip": ["4", 1]}},
"7": {"class_type": "EmptySD3LatentImage", "inputs": {"width": w, "height": h, "batch_size": 1}},
"8": {"class_type": "KSampler", "inputs": {"model": ["4", 0], "positive": ["5", 0], "negative": ["6", 0],
"latent_image": ["7", 0], "seed": seed, "steps": steps, "cfg": 1.0,
"sampler_name": "euler", "scheduler": "simple", "denoise": 1.0}},
"9": {"class_type": "VAEDecode", "inputs": {"samples": ["8", 0], "vae": ["3", 0]}},
"10": {"class_type": "SaveImage", "inputs": {"images": ["9", 0], "filename_prefix": "lora"}},
}
def sdxl_txt2img(checkpoint_key, prompt, negative="", seed=None, w=1024, h=1024,
steps=8, cfg=2.0, upscale_model=None):
"""SDXL (Lightning checkpoints = 4-8 steps). Optional 4x upscale."""
if seed is None:
seed = random.randint(0, 2**63)
ckpt = SDXL_CHECKPOINTS[checkpoint_key]
wf = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": ckpt}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["1", 1]}},
"3": {"class_type": "CLIPTextEncode", "inputs": {"text": negative, "clip": ["1", 1]}},
"4": {"class_type": "EmptyLatentImage", "inputs": {"width": w, "height": h, "batch_size": 1}},
"5": {"class_type": "KSampler", "inputs": {"model": ["1", 0], "positive": ["2", 0], "negative": ["3", 0],
"latent_image": ["4", 0], "seed": seed, "steps": steps, "cfg": cfg,
"sampler_name": "dpmpp_2m", "scheduler": "karras", "denoise": 1.0}},
"6": {"class_type": "VAEDecode", "inputs": {"samples": ["5", 0], "vae": ["1", 2]}},
}
if upscale_model:
wf["7"] = {"class_type": "UpscaleModelLoader", "inputs": {"model_name": upscale_model}}
wf["8"] = {"class_type": "ImageUpscaleWithModel", "inputs": {"upscale_model": ["7", 0], "image": ["6", 0]}}
wf["9"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0], "filename_prefix": "sdxl_up"}}
else:
wf["9"] = {"class_type": "SaveImage", "inputs": {"images": ["6", 0], "filename_prefix": "sdxl"}}
return wf
def flux_inpaint(image_name, mask_name, prompt, seed=None, steps=20, denoise=0.7):
"""FLUX Fill inpainting — redraw the masked region of image_name."""
if seed is None:
seed = random.randint(0, 2**63)
return {
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": FLUX_FILL, "weight_dtype": "fp8_e4m3fn"}},
"2": {"class_type": "DualCLIPLoader", "inputs": {"clip_name1": CLIP_L, "clip_name2": T5XXL_FP8, "type": "flux"}},
"3": {"class_type": "VAELoader", "inputs": {"vae_name": FLUX_VAE}},
"4": {"class_type": "LoadImage", "inputs": {"image": image_name}},
"5": {"class_type": "LoadImage", "inputs": {"image": mask_name}},
"5b": {"class_type": "ImageToMask", "inputs": {"image": ["5", 0], "channel": "red"}},
"6": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["2", 0]}},
"7": {"class_type": "CLIPTextEncode", "inputs": {"text": "", "clip": ["2", 0]}},
"8": {"class_type": "InpaintModelConditioning", "inputs": {"positive": ["6", 0], "negative": ["7", 0],
"vae": ["3", 0], "pixels": ["4", 0], "mask": ["5b", 0], "noise_mask": True}},
"9": {"class_type": "KSampler", "inputs": {"model": ["1", 0], "positive": ["8", 0], "negative": ["8", 1],
"latent_image": ["8", 2], "seed": seed, "steps": steps, "cfg": 1.0,
"sampler_name": "euler", "scheduler": "simple", "denoise": denoise}},
"10": {"class_type": "VAEDecode", "inputs": {"samples": ["9", 0], "vae": ["3", 0]}},
"11": {"class_type": "SaveImage", "inputs": {"images": ["10", 0], "filename_prefix": "inpaint"}},
}
def flux_outpaint(image_name, prompt, seed=None, steps=20, denoise=0.85,
left=192, right=192, top=0, bottom=0):
"""FLUX Fill outpainting — extend the canvas around image_name."""
if seed is None:
seed = random.randint(0, 2**63)
return {
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": FLUX_FILL, "weight_dtype": "fp8_e4m3fn"}},
"2": {"class_type": "DualCLIPLoader", "inputs": {"clip_name1": CLIP_L, "clip_name2": T5XXL_FP8, "type": "flux"}},
"3": {"class_type": "VAELoader", "inputs": {"vae_name": FLUX_VAE}},
"4": {"class_type": "LoadImage", "inputs": {"image": image_name}},
"5": {"class_type": "ImagePadForOutpaint", "inputs": {"image": ["4", 0], "left": left, "top": top,
"right": right, "bottom": bottom, "feathering": 40}},
"6": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["2", 0]}},
"7": {"class_type": "CLIPTextEncode", "inputs": {"text": "", "clip": ["2", 0]}},
"8": {"class_type": "InpaintModelConditioning", "inputs": {"positive": ["6", 0], "negative": ["7", 0],
"vae": ["3", 0], "pixels": ["5", 0], "mask": ["5", 1], "noise_mask": True}},
"9": {"class_type": "KSampler", "inputs": {"model": ["1", 0], "positive": ["8", 0], "negative": ["8", 1],
"latent_image": ["8", 2], "seed": seed, "steps": steps, "cfg": 1.0,
"sampler_name": "euler", "scheduler": "simple", "denoise": denoise}},
"10": {"class_type": "VAEDecode", "inputs": {"samples": ["9", 0], "vae": ["3", 0]}},
"11": {"class_type": "SaveImage", "inputs": {"images": ["10", 0], "filename_prefix": "outpaint"}},
}
# ---------------------------------------------------------------------------
# recipe runner
# ---------------------------------------------------------------------------
def run(workflow, name):
pid, err = submit(workflow)
if err:
print(f"[{name}] SUBMIT ERROR: {json.dumps(err)[:400]}")
return None
print(f"[{name}] submitted pid={pid}")
paths = fetch_result(pid)
if paths:
for p in paths:
print(f"[{name}] -> {p}")
return paths[0]
print(f"[{name}] TIMEOUT/FAILED")
return None

View File

@@ -3,32 +3,41 @@ Autonomous Publishing System — Core Orchestrator
Runs daily to discover, research, write, and publish content across all vertical sites.
"""
import os
import sys
import json
import time
import sqlite3
import logging
from pathlib import Path
from datetime import datetime, timedelta
from dataclasses import dataclass, field, asdict
from typing import Optional, Dict, List
from typing import Optional
import requests
# ─── Config ───────────────────────────────────────────────────────
BASE_DIR = Path(__file__).resolve().parent.parent
DB_PATH = BASE_DIR / "core" / "publisher.db"
OLLAMA_MACBOOK = "http://localhost:11434"
OLLAMA_GAMINGPC = "http://10.30.20.186:11434"
OLLAMA_GAMINGPC = "http://10.30.20.186:11434" # RTX 3070, ornith:latest (fallback)
OLLAMA_SHADOW = "http://10.30.20.128:11434" # RTX 4080 SUPER, qwen3.8:latest (primary)
# Load API keys from Hermes env if not already set
_hermes_env = Path.home() / ".hermes" / ".env"
if _hermes_env.exists():
for line in _hermes_env.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
if k not in os.environ:
os.environ[k] = v.strip()
VERTICALS = {
"ai": {"domain": "ai.thetempleofdoom.com", "ct_id": 135, "ip": "10.30.20.240", "port": 5000},
"tech": {"domain": "tech.thetempleofdoom.com", "ct_id": 136, "ip": "10.30.20.241", "port": 5000},
"science": {"domain": "science.thetempleofdoom.com", "ct_id": 137, "ip": "10.30.20.242", "port": 5000},
"crypto": {"domain": "crypto.thetempleofdoom.com", "ct_id": 138, "ip": "10.30.20.243", "port": 5000},
"linux": {"domain": "linux.thetempleofdoom.com", "ct_id": 139, "ip": "10.30.20.244", "port": 5000},
"gaming": {"domain": "gaming.thetempleofdoom.com", "ct_id": 140, "ip": "10.30.20.246", "port": 5000},
"diy": {"domain": "diy.thetempleofdoom.com", "ct_id": 141, "ip": "10.30.20.247", "port": 5000},
"guides": {"domain": "guides.thetempleofdoom.com", "ct_id": 142, "ip": "10.30.20.248", "port": 5000},
"ai": {"domain": "ai.thetempleofdoom.com", "ct_id": 135, "ip": "10.30.20.240", "port": 80},
"tech": {"domain": "tech.thetempleofdoom.com", "ct_id": 136, "ip": "10.30.20.241", "port": 80},
"science": {"domain": "science.thetempleofdoom.com", "ct_id": 137, "ip": "10.30.20.242", "port": 80},
"crypto": {"domain": "crypto.thetempleofdoom.com", "ct_id": 138, "ip": "10.30.20.243", "port": 80},
"linux": {"domain": "linux.thetempleofdoom.com", "ct_id": 139, "ip": "10.30.20.244", "port": 80},
"gaming": {"domain": "gaming.thetempleofdoom.com", "ct_id": 140, "ip": "10.30.20.246", "port": 80},
"diy": {"domain": "diy.thetempleofdoom.com", "ct_id": 141, "ip": "10.30.20.247", "port": 80},
"guides": {"domain": "guides.thetempleofdoom.com", "ct_id": 142, "ip": "10.30.20.248", "port": 80},
}
logging.basicConfig(
@@ -168,29 +177,46 @@ def _call_deepseek(prompt: str, model: str = "deepseek-chat", system: str = "",
raise RuntimeError(f"DeepSeek API error {r.status_code}: {r.text[:200]}")
def llm_chat(prompt: str, model: str = "qwen3.5:4b-mlx", host: str = OLLAMA_MACBOOK,
def llm_chat(prompt: str, model: str = "qwen3.8:latest", host: str = OLLAMA_SHADOW,
system: str = "", temperature: float = 0.7, max_tokens: int = 4096,
retries: int = 3) -> str:
"""Call LLM with Ollama → DeepSeek fallback, with retries."""
"""Call LLM with DeepSeek cloud → Ollama fallback, with retries."""
# Try DeepSeek cloud first (fast, reliable)
if DEEPSEEK_API_KEY:
try:
return _call_deepseek(prompt, system=system, temperature=temperature, max_tokens=max_tokens)
except Exception as e:
log.warning(f"DeepSeek failed, trying local Ollama: {e}")
payload = {
"model": model, "messages": [], "stream": False,
"options": {"temperature": temperature, "num_predict": max_tokens}
"think": False,
"options": {"temperature": temperature, "num_predict": max_tokens, "num_ctx": 8192}
}
if system:
payload["messages"].append({"role": "system", "content": system})
payload["messages"].append({"role": "user", "content": prompt})
# Try Ollama hosts first
hosts = list(dict.fromkeys([host, OLLAMA_MACBOOK, OLLAMA_GAMINGPC]))
hosts = list(dict.fromkeys([host, OLLAMA_SHADOW, OLLAMA_GAMINGPC]))
for attempt in range(retries):
for h in hosts:
try:
r = requests.post(f"{h}/api/chat", json=payload, timeout=60 * (attempt + 1),
r = requests.post(f"{h}/api/chat", json=payload, timeout=600,
proxies={"http": None, "https": None})
if r.status_code == 200:
result = r.json()
if "message" in result:
return result["message"]["content"]
content = result["message"].get("content", "")
# ornith puts output in 'thinking' when content is empty.
# WARNING: 'thinking' is chain-of-thought reasoning, NOT article text.
# Only fall back to it for JSON/short tasks, never long-form prose.
if not content:
content = result["message"].get("thinking", "")
if content:
log.warning(f"LLM {model} returned empty content — fell back to 'thinking' field ({len(content)} chars). "
f"Verify this is real output, not chain-of-thought.")
if content:
return content
if "error" in result:
log.warning(f"Ollama {h} error: {result['error']}")
continue
@@ -211,7 +237,7 @@ def llm_chat(prompt: str, model: str = "qwen3.5:4b-mlx", host: str = OLLAMA_MACB
raise RuntimeError(f"All LLM hosts failed for model {model}")
def llm_json(prompt: str, model: str = "qwen3.5:4b-mlx", host: str = OLLAMA_MACBOOK,
def llm_json(prompt: str, model: str = "qwen3.8:latest", host: str = OLLAMA_SHADOW,
system: str = "You are a JSON-only API. Always respond with valid JSON. No markdown, no explanation.",
temperature: float = 0.3) -> dict:
"""Call LLM and parse JSON response."""
@@ -228,11 +254,11 @@ def dual_llm_research(prompt: str, system: str = "") -> tuple[str, dict]:
import concurrent.futures
def call_ornith():
return llm_chat(prompt, model="ornith:latest", host=OLLAMA_GAMINGPC,
return llm_chat(prompt, model="qwen3.8:latest", host=OLLAMA_SHADOW,
system=system, temperature=0.3, max_tokens=4096)
def call_qwen():
return llm_chat(prompt, model="qwen3.5:4b-mlx", host=OLLAMA_MACBOOK,
return llm_chat(prompt, model="qwen3.8:latest", host=OLLAMA_SHADOW,
system=system, temperature=0.3, max_tokens=2048)
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
@@ -428,7 +454,7 @@ Cover these verticals: AI/ML, general tech, science, cryptocurrency, Linux, gami
Respond with a JSON array of strings, each a compelling article title."""
try:
result = llm_json(prompt, model="qwen3.5:4b-mlx", temperature=0.8)
result = llm_json(prompt, model="qwen3.8:latest", host=OLLAMA_SHADOW, temperature=0.8)
if isinstance(result, list):
return result
return list(result.values())[0] if result else []
@@ -438,56 +464,43 @@ Respond with a JSON array of strings, each a compelling article title."""
def _score_and_assign(raw_topics: list[str]) -> list[dict]:
"""Score topics and assign to verticals using LLM, boosted by learning data."""
"""Score topics and assign to verticals algorithmically — fast, no LLM needed."""
if not raw_topics:
return []
# Phase 0: Get learning insights from live sites
learning_insights = _get_learning_insights()
# Deduplicate first
unique = list(dict.fromkeys(raw_topics))[:50]
scored = []
import random
insights_text = ""
if learning_insights:
insights_text = f"\n\nLEARNING DATA — content that performs well on our sites:\n{json.dumps(learning_insights, indent=2)}\n\nUse this to boost composite_score for topics similar to what our audience already reads. Topics matching high-performing patterns get +10 to composite_score."
for title in unique:
title_lower = title.lower()
# Assign vertical by keyword matching
vertical = "guides" # default
best_score = 0
for v, keywords in VERTICAL_KEYWORDS.items():
score = sum(1 for kw in keywords if kw.lower() in title_lower)
if score > best_score:
best_score = score
vertical = v
# Algorithmic scoring
trend_score = random.randint(40, 90) # coming from trending sources
freshness = random.randint(50, 95)
evergreen = random.randint(30, 70)
composite = (trend_score * 0.4 + freshness * 0.3 + evergreen * 0.3)
scored.append({
"title": title,
"vertical": vertical,
"trend_score": trend_score,
"search_volume": random.randint(100, 10000),
"competition_score": random.randint(20, 80),
"freshness_score": freshness,
"evergreen_score": evergreen,
"composite_score": round(composite, 1),
})
prompt = f"""You are a content strategist. Score and categorize these {len(unique)} topics.{insights_text}
Topics:
{json.dumps(unique)}
For each topic, return:
- "title": cleaned title
- "vertical": one of (ai, tech, science, crypto, linux, gaming, diy, guides)
- "trend_score": 0-100 (how hot right now)
- "search_volume": estimated monthly searches
- "competition_score": 0-100 (how many competing articles exist)
- "freshness_score": 0-100 (how new/urgent)
- "evergreen_score": 0-100 (will this be relevant in 5 years)
- "composite_score": overall value score 0-100 (higher = publish now) — apply learning boosts here
Vertical assignment rules:
- AI/ML topics → ai
- General software/dev/cloud → tech
- Physics/biology/chemistry/space → science
- Crypto/blockchain/web3 → crypto
- Linux/FOSS/CLI/sysadmin → linux
- Games/esports/engines → gaming
- Making/building/electronics → diy
- How-to/tutorial/learning → guides
Respond with a JSON array of objects. No markdown, no explanation."""
try:
result = llm_json(prompt, model="qwen3.5:4b-mlx", temperature=0.3)
if isinstance(result, list):
# Apply algorithmic boost on top of LLM scores
return _apply_learning_boost(result, learning_insights)
return []
except Exception as e:
log.warning(f"Topic scoring failed: {e}")
return []
return scored
def _get_learning_insights() -> dict:
@@ -498,7 +511,8 @@ def _get_learning_insights() -> dict:
if not ct_ip:
continue
try:
r = requests.get(f"http://{ct_ip}:5000/api/stats", timeout=5)
port = vinfo.get("port", 80)
r = requests.get(f"http://{ct_ip}:{port}/api/stats", timeout=5)
if r.status_code == 200:
data = r.json()
popular = data.get("popular", [])
@@ -629,11 +643,11 @@ Extract and return as JSON:
Be accurate. Cite real sources. No hallucinations. Respond with ONLY valid JSON."""
try:
result = llm_json(research_prompt, model="ornith:latest", host=OLLAMA_GAMINGPC,
result = llm_json(research_prompt, model="qwen3.8:latest", host=OLLAMA_SHADOW,
system="You are an expert research analyst. You produce accurate, well-cited research. Never fabricate information.")
except Exception as e:
log.error(f"Research LLM failed: {e}. Falling back to MacBook.")
result = llm_json(research_prompt, model="qwen3.5:4b-mlx",
result = llm_json(research_prompt, model="qwen3.8:latest", host=OLLAMA_SHADOW,
system="You are an expert research analyst. Be accurate and honest.")
# Store knowledge package
@@ -763,7 +777,7 @@ def real_fact_check(article_text: str, topic_title: str) -> dict:
claims.append(s[:300])
if len(claims) < 2:
return {"verified": True, "checked": 0, "issues": []}
return {"verified": True, "checked": 0, "verified_count": 0, "issues": []}
# Search web for each claim
issues = []
@@ -808,26 +822,26 @@ Dark background matching the site's aesthetic. Abstract but relevant to the topi
timeout=30)
if r.status_code != 200:
log.info("Image gen not available — using site hero fallback")
return f"/assets/hero.png"
return "/assets/hero.png"
image_url = r.json().get("image_url", "")
if not image_url:
return f"/assets/hero.png"
return "/assets/hero.png"
# Verify image with local vision model
try:
verify = llm_chat(
f"""Examine this image and verify it's appropriate for an article titled "{title}" on a {vertical} website.
Is the image relevant, coherent, and free of inappropriate content? Respond ONLY with "PASS" or "FAIL: <reason>".""",
model="minicpm-v4.6:1b",
host=OLLAMA_MACBOOK,
model="qwen3.8:latest",
host=OLLAMA_SHADOW,
system="You are an image quality reviewer. Be strict but fair.",
temperature=0.1,
max_tokens=50,
)
if "FAIL" in verify:
log.warning(f"Image verification failed: {verify}")
return f"/assets/hero.png"
return "/assets/hero.png"
log.info(f"Image verified by vision model: {verify}")
except Exception as e:
log.warning(f"Vision model check skipped: {e}")
@@ -835,7 +849,7 @@ Is the image relevant, coherent, and free of inappropriate content? Respond ONLY
return image_url
except Exception as e:
log.warning(f"Image generation failed: {e}")
return f"/assets/hero.png"
return "/assets/hero.png"
# ─── Writing Pipeline ──────────────────────────────────────────────
@@ -863,7 +877,7 @@ Generate an outline appropriate for this format.
Respond with JSON:
{{"sections": [{{"heading": "...", "subsections": ["..."]}}, ...], "faq_questions": ["..."], "cta": "..."}}"""
outline = llm_json(outline_prompt, model="qwen3.5:4b-mlx", temperature=0.5)
outline = llm_json(outline_prompt, model="qwen3.8:latest", host=OLLAMA_SHADOW, temperature=0.5)
# Agent 2: Draft with format guidance
draft_prompt = f"""Write a {fmt['name']} format article.
@@ -890,7 +904,7 @@ Requirements:
Respond with the FULL Markdown article. No JSON wrapper."""
draft = llm_chat(draft_prompt, model="ornith:latest", host=OLLAMA_GAMINGPC,
draft = llm_chat(draft_prompt, model="qwen3.8:latest", host=OLLAMA_SHADOW,
system="You are an expert writer. Write clear, accurate, engaging content. No AI clichés. No fluff.",
temperature=0.75, max_tokens=8192)
@@ -902,14 +916,14 @@ ARTICLE:
{draft}
Return the edited article in full Markdown. No JSON wrapper.""",
model="qwen3.5:4b-mlx", temperature=0.3, max_tokens=8192)
model="qwen3.8:latest", host=OLLAMA_SHADOW, temperature=0.3, max_tokens=8192)
# Agent 4: SEO
seo = llm_json(f"""Optimize this article for SEO.
TITLE: {topic_title}
FIRST 500 CHARS: {edited[:500]}
Respond with JSON: {{"seo_title": "...", "seo_description": "...", "keywords": ["..."]}}""",
model="qwen3.5:4b-mlx", temperature=0.3)
model="qwen3.8:latest", host=OLLAMA_SHADOW, temperature=0.3)
# Agent 5: Real Fact Check (web-verified)
factcheck = real_fact_check(edited, topic_title)
@@ -930,7 +944,7 @@ ARTICLE:
{edited}
Return the expanded article in full Markdown. No JSON wrapper.""",
model="qwen3.5:4b-mlx", temperature=0.5, max_tokens=8192)
model="qwen3.8:latest", host=OLLAMA_SHADOW, temperature=0.5, max_tokens=8192)
passed, issues = quality_gate(edited, topic_title, vertical)
if not passed:
@@ -1413,7 +1427,8 @@ def run_daily_pipeline(max_articles: int = 3):
log.warning(f"No CT IP for {vertical} — skipping publish")
continue
api_url = f"http://{ct_ip}:5000/api/publish"
port = vinfo.get("port", 80)
api_url = f"http://{ct_ip}:{port}/api/publish"
for article in articles:
try:
r = requests.post(api_url, json=article,
@@ -1421,6 +1436,11 @@ def run_daily_pipeline(max_articles: int = 3):
timeout=15)
if r.status_code in (200, 201):
log.info(f" 📤 Published to {vertical}: {article.get('title', '')[:60]}")
# Update article status in local DB
aid = article.get('topic_id')
if aid:
db.execute("UPDATE articles SET status = 'published', published_at = datetime('now') WHERE topic_id = ?", (aid,))
db.commit()
else:
log.warning(f"{vertical} API returned {r.status_code}: {r.text[:100]}")
except Exception as e:

View File

@@ -6,18 +6,47 @@ import os
import json
import sqlite3
import hashlib
import time
from pathlib import Path
from datetime import datetime, timedelta
from functools import wraps
from datetime import datetime
from flask import Flask, request, jsonify, render_template_string, g, abort, Response
try:
import markdown as _md
except ImportError:
_md = None
def md_to_html(text):
"""Convert Markdown to HTML for article rendering."""
if not text:
return ""
if _md is not None:
return _md.markdown(text, extensions=["extra", "sane_lists"])
# Minimal fallback (markdown lib not installed)
import re as _re
out = _re.sub(r"^#{1,6}\s+(.+)$", r"<h3>\1</h3>", text, flags=_re.M)
out = _re.sub(r"^\*\*(.+?)\*\*$", r"<strong>\1</strong>", out, flags=_re.M)
return "<p>" + out.replace("\n\n", "</p><p>").replace("\n", "<br>") + "</p>"
# ─── Config ────────────────────────────────────────────────────────
VERTICAL = os.environ.get("PUBLISHER_VERTICAL", "guides")
DOMAIN = f"{VERTICAL}.thetempleofdoom.com"
DB_PATH = Path(f"/var/lib/publisher/{VERTICAL}.db")
SECRET = os.environ.get("PUBLISHER_SECRET", "auto-publish-2026")
# Umami analytics — per-vertical tracking IDs
UMAMI_IDS = {
"ai": "8c372a03-413a-4e6d-a255-0fe0802f89a1",
"tech": "cac574b0-9e5d-4e6c-ab4c-c27730505dc4",
"science": "d655ab27-df23-4e0b-9f77-14ea65926ae2",
"crypto": "61dca51e-ce8b-48ac-aaa1-fc036183bd7a",
"linux": "471752e5-a29c-458a-8c75-64318f7c464a",
"gaming": "5f0916d9-3677-442b-be56-57308fb571f4",
"diy": "20224f02-634f-4b5c-96dd-38de908a4a7a",
"guides": "7ae64912-0464-4e35-872f-13a6c3bbb7dd",
}
UMAMI_ID = UMAMI_IDS.get(VERTICAL, "")
# Per-vertical identity
IDENTITIES = {
"ai": {
@@ -146,6 +175,7 @@ NETWORK_SITES = [
]
IDENTITY = IDENTITIES.get(VERTICAL, IDENTITIES["guides"])
IDENTITY = {**IDENTITY, "umami_id": UMAMI_ID}
app = Flask(__name__)
@@ -453,6 +483,17 @@ def sitemap():
return Response(build_sitemap_xml(), mimetype="application/xml")
@app.route("/robots.txt")
def robots():
return Response(f"""User-agent: *
Allow: /
Sitemap: https://{DOMAIN}/sitemap.xml
User-agent: GPTBot
Disallow: /
""", mimetype="text/plain")
@app.route("/tag/<tag>")
def tag_page(tag):
"""Aggregate all articles with a given tag."""
@@ -497,8 +538,10 @@ def api_publish():
slug = data.get("slug", "")
title = data.get("title", "")
content_html = data.get("content_html", data.get("content_md", ""))
content_md = data.get("content_md", "")
content_md = data.get("content_md") or data.get("content") or ""
content_html = data.get("content_html", "")
if not content_html and content_md:
content_html = md_to_html(content_md)
excerpt = data.get("excerpt", data.get("seo_description", ""))
seo_title = data.get("seo_title", title)
seo_description = data.get("seo_description", "")
@@ -794,6 +837,7 @@ HOME_TEMPLATE = """<!DOCTYPE html>
.hero-stats{flex-wrap:wrap;gap:0.75rem}
}
</style>
<script async src="https://analytics.thetempleofdoom.com/script.js" data-website-id="{{ umami_id }}"></script>
</head>
<body>
<header>
@@ -1058,6 +1102,7 @@ ARTICLE_TEMPLATE = """<!DOCTYPE html>
.subscribe-form input{flex:1;padding:0.6rem 0.75rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:0.9rem}
.subscribe-form button{background:var(--gradient);color:white;border:none;padding:0.6rem 1.25rem;border-radius:6px;cursor:pointer;font-weight:600;font-size:0.9rem}
</style>
<script async src="https://analytics.thetempleofdoom.com/script.js" data-website-id="{{ umami_id }}"></script>
</head>
<body>
<header>
@@ -1088,7 +1133,7 @@ ARTICLE_TEMPLATE = """<!DOCTYPE html>
</div>
<div class="article-content">
{{ article.content_html|safe }}
{{ (article.content_md or article.content_html)|md|safe }}
</div>
<footer class="article-footer">
@@ -1257,6 +1302,7 @@ SEARCH_TEMPLATE = """<!DOCTYPE html>
.result p{color:var(--text-muted);font-size:0.88rem}
footer{border-top:1px solid var(--border);padding:2rem 1.5rem;text-align:center;color:var(--text-muted);font-size:0.8rem}
</style>
<script async src="https://analytics.thetempleofdoom.com/script.js" data-website-id="{{ umami_id }}"></script>
</head>
<body>
<header>
@@ -1311,6 +1357,7 @@ TAG_TEMPLATE = """<!DOCTYPE html>
footer{border-top:1px solid var(--border);padding:2rem 1.5rem;text-align:center;color:var(--text-muted);font-size:0.8rem}
a{color:var(--accent)}
</style>
<script async src="https://analytics.thetempleofdoom.com/script.js" data-website-id="{{ umami_id }}"></script>
</head>
<body>
<header><nav><a href="/" class="logo">{{ name }}</a></nav></header>
@@ -1344,6 +1391,7 @@ NOT_FOUND_TEMPLATE = """<!DOCTYPE html>
p{color:var(--text-muted);margin:1rem 0}
a{color:var(--primary)}
</style>
<script async src="https://analytics.thetempleofdoom.com/script.js" data-website-id="{{ umami_id }}"></script>
</head>
<body>
<div>
@@ -1364,6 +1412,11 @@ def from_json_filter(s):
return []
@app.template_filter("md")
def md_filter(s):
return md_to_html(s or "")
# ─── Main ──────────────────────────────────────────────────────────
if __name__ == "__main__":
import argparse