Compare commits
5 Commits
0cf987fc59
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
532c07cd34 | ||
|
|
ec35333926 | ||
|
|
8ce68fa779 | ||
|
|
62dff51023 | ||
|
|
7090df6a53 |
205
core/comfy_apps.py
Normal file
205
core/comfy_apps.py
Normal 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
291
core/comfy_workflows_lib.py
Normal 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
|
||||||
@@ -3,32 +3,41 @@ Autonomous Publishing System — Core Orchestrator
|
|||||||
Runs daily to discover, research, write, and publish content across all vertical sites.
|
Runs daily to discover, research, write, and publish content across all vertical sites.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from dataclasses import dataclass, field, asdict
|
from typing import Optional
|
||||||
from typing import Optional, Dict, List
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
# ─── Config ───────────────────────────────────────────────────────
|
# ─── Config ───────────────────────────────────────────────────────
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
DB_PATH = BASE_DIR / "core" / "publisher.db"
|
DB_PATH = BASE_DIR / "core" / "publisher.db"
|
||||||
OLLAMA_MACBOOK = "http://localhost:11434"
|
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 = {
|
VERTICALS = {
|
||||||
"ai": {"domain": "ai.thetempleofdoom.com", "ct_id": 135, "ip": "10.30.20.240", "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": 5000},
|
"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": 5000},
|
"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": 5000},
|
"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": 5000},
|
"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": 5000},
|
"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": 5000},
|
"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": 5000},
|
"guides": {"domain": "guides.thetempleofdoom.com", "ct_id": 142, "ip": "10.30.20.248", "port": 80},
|
||||||
}
|
}
|
||||||
|
|
||||||
logging.basicConfig(
|
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]}")
|
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,
|
system: str = "", temperature: float = 0.7, max_tokens: int = 4096,
|
||||||
retries: int = 3) -> str:
|
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 = {
|
payload = {
|
||||||
"model": model, "messages": [], "stream": False,
|
"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:
|
if system:
|
||||||
payload["messages"].append({"role": "system", "content": system})
|
payload["messages"].append({"role": "system", "content": system})
|
||||||
payload["messages"].append({"role": "user", "content": prompt})
|
payload["messages"].append({"role": "user", "content": prompt})
|
||||||
|
|
||||||
# Try Ollama hosts first
|
# 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 attempt in range(retries):
|
||||||
for h in hosts:
|
for h in hosts:
|
||||||
try:
|
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})
|
proxies={"http": None, "https": None})
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
result = r.json()
|
result = r.json()
|
||||||
if "message" in result:
|
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:
|
if "error" in result:
|
||||||
log.warning(f"Ollama {h} error: {result['error']}")
|
log.warning(f"Ollama {h} error: {result['error']}")
|
||||||
continue
|
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}")
|
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.",
|
system: str = "You are a JSON-only API. Always respond with valid JSON. No markdown, no explanation.",
|
||||||
temperature: float = 0.3) -> dict:
|
temperature: float = 0.3) -> dict:
|
||||||
"""Call LLM and parse JSON response."""
|
"""Call LLM and parse JSON response."""
|
||||||
@@ -228,11 +254,11 @@ def dual_llm_research(prompt: str, system: str = "") -> tuple[str, dict]:
|
|||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
|
|
||||||
def call_ornith():
|
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)
|
system=system, temperature=0.3, max_tokens=4096)
|
||||||
|
|
||||||
def call_qwen():
|
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)
|
system=system, temperature=0.3, max_tokens=2048)
|
||||||
|
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
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."""
|
Respond with a JSON array of strings, each a compelling article title."""
|
||||||
|
|
||||||
try:
|
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):
|
if isinstance(result, list):
|
||||||
return result
|
return result
|
||||||
return list(result.values())[0] if result else []
|
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]:
|
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:
|
if not raw_topics:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Phase 0: Get learning insights from live sites
|
|
||||||
learning_insights = _get_learning_insights()
|
|
||||||
|
|
||||||
# Deduplicate first
|
|
||||||
unique = list(dict.fromkeys(raw_topics))[:50]
|
unique = list(dict.fromkeys(raw_topics))[:50]
|
||||||
|
scored = []
|
||||||
|
import random
|
||||||
|
|
||||||
insights_text = ""
|
for title in unique:
|
||||||
if learning_insights:
|
title_lower = title.lower()
|
||||||
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."
|
# 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
|
||||||
|
|
||||||
prompt = f"""You are a content strategist. Score and categorize these {len(unique)} topics.{insights_text}
|
# 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)
|
||||||
|
|
||||||
Topics:
|
scored.append({
|
||||||
{json.dumps(unique)}
|
"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),
|
||||||
|
})
|
||||||
|
|
||||||
For each topic, return:
|
return scored
|
||||||
- "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 []
|
|
||||||
|
|
||||||
|
|
||||||
def _get_learning_insights() -> dict:
|
def _get_learning_insights() -> dict:
|
||||||
@@ -498,7 +511,8 @@ def _get_learning_insights() -> dict:
|
|||||||
if not ct_ip:
|
if not ct_ip:
|
||||||
continue
|
continue
|
||||||
try:
|
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:
|
if r.status_code == 200:
|
||||||
data = r.json()
|
data = r.json()
|
||||||
popular = data.get("popular", [])
|
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."""
|
Be accurate. Cite real sources. No hallucinations. Respond with ONLY valid JSON."""
|
||||||
|
|
||||||
try:
|
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.")
|
system="You are an expert research analyst. You produce accurate, well-cited research. Never fabricate information.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Research LLM failed: {e}. Falling back to MacBook.")
|
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.")
|
system="You are an expert research analyst. Be accurate and honest.")
|
||||||
|
|
||||||
# Store knowledge package
|
# Store knowledge package
|
||||||
@@ -763,7 +777,7 @@ def real_fact_check(article_text: str, topic_title: str) -> dict:
|
|||||||
claims.append(s[:300])
|
claims.append(s[:300])
|
||||||
|
|
||||||
if len(claims) < 2:
|
if len(claims) < 2:
|
||||||
return {"verified": True, "checked": 0, "issues": []}
|
return {"verified": True, "checked": 0, "verified_count": 0, "issues": []}
|
||||||
|
|
||||||
# Search web for each claim
|
# Search web for each claim
|
||||||
issues = []
|
issues = []
|
||||||
@@ -808,26 +822,26 @@ Dark background matching the site's aesthetic. Abstract but relevant to the topi
|
|||||||
timeout=30)
|
timeout=30)
|
||||||
if r.status_code != 200:
|
if r.status_code != 200:
|
||||||
log.info("Image gen not available — using site hero fallback")
|
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", "")
|
image_url = r.json().get("image_url", "")
|
||||||
if not image_url:
|
if not image_url:
|
||||||
return f"/assets/hero.png"
|
return "/assets/hero.png"
|
||||||
|
|
||||||
# Verify image with local vision model
|
# Verify image with local vision model
|
||||||
try:
|
try:
|
||||||
verify = llm_chat(
|
verify = llm_chat(
|
||||||
f"""Examine this image and verify it's appropriate for an article titled "{title}" on a {vertical} website.
|
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>".""",
|
Is the image relevant, coherent, and free of inappropriate content? Respond ONLY with "PASS" or "FAIL: <reason>".""",
|
||||||
model="minicpm-v4.6:1b",
|
model="qwen3.8:latest",
|
||||||
host=OLLAMA_MACBOOK,
|
host=OLLAMA_SHADOW,
|
||||||
system="You are an image quality reviewer. Be strict but fair.",
|
system="You are an image quality reviewer. Be strict but fair.",
|
||||||
temperature=0.1,
|
temperature=0.1,
|
||||||
max_tokens=50,
|
max_tokens=50,
|
||||||
)
|
)
|
||||||
if "FAIL" in verify:
|
if "FAIL" in verify:
|
||||||
log.warning(f"Image verification failed: {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}")
|
log.info(f"Image verified by vision model: {verify}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"Vision model check skipped: {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
|
return image_url
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"Image generation failed: {e}")
|
log.warning(f"Image generation failed: {e}")
|
||||||
return f"/assets/hero.png"
|
return "/assets/hero.png"
|
||||||
|
|
||||||
|
|
||||||
# ─── Writing Pipeline ──────────────────────────────────────────────
|
# ─── Writing Pipeline ──────────────────────────────────────────────
|
||||||
@@ -863,7 +877,7 @@ Generate an outline appropriate for this format.
|
|||||||
Respond with JSON:
|
Respond with JSON:
|
||||||
{{"sections": [{{"heading": "...", "subsections": ["..."]}}, ...], "faq_questions": ["..."], "cta": "..."}}"""
|
{{"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
|
# Agent 2: Draft with format guidance
|
||||||
draft_prompt = f"""Write a {fmt['name']} format article.
|
draft_prompt = f"""Write a {fmt['name']} format article.
|
||||||
@@ -890,7 +904,7 @@ Requirements:
|
|||||||
|
|
||||||
Respond with the FULL Markdown article. No JSON wrapper."""
|
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.",
|
system="You are an expert writer. Write clear, accurate, engaging content. No AI clichés. No fluff.",
|
||||||
temperature=0.75, max_tokens=8192)
|
temperature=0.75, max_tokens=8192)
|
||||||
|
|
||||||
@@ -902,14 +916,14 @@ ARTICLE:
|
|||||||
{draft}
|
{draft}
|
||||||
|
|
||||||
Return the edited article in full Markdown. No JSON wrapper.""",
|
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
|
# Agent 4: SEO
|
||||||
seo = llm_json(f"""Optimize this article for SEO.
|
seo = llm_json(f"""Optimize this article for SEO.
|
||||||
TITLE: {topic_title}
|
TITLE: {topic_title}
|
||||||
FIRST 500 CHARS: {edited[:500]}
|
FIRST 500 CHARS: {edited[:500]}
|
||||||
Respond with JSON: {{"seo_title": "...", "seo_description": "...", "keywords": ["..."]}}""",
|
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)
|
# Agent 5: Real Fact Check (web-verified)
|
||||||
factcheck = real_fact_check(edited, topic_title)
|
factcheck = real_fact_check(edited, topic_title)
|
||||||
@@ -930,7 +944,7 @@ ARTICLE:
|
|||||||
{edited}
|
{edited}
|
||||||
|
|
||||||
Return the expanded article in full Markdown. No JSON wrapper.""",
|
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)
|
passed, issues = quality_gate(edited, topic_title, vertical)
|
||||||
|
|
||||||
if not passed:
|
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")
|
log.warning(f"No CT IP for {vertical} — skipping publish")
|
||||||
continue
|
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:
|
for article in articles:
|
||||||
try:
|
try:
|
||||||
r = requests.post(api_url, json=article,
|
r = requests.post(api_url, json=article,
|
||||||
@@ -1421,6 +1436,11 @@ def run_daily_pipeline(max_articles: int = 3):
|
|||||||
timeout=15)
|
timeout=15)
|
||||||
if r.status_code in (200, 201):
|
if r.status_code in (200, 201):
|
||||||
log.info(f" 📤 Published to {vertical}: {article.get('title', '')[:60]}")
|
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:
|
else:
|
||||||
log.warning(f" ❌ {vertical} API returned {r.status_code}: {r.text[:100]}")
|
log.warning(f" ❌ {vertical} API returned {r.status_code}: {r.text[:100]}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -6,18 +6,47 @@ import os
|
|||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import hashlib
|
import hashlib
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
from functools import wraps
|
|
||||||
from flask import Flask, request, jsonify, render_template_string, g, abort, Response
|
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 ────────────────────────────────────────────────────────
|
# ─── Config ────────────────────────────────────────────────────────
|
||||||
VERTICAL = os.environ.get("PUBLISHER_VERTICAL", "guides")
|
VERTICAL = os.environ.get("PUBLISHER_VERTICAL", "guides")
|
||||||
DOMAIN = f"{VERTICAL}.thetempleofdoom.com"
|
DOMAIN = f"{VERTICAL}.thetempleofdoom.com"
|
||||||
DB_PATH = Path(f"/var/lib/publisher/{VERTICAL}.db")
|
DB_PATH = Path(f"/var/lib/publisher/{VERTICAL}.db")
|
||||||
SECRET = os.environ.get("PUBLISHER_SECRET", "auto-publish-2026")
|
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
|
# Per-vertical identity
|
||||||
IDENTITIES = {
|
IDENTITIES = {
|
||||||
"ai": {
|
"ai": {
|
||||||
@@ -146,6 +175,7 @@ NETWORK_SITES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
IDENTITY = IDENTITIES.get(VERTICAL, IDENTITIES["guides"])
|
IDENTITY = IDENTITIES.get(VERTICAL, IDENTITIES["guides"])
|
||||||
|
IDENTITY = {**IDENTITY, "umami_id": UMAMI_ID}
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
@@ -453,6 +483,17 @@ def sitemap():
|
|||||||
return Response(build_sitemap_xml(), mimetype="application/xml")
|
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>")
|
@app.route("/tag/<tag>")
|
||||||
def tag_page(tag):
|
def tag_page(tag):
|
||||||
"""Aggregate all articles with a given tag."""
|
"""Aggregate all articles with a given tag."""
|
||||||
@@ -497,8 +538,10 @@ def api_publish():
|
|||||||
|
|
||||||
slug = data.get("slug", "")
|
slug = data.get("slug", "")
|
||||||
title = data.get("title", "")
|
title = data.get("title", "")
|
||||||
content_html = data.get("content_html", data.get("content_md", ""))
|
content_md = data.get("content_md") or data.get("content") or ""
|
||||||
content_md = data.get("content_md", "")
|
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", ""))
|
excerpt = data.get("excerpt", data.get("seo_description", ""))
|
||||||
seo_title = data.get("seo_title", title)
|
seo_title = data.get("seo_title", title)
|
||||||
seo_description = data.get("seo_description", "")
|
seo_description = data.get("seo_description", "")
|
||||||
@@ -794,6 +837,7 @@ HOME_TEMPLATE = """<!DOCTYPE html>
|
|||||||
.hero-stats{flex-wrap:wrap;gap:0.75rem}
|
.hero-stats{flex-wrap:wrap;gap:0.75rem}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<script async src="https://analytics.thetempleofdoom.com/script.js" data-website-id="{{ umami_id }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<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 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}
|
.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>
|
</style>
|
||||||
|
<script async src="https://analytics.thetempleofdoom.com/script.js" data-website-id="{{ umami_id }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
@@ -1088,7 +1133,7 @@ ARTICLE_TEMPLATE = """<!DOCTYPE html>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="article-content">
|
<div class="article-content">
|
||||||
{{ article.content_html|safe }}
|
{{ (article.content_md or article.content_html)|md|safe }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="article-footer">
|
<footer class="article-footer">
|
||||||
@@ -1257,6 +1302,7 @@ SEARCH_TEMPLATE = """<!DOCTYPE html>
|
|||||||
.result p{color:var(--text-muted);font-size:0.88rem}
|
.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}
|
footer{border-top:1px solid var(--border);padding:2rem 1.5rem;text-align:center;color:var(--text-muted);font-size:0.8rem}
|
||||||
</style>
|
</style>
|
||||||
|
<script async src="https://analytics.thetempleofdoom.com/script.js" data-website-id="{{ umami_id }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<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}
|
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)}
|
a{color:var(--accent)}
|
||||||
</style>
|
</style>
|
||||||
|
<script async src="https://analytics.thetempleofdoom.com/script.js" data-website-id="{{ umami_id }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header><nav><a href="/" class="logo">{{ name }}</a></nav></header>
|
<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}
|
p{color:var(--text-muted);margin:1rem 0}
|
||||||
a{color:var(--primary)}
|
a{color:var(--primary)}
|
||||||
</style>
|
</style>
|
||||||
|
<script async src="https://analytics.thetempleofdoom.com/script.js" data-website-id="{{ umami_id }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div>
|
<div>
|
||||||
@@ -1364,6 +1412,11 @@ def from_json_filter(s):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@app.template_filter("md")
|
||||||
|
def md_filter(s):
|
||||||
|
return md_to_html(s or "")
|
||||||
|
|
||||||
|
|
||||||
# ─── Main ──────────────────────────────────────────────────────────
|
# ─── Main ──────────────────────────────────────────────────────────
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import argparse
|
import argparse
|
||||||
|
|||||||
Reference in New Issue
Block a user