Brettapps's picture
Build Fair Dinkum Publishing ebook studio
f61aec9 verified
Raw
History Blame Contribute Delete
78.2 kB
from __future__ import annotations
import base64
import hashlib
import hmac
import html
import io
import json
import os
import re
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
import httpx
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from PIL import Image, ImageDraw, ImageFont
from pydantic import BaseModel, Field
try:
from duckduckgo_search import DDGS
except Exception: # pragma: no cover - optional dependency
DDGS = None
try:
from openai import OpenAI
except Exception: # pragma: no cover - optional dependency
OpenAI = None
try:
from supabase import create_client
except Exception: # pragma: no cover - optional dependency
create_client = None
load_dotenv()
APP_NAME = "Fair Dinkum Publishing Studio"
BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = BASE_DIR / "data"
EXPORTS_DIR = BASE_DIR / "exports"
LIBRARY_PATH = DATA_DIR / "library.json"
MEMBERS_PATH = DATA_DIR / "members.json"
DATA_DIR.mkdir(parents=True, exist_ok=True)
EXPORTS_DIR.mkdir(parents=True, exist_ok=True)
app = FastAPI(title=APP_NAME)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.mount("/exports", StaticFiles(directory=str(EXPORTS_DIR)), name="exports")
@dataclass(frozen=True)
class SeedBook:
slug: str
title: str
subtitle: str
description: str
category: str
audience: str
length: str
tags: tuple[str, ...]
accent: tuple[str, str]
featured: bool = False
SEED_BOOKS: List[SeedBook] = [
SeedBook(
slug="ebook-market-research-blueprint",
title="Ebook Market Research Blueprint",
subtitle="Find demand before you write",
description="A practical research-first workbook for spotting profitable topics, reading public signals, and turning a vague idea into a marketable plan.",
category="Research",
audience="Independent publishers",
length="58 pages",
tags=("research", "demand", "strategy"),
accent=("#0F766E", "#042F2E"),
featured=True,
),
SeedBook(
slug="seo-keyword-stack",
title="SEO Keyword Stack",
subtitle="Build ideas people search for",
description="A keyword-led system for creating ebook ideas, subtitles, and product page copy that matches reader intent.",
category="SEO",
audience="Creators and marketers",
length="44 pages",
tags=("keywords", "intent", "copy"),
accent=("#B45309", "#451A03"),
),
SeedBook(
slug="ebook-build-playbook",
title="Ebook Build Playbook",
subtitle="Outline, draft, package, publish",
description="An end-to-end writing workflow that turns research into a publishable ebook without over-engineering the process.",
category="Writing",
audience="Solo authors",
length="72 pages",
tags=("outline", "drafting", "publishing"),
accent=("#1D4ED8", "#172554"),
featured=True,
),
SeedBook(
slug="thumbnail-design-system",
title="Thumbnail Design System",
subtitle="Covers that hold up in a grid",
description="A clean visual system for ebook covers, series art, and thumbnail previews that stay readable at small sizes.",
category="Design",
audience="Self publishers",
length="39 pages",
tags=("covers", "thumbnails", "brand"),
accent=("#14532D", "#052E16"),
),
SeedBook(
slug="membership-offer-playbook",
title="Membership Offer Playbook",
subtitle="Subscription packaging for readers",
description="A guide to turning a book catalog into recurring revenue with clear tiers, strong onboarding, and useful member benefits.",
category="Membership",
audience="Subscription businesses",
length="51 pages",
tags=("stripe", "membership", "pricing"),
accent=("#7C2D12", "#431407"),
),
SeedBook(
slug="catalog-copy-that-converts",
title="Catalog Copy That Converts",
subtitle="Write blurbs people click",
description="A copywriting system for ebook descriptions, benefit-led blurbs, and bookstore metadata that improves click-through.",
category="Copywriting",
audience="Publishing teams",
length="46 pages",
tags=("catalog", "blurb", "metadata"),
accent=("#312E81", "#1E1B4B"),
),
]
PALETTES = [
("#0F766E", "#042F2E"),
("#B45309", "#451A03"),
("#1D4ED8", "#172554"),
("#14532D", "#052E16"),
("#7C2D12", "#431407"),
("#7C3AED", "#312E81"),
]
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "").strip()
SUPABASE_URL = os.getenv("SUPABASE_URL", "").strip()
SUPABASE_SERVICE_KEY = (
os.getenv("SUPABASE_SECRET_KEY", "").strip()
or os.getenv("SUPABASE_SERVICE_ROLE_KEY", "").strip()
)
SUPABASE_PUBLISHABLE_KEY = os.getenv("SUPABASE_PUBLISHABLE_KEY", "").strip()
SUPABASE_JWKS_URL = os.getenv("SUPABASE_JWKS_URL", "").strip()
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "").strip()
STRIPE_PRICE_ID_MONTHLY = os.getenv("STRIPE_PRICE_ID_MONTHLY", "").strip()
STRIPE_PRICE_ID_ANNUAL = os.getenv("STRIPE_PRICE_ID_ANNUAL", "").strip()
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "").strip()
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "").strip()
class ResearchRequest(BaseModel):
topic: str = Field(..., min_length=2, max_length=120)
market: str = Field(default="United States", max_length=80)
audience: str = Field(default="independent readers", max_length=120)
tone: str = Field(default="practical", max_length=40)
source_limit: int = Field(default=6, ge=3, le=12)
class BuildRequest(BaseModel):
topic: str = Field(..., min_length=2, max_length=120)
market: str = Field(default="United States", max_length=80)
audience: str = Field(default="independent readers", max_length=120)
tone: str = Field(default="practical", max_length=40)
chapter_count: int = Field(default=6, ge=4, le=8)
idea_index: int = Field(default=0, ge=0, le=20)
research: Optional[Dict[str, Any]] = None
class CheckoutRequest(BaseModel):
email: str = Field(..., min_length=5, max_length=180)
name: Optional[str] = Field(default=None, max_length=120)
plan: str = Field(default="monthly", max_length=20)
public_base_url: Optional[str] = Field(default=None, max_length=200)
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _load_json(path: Path, default: Any) -> Any:
if not path.exists():
return default
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return default
def _save_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
def _slugify(text: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
return slug or "ebook"
def _esc(value: Any) -> str:
return html.escape("" if value is None else str(value))
def _font(size: int, bold: bool = False) -> ImageFont.ImageFont:
candidates = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
]
for candidate in candidates:
if os.path.exists(candidate):
return ImageFont.truetype(candidate, size=size)
return ImageFont.load_default()
def _wrap_lines(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_width: int) -> List[str]:
words = text.split()
lines: List[str] = []
current = ""
for word in words:
trial = f"{current} {word}".strip()
if not current or draw.textbbox((0, 0), trial, font=font)[2] <= max_width:
current = trial
else:
lines.append(current)
current = word
if current:
lines.append(current)
return lines
def _palette_for(text: str) -> tuple[str, str]:
idx = abs(hash(text)) % len(PALETTES)
return PALETTES[idx]
def _base_url(request: Request) -> str:
if PUBLIC_BASE_URL:
return PUBLIC_BASE_URL.rstrip("/")
return str(request.base_url).rstrip("/")
def _status_snapshot() -> Dict[str, Any]:
return {
"openai_ready": bool(OPENAI_API_KEY and OpenAI is not None),
"supabase_ready": bool(SUPABASE_URL and (SUPABASE_SERVICE_KEY or SUPABASE_PUBLISHABLE_KEY) and create_client is not None),
"stripe_ready": bool(STRIPE_SECRET_KEY and STRIPE_PRICE_ID_MONTHLY),
"library_count": len(_all_library_records()),
"member_count": len(_load_json(MEMBERS_PATH, [])),
"jwks_configured": bool(SUPABASE_JWKS_URL),
}
def _openai_client() -> Optional[Any]:
if not OPENAI_API_KEY or OpenAI is None:
return None
return OpenAI(api_key=OPENAI_API_KEY)
def _call_openai_json(system_prompt: str, user_prompt: str, *, temperature: float = 0.2) -> Optional[Dict[str, Any]]:
client = _openai_client()
if client is None:
return None
try:
completion = client.chat.completions.create(
model=OPENAI_MODEL,
temperature=temperature,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
)
content = completion.choices[0].message.content or "{}"
return json.loads(content)
except Exception as exc:
print(f"OpenAI JSON generation failed: {exc}")
return None
def _search_region_for_market(market: str) -> str:
market_lower = market.lower()
if "australia" in market_lower:
return "au-en"
if "united states" in market_lower or "usa" in market_lower or "us " in market_lower:
return "us-en"
if "united kingdom" in market_lower or "uk" in market_lower:
return "uk-en"
return "wt-wt"
def _build_queries(topic: str, market: str) -> List[str]:
return [
f"{topic} ebook market trends {market}",
f"{topic} reader pain points {market}",
f"best {topic} ebooks {market}",
f"{topic} seo keywords",
f"{topic} buying intent phrases",
f"{topic} content gaps",
]
def _collect_sources(topic: str, market: str, limit: int = 6) -> List[Dict[str, str]]:
queries = _build_queries(topic, market)
region = _search_region_for_market(market)
collected: List[Dict[str, str]] = []
seen_urls: set[str] = set()
if DDGS is not None:
per_query = max(1, min(3, limit // max(1, len(queries))))
try:
with DDGS() as ddgs:
for query in queries:
for result in ddgs.text(
query,
region=region,
safesearch="moderate",
max_results=per_query,
):
item = {
"query": query,
"title": (result.get("title") or "").strip(),
"url": result.get("href") or result.get("url") or "",
"snippet": (result.get("body") or result.get("snippet") or "").strip(),
}
url = item["url"]
if not url or url in seen_urls:
continue
seen_urls.add(url)
collected.append(item)
if len(collected) >= limit:
return collected
except Exception as exc:
print(f"DuckDuckGo search failed: {exc}")
if not collected:
for index, query in enumerate(queries[:limit], start=1):
collected.append(
{
"query": query,
"title": f"{topic} market signal {index}",
"url": "",
"snippet": f"Fallback search signal for {topic} in {market}.",
}
)
return collected[:limit]
def _fallback_keyword_clusters(topic: str) -> List[Dict[str, Any]]:
core = topic.lower()
return [
{
"cluster": f"{topic} for beginners",
"keywords": [f"{core} for beginners", f"how to start {core}", f"{core} guide"],
"intent": "entry-level",
"angle": "Start here and show the first win quickly.",
},
{
"cluster": f"best {topic} ideas",
"keywords": [f"best {core}", f"{core} ideas", f"{core} examples"],
"intent": "comparison",
"angle": "Frame the book as the practical shortlist readers want.",
},
{
"cluster": f"{topic} checklist",
"keywords": [f"{core} checklist", f"{core} template", f"{core} workbook"],
"intent": "action",
"angle": "Turn advice into a repeatable process.",
},
{
"cluster": f"advanced {topic}",
"keywords": [f"advanced {core}", f"{core} strategy", f"{core} system"],
"intent": "advanced",
"angle": "Give the reader a second-level framework once the basics are clear.",
},
]
def _fallback_book_ideas(topic: str, market: str, audience: str, research: Dict[str, Any]) -> List[Dict[str, Any]]:
keywords = research.get("seo_clusters", [])
keyword_pool: List[str] = []
for cluster in keywords:
keyword_pool.extend(cluster.get("keywords", []))
if not keyword_pool:
keyword_pool = [f"{topic} guide", f"{topic} for beginners", f"best {topic}"]
chapter_plans = [
[
"Why the market wants this book",
"How to position the promise",
"Keyword research and reader intent",
"Outline, chapters, and flow",
"Writing the manuscript",
"Publishing, pricing, and launch",
],
[
"Reader pain points and demand",
"SEO angles and long-tail queries",
"Book structure and chapter map",
"Drafting with clarity and pace",
"Cover, product page, and metadata",
"Membership, upsells, and retention",
],
[
"Research signals that matter",
"Choosing a title that sells",
"Creating the chapter skeleton",
"Writing practical content",
"Packaging for the marketplace",
"Launching the offer",
],
]
titles = [
f"{topic}: The Practical Playbook",
f"The {topic} Blueprint",
f"{topic} Made Simple",
f"The {topic} Growth System",
f"{topic} SEO and Sales Guide",
]
subtitles = [
f"SEO-ready ideas for {audience} in {market}",
f"A clear path from research to launch in {market}",
f"How to turn search intent into a publishable ebook",
f"Build a product readers can find and buy",
f"Create a book that fits a catalog and a subscription",
]
ideas: List[Dict[str, Any]] = []
for index in range(5):
ideas.append(
{
"title": titles[index],
"subtitle": subtitles[index],
"description": (
f"This angle helps {audience.lower()} turn {topic.lower()} into a focused, searchable, "
f"and commercially useful ebook for {market}."
),
"seo_keywords": [
keyword_pool[index % len(keyword_pool)],
keyword_pool[(index + 1) % len(keyword_pool)],
f"{topic} guide",
f"{topic} ebook",
f"best {topic.lower()}",
],
"positioning": f"Useful for readers who want a practical {topic.lower()} outcome, not theory.",
"why_it_works": "It matches search intent, gives the reader a clear promise, and converts naturally into product copy.",
"chapter_plan": chapter_plans[index % len(chapter_plans)],
"cover_direction": "High-contrast editorial cover with bold title, clear subtitle, and a premium band of color.",
"price_point": "$14.99",
}
)
return ideas
def _normalize_research(report: Dict[str, Any], *, topic: str, market: str, audience: str, sources: List[Dict[str, str]]) -> Dict[str, Any]:
normalized = {
"topic": topic,
"market": market,
"audience": audience,
"executive_summary": report.get("executive_summary")
or f"{topic} shows enough public search interest to justify a focused ebook for {audience} in {market}.",
"demand_signals": report.get("demand_signals") or [
f"Readers ask for practical {topic.lower()} guidance.",
f"Search intent favors step-by-step answers and examples.",
f"Comparison and checklist queries are common entry points.",
],
"reader_pain_points": report.get("reader_pain_points") or [
f"Readers are unsure how to start with {topic.lower()}.",
f"They want a clearer path from idea to implementation.",
f"They need concise, actionable steps instead of broad advice.",
],
"seo_clusters": report.get("seo_clusters") or _fallback_keyword_clusters(topic),
"book_ideas": report.get("book_ideas") or _fallback_book_ideas(topic, market, audience, report),
"pricing_guidance": report.get("pricing_guidance")
or "Position the ebook as a practical premium guide in the $12 to $19 range, then use membership for recurring value.",
"launch_notes": report.get("launch_notes") or [
"Lead with a keyword-led title and subtitle.",
"Use a cover that reads clearly in thumbnail size.",
"Add a members-only upsell around the catalog.",
],
"risks": report.get("risks") or [
"Overly broad positioning will blur the search intent.",
"Weak cover readability will reduce click-through.",
"Thin product pages will underperform even if the content is good.",
],
"sources": sources,
"generated_at": _now_iso(),
}
if len(normalized["book_ideas"]) < 5:
normalized["book_ideas"] = (normalized["book_ideas"] + _fallback_book_ideas(topic, market, audience, normalized))[:5]
return normalized
def _research_from_openai(topic: str, market: str, audience: str, tone: str, sources: List[Dict[str, str]]) -> Optional[Dict[str, Any]]:
user_prompt = {
"topic": topic,
"market": market,
"audience": audience,
"tone": tone,
"sources": sources,
}
system_prompt = (
"You are a senior ebook market researcher and publishing strategist. "
"Return only valid JSON with actionable findings, SEO keyword clusters, and five book ideas."
)
prompt = (
"Return a strict JSON object with this structure:\n"
"{\n"
' "executive_summary": "string",\n'
' "demand_signals": ["string"],\n'
' "reader_pain_points": ["string"],\n'
' "seo_clusters": [{"cluster":"string","keywords":["string"],"intent":"string","angle":"string"}],\n'
' "book_ideas": [{"title":"string","subtitle":"string","description":"string","seo_keywords":["string"],"positioning":"string","why_it_works":"string","chapter_plan":["string"],"cover_direction":"string","price_point":"string"}],\n'
' "pricing_guidance": "string",\n'
' "launch_notes": ["string"],\n'
' "risks": ["string"]\n'
"}\n\n"
f"Research brief:\n{json.dumps(user_prompt, indent=2)}"
)
return _call_openai_json(system_prompt, prompt, temperature=0.2)
def build_research_report(*, topic: str, market: str, audience: str, tone: str, source_limit: int) -> Dict[str, Any]:
sources = _collect_sources(topic, market, limit=source_limit)
report = None
if OPENAI_API_KEY and OpenAI is not None:
report = _research_from_openai(topic, market, audience, tone, sources)
if report is None:
report = {
"executive_summary": (
f"{topic} has a workable search footprint in {market}. The strongest opportunity is to package the topic "
f"as a clear, practical ebook for {audience}."
),
"demand_signals": [
f"Searchers want direct answers about {topic.lower()}.",
f"How-to and checklist queries are recurring signals.",
f"Readers respond to narrower, outcome-led positioning.",
],
"reader_pain_points": [
f"People struggle to turn {topic.lower()} into a first purchase.",
f"They want a roadmap that removes setup friction.",
f"They need better titles, covers, and metadata to choose from.",
],
"seo_clusters": _fallback_keyword_clusters(topic),
"book_ideas": _fallback_book_ideas(topic, market, audience, {}),
"pricing_guidance": "Price the standalone ebook in the middle of the impulse-buy range, then use membership for ongoing access.",
"launch_notes": [
"Make the promise specific.",
"Keep the thumbnail readable.",
"Bundle a membership upsell with the catalog.",
],
"risks": [
"Generic positioning makes the book invisible.",
"Thin metadata weakens discovery.",
"A weak product page reduces conversion.",
],
}
normalized = _normalize_research(report, topic=topic, market=market, audience=audience, sources=sources)
normalized["status"] = "complete"
return normalized
def _selected_idea_from_research(research: Dict[str, Any], idea_index: int) -> Dict[str, Any]:
ideas = research.get("book_ideas") or _fallback_book_ideas(
research["topic"], research["market"], research["audience"], research
)
if not ideas:
ideas = _fallback_book_ideas(research["topic"], research["market"], research["audience"], research)
index = max(0, min(idea_index, len(ideas) - 1))
selected = dict(ideas[index])
selected["index"] = index
selected["keyword_focus"] = selected.get("seo_keywords", [])[:3]
return selected
def _chapter_blueprint(selected_idea: Dict[str, Any], research: Dict[str, Any], chapter_count: int) -> List[Dict[str, Any]]:
chapter_plan = list(selected_idea.get("chapter_plan") or [])
if not chapter_plan:
chapter_plan = [
f"Why {selected_idea['title']} matches the market",
"Keyword positioning and reader intent",
"Outline and manuscript structure",
"Drafting the chapters",
"Cover, metadata, and pricing",
"Launch and membership growth",
]
if len(chapter_plan) < chapter_count:
chapter_plan.extend(
f"Extended chapter {number}"
for number in range(len(chapter_plan) + 1, chapter_count + 1)
)
keyword_pool = selected_idea.get("seo_keywords") or ["ebook", "guide", "strategy"]
demand = research.get("demand_signals") or []
pain_points = research.get("reader_pain_points") or []
chapters: List[Dict[str, Any]] = []
for number, title in enumerate(chapter_plan[:chapter_count], start=1):
primary_keyword = keyword_pool[(number - 1) % len(keyword_pool)]
signal = demand[(number - 1) % len(demand)] if demand else primary_keyword
pain = pain_points[(number - 1) % len(pain_points)] if pain_points else selected_idea["subtitle"]
sections = [
{
"heading": "What the reader is trying to solve",
"paragraphs": [
f"{title} should start by showing why {primary_keyword} matters to readers in {research['market']}.",
f"For {research['audience']}, the book should be written around one visible outcome instead of broad theory.",
],
},
{
"heading": "How to make the chapter useful",
"paragraphs": [
f"Turn the search signal '{signal}' into a concrete workflow and keep the steps short enough to act on immediately.",
f"The reader pain point '{pain}' is a cue to add examples, checklists, and a simple decision path.",
],
},
{
"heading": "Where the SEO lives",
"paragraphs": [
f"Use {', '.join(keyword_pool[:3])} naturally in the chapter heading, subheads, and product copy.",
f"Keep the language plain and specific so the ebook reads well on a catalog page and in search results.",
],
},
]
chapters.append(
{
"number": number,
"title": title,
"summary": f"Build a practical chapter around {primary_keyword} and the reader's main question.",
"sections": sections,
}
)
return chapters
def _chapter_to_markdown(chapter: Dict[str, Any]) -> str:
lines = [f"# Chapter {chapter['number']}: {chapter['title']}", "", chapter["summary"], ""]
for section in chapter["sections"]:
lines.extend([f"## {section['heading']}", ""])
for paragraph in section["paragraphs"]:
lines.extend([paragraph, ""])
return "\n".join(lines).strip() + "\n"
def _render_paragraphs(paragraphs: List[str]) -> str:
return "".join(f"<p>{_esc(paragraph)}</p>" for paragraph in paragraphs)
def _render_bullets(items: List[str]) -> str:
return "<ul>" + "".join(f"<li>{_esc(item)}</li>" for item in items) + "</ul>"
def _render_cover_png(title: str, subtitle: str, category: str, accent: tuple[str, str]) -> str:
width, height = 1200, 1600
img = Image.new("RGB", (width, height), accent[1])
draw = ImageDraw.Draw(img)
draw.rectangle((0, 0, width, int(height * 0.42)), fill=accent[0])
draw.rounded_rectangle((56, 56, width - 56, height - 56), radius=48, outline=(255, 255, 255), width=4)
draw.rounded_rectangle((90, 90, width - 90, height - 90), radius=30, outline=(255, 255, 255, 170), width=2)
label_font = _font(40, bold=True)
title_font = _font(84, bold=True)
subtitle_font = _font(46, bold=False)
meta_font = _font(34, bold=False)
draw.text((112, 126), "FAIR DINKUM PUBLISHING", font=label_font, fill=(255, 255, 255))
draw.line((112, 218, width - 112, 218), fill=(255, 255, 255), width=2)
title_lines = _wrap_lines(draw, title, title_font, width - 224)
y = 440
for line in title_lines[:4]:
draw.text((112, y), line, font=title_font, fill=(255, 255, 255))
y += 88
subtitle_lines = _wrap_lines(draw, subtitle, subtitle_font, width - 224)
y = 860
for line in subtitle_lines[:4]:
draw.text((112, y), line, font=subtitle_font, fill=(246, 248, 250))
y += 56
draw.text((112, 1390), category.upper(), font=meta_font, fill=(255, 255, 255))
buffer = io.BytesIO()
img.save(buffer, format="PNG", optimize=True)
return base64.b64encode(buffer.getvalue()).decode("ascii")
def _render_ebook_html(
*,
title: str,
subtitle: str,
description: str,
research: Dict[str, Any],
selected_idea: Dict[str, Any],
chapters: List[Dict[str, Any]],
cover_b64: str,
) -> str:
chapter_nav = "".join(
f'<li><a href="#chapter-{chapter["number"]}">Chapter {chapter["number"]}: {_esc(chapter["title"])}</a></li>'
for chapter in chapters
)
chapter_sections = []
for chapter in chapters:
section_html = []
for section in chapter["sections"]:
section_html.append(
f"""
<section class="book-section">
<h3>{_esc(section['heading'])}</h3>
{_render_paragraphs(section['paragraphs'])}
</section>
"""
)
chapter_sections.append(
f"""
<article class="chapter" id="chapter-{chapter['number']}">
<div class="chapter-head">
<span>Chapter {chapter['number']}</span>
<h2>{_esc(chapter['title'])}</h2>
<p>{_esc(chapter['summary'])}</p>
</div>
{''.join(section_html)}
</article>
"""
)
source_list = "".join(
f"<li><strong>{_esc(source['title'])}</strong><br><span>{_esc(source['snippet'])}</span></li>"
for source in research.get("sources", [])
)
keyword_tags = "".join(f"<span class='tag'>{_esc(keyword)}</span>" for keyword in selected_idea.get("seo_keywords", []))
return f"""<!doctype html>
<html lang="en-AU">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{_esc(title)}</title>
<style>
:root {{
--ink: #10202b;
--muted: #56616a;
--panel: #ffffff;
--line: #dde2dd;
--accent: #0f766e;
--accent2: #b45309;
--bg: #f6f3ed;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: linear-gradient(180deg, #f6f3ed 0%, #fbfbfb 100%);
color: var(--ink);
}}
main {{ max-width: 980px; margin: 0 auto; padding: 20px; }}
.cover {{
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 18px 36px rgba(16, 32, 43, 0.08);
margin-bottom: 18px;
}}
.cover img {{ display: block; width: 100%; }}
.cover-body {{ padding: 22px; }}
.cover-body h1 {{ margin: 0 0 8px; font-size: 34px; line-height: 1.1; }}
.cover-body p {{ margin: 0; color: var(--muted); line-height: 1.6; }}
.badge-row, .tag-row {{ display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }}
.badge, .tag {{
display: inline-flex; align-items: center;
border-radius: 999px; padding: 6px 10px; font-size: 12px; font-weight: 700;
background: #f8fafc; color: #41515b; border: 1px solid var(--line);
}}
.layout {{
display: grid;
grid-template-columns: 260px 1fr;
gap: 18px;
margin-top: 18px;
}}
nav, .panel {{
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
padding: 16px;
}}
nav h2, .panel h2, .panel h3 {{ margin-top: 0; }}
nav ul {{ list-style: none; padding: 0; margin: 0; }}
nav li {{ margin-bottom: 8px; }}
nav a {{ color: var(--accent); text-decoration: none; }}
.chapter {{
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px solid var(--line);
}}
.chapter-head span {{ color: var(--accent2); font-weight: 800; font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; }}
.chapter-head h2 {{ margin: 6px 0 6px; font-size: 24px; }}
.chapter-head p {{ margin: 0 0 10px; color: var(--muted); }}
.book-section h3 {{ margin-bottom: 8px; font-size: 18px; color: var(--accent); }}
p {{ line-height: 1.7; margin: 0 0 14px; }}
ul {{ margin: 8px 0 14px 20px; }}
.meta-list li {{ margin-bottom: 10px; }}
.footer {{
margin: 18px 0 0;
color: var(--muted);
font-size: 13px;
text-align: center;
}}
@media (max-width: 820px) {{
.layout {{ grid-template-columns: 1fr; }}
}}
</style>
</head>
<body>
<main>
<section class="cover">
<img src="data:image/png;base64,{cover_b64}" alt="{_esc(title)} cover">
<div class="cover-body">
<h1>{_esc(title)}</h1>
<p>{_esc(subtitle)}</p>
<div class="badge-row">
<span class="badge">{_esc(selected_idea.get('price_point', '$14.99'))}</span>
<span class="badge">{_esc(selected_idea.get('positioning', 'Practical ebook'))}</span>
<span class="badge">{_esc(research.get('market', ''))}</span>
</div>
<div class="tag-row">{keyword_tags}</div>
</div>
</section>
<div class="layout">
<nav>
<h2>Contents</h2>
<ul>
<li><a href="#research">Research summary</a></li>
{chapter_nav}
<li><a href="#sources">Sources</a></li>
</ul>
</nav>
<div>
<section class="panel" id="research">
<h2>Research summary</h2>
<p>{_esc(research.get('executive_summary', ''))}</p>
<h3>Demand signals</h3>
<ul class="meta-list">{''.join(f'<li>{_esc(item)}</li>' for item in research.get('demand_signals', []))}</ul>
<h3>Reader pain points</h3>
<ul class="meta-list">{''.join(f'<li>{_esc(item)}</li>' for item in research.get('reader_pain_points', []))}</ul>
</section>
{''.join(chapter_sections)}
<section class="panel" id="sources">
<h2>Sources</h2>
<ul class="meta-list">{source_list}</ul>
</section>
<section class="panel">
<h2>Next step</h2>
<p>{_esc(description)}</p>
</section>
</div>
</div>
<div class="footer">Generated by Fair Dinkum Publishing Studio</div>
</main>
</body>
</html>"""
def _render_ebook_markdown(
*,
title: str,
subtitle: str,
research: Dict[str, Any],
selected_idea: Dict[str, Any],
chapters: List[Dict[str, Any]],
) -> str:
parts = [f"# {title}", "", subtitle, "", "## Research summary", "", research.get("executive_summary", ""), ""]
parts.append("### Demand signals")
parts.extend(f"- {item}" for item in research.get("demand_signals", []))
parts.append("")
parts.append("### Reader pain points")
parts.extend(f"- {item}" for item in research.get("reader_pain_points", []))
parts.append("")
for chapter in chapters:
parts.append(f"## Chapter {chapter['number']}: {chapter['title']}")
parts.append("")
parts.append(chapter["summary"])
parts.append("")
for section in chapter["sections"]:
parts.append(f"### {section['heading']}")
parts.append("")
for paragraph in section["paragraphs"]:
parts.append(paragraph)
parts.append("")
parts.append("## Sources")
parts.extend(f"- {source['title']}: {source.get('snippet', '')}" for source in research.get("sources", []))
parts.append("")
parts.append(f"## Positioning")
parts.append(selected_idea.get("positioning", ""))
return "\n".join(parts).strip() + "\n"
def _supabase_client() -> Optional[Any]:
if create_client is None:
return None
key = SUPABASE_SERVICE_KEY or SUPABASE_PUBLISHABLE_KEY
if not SUPABASE_URL or not key:
return None
try:
return create_client(SUPABASE_URL, key)
except Exception as exc:
print(f"Supabase client init failed: {exc}")
return None
def _supabase_insert(table: str, payload: Dict[str, Any]) -> None:
client = _supabase_client()
if client is None:
return
try:
client.table(table).upsert(payload).execute()
except Exception as exc:
print(f"Supabase insert into {table} failed: {exc}")
def _merge_records(records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
merged: Dict[str, Dict[str, Any]] = {}
for record in records:
slug = record["slug"]
merged[slug] = record
return sorted(merged.values(), key=lambda item: item.get("created_at", ""), reverse=True)
def _all_library_records() -> List[Dict[str, Any]]:
records = []
for seed in SEED_BOOKS:
records.append(
{
"slug": seed.slug,
"title": seed.title,
"subtitle": seed.subtitle,
"description": seed.description,
"category": seed.category,
"audience": seed.audience,
"length": seed.length,
"tags": list(seed.tags),
"featured": seed.featured,
"kind": "seed",
"cover_url": _seed_cover_url(seed),
"html_url": "",
"markdown_url": "",
"report_url": "",
"created_at": "",
}
)
local_records = _load_json(LIBRARY_PATH, [])
if isinstance(local_records, list):
records.extend(local_records)
return _merge_records(records)
def _seed_cover_url(seed: SeedBook) -> str:
return f"data:image/png;base64,{_render_cover_png(seed.title, seed.subtitle, seed.category, seed.accent)}"
def _render_library_cards(records: List[Dict[str, Any]]) -> str:
cards = []
for record in records:
badge_html = []
if record.get("featured"):
badge_html.append('<span class="badge featured">Featured</span>')
if record.get("kind") == "seed":
badge_html.append('<span class="badge">Showcase</span>')
else:
badge_html.append('<span class="badge">Generated</span>')
badge_html.append('<span class="badge">Members</span>')
links = []
if record.get("html_url"):
links.append(f'<a class="link-btn" href="{_esc(record["html_url"])}" target="_blank" rel="noreferrer">Read</a>')
if record.get("markdown_url"):
links.append(f'<a class="link-btn" href="{_esc(record["markdown_url"])}" target="_blank" rel="noreferrer">Markdown</a>')
if record.get("report_url"):
links.append(f'<a class="link-btn" href="{_esc(record["report_url"])}" target="_blank" rel="noreferrer">Report</a>')
cover = record.get("cover_url") or ""
cards.append(
f"""
<article class="book-card">
<img src="{cover}" alt="{_esc(record['title'])} cover">
<div class="book-card-body">
<div class="card-line">
<span class="pill">{_esc(record.get('category', 'Ebook'))}</span>
{''.join(badge_html)}
</div>
<h3>{_esc(record['title'])}</h3>
<p class="sub">{_esc(record['subtitle'])}</p>
<p class="desc">{_esc(record['description'])}</p>
<div class="meta">{_esc(record.get('audience', ''))} · {_esc(record.get('length', ''))}</div>
<div class="button-row">{''.join(links)}</div>
</div>
</article>
"""
)
return "".join(cards)
def _persist_library_record(record: Dict[str, Any]) -> None:
existing = _load_json(LIBRARY_PATH, [])
if not isinstance(existing, list):
existing = []
existing = [item for item in existing if item.get("slug") != record["slug"]]
existing.append(record)
_save_json(LIBRARY_PATH, _merge_records(existing))
_supabase_insert("ebook_library", record)
def _persist_member_record(record: Dict[str, Any]) -> None:
existing = _load_json(MEMBERS_PATH, [])
if not isinstance(existing, list):
existing = []
existing = [item for item in existing if item.get("email") != record["email"]]
existing.append(record)
_save_json(MEMBERS_PATH, existing)
_supabase_insert("ebook_members", record)
def _build_book_package(
*,
topic: str,
market: str,
audience: str,
tone: str,
chapter_count: int,
idea_index: int,
research: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
if research is None:
research = build_research_report(
topic=topic,
market=market,
audience=audience,
tone=tone,
source_limit=6,
)
selected_idea = _selected_idea_from_research(research, idea_index)
chapters = _chapter_blueprint(selected_idea, research, chapter_count)
slug = _slugify(selected_idea["title"])
cover_accent = _palette_for(selected_idea["title"])
cover_b64 = _render_cover_png(
selected_idea["title"],
selected_idea["subtitle"],
selected_idea.get("price_point", "Ebook"),
cover_accent,
)
html_doc = _render_ebook_html(
title=selected_idea["title"],
subtitle=selected_idea["subtitle"],
description=selected_idea["description"],
research=research,
selected_idea=selected_idea,
chapters=chapters,
cover_b64=cover_b64,
)
markdown_doc = _render_ebook_markdown(
title=selected_idea["title"],
subtitle=selected_idea["subtitle"],
research=research,
selected_idea=selected_idea,
chapters=chapters,
)
export_dir = EXPORTS_DIR / slug
export_dir.mkdir(parents=True, exist_ok=True)
cover_path = export_dir / "cover.png"
html_path = export_dir / "book.html"
markdown_path = export_dir / "book.md"
report_path = export_dir / "report.json"
cover_path.write_bytes(base64.b64decode(cover_b64))
html_path.write_text(html_doc, encoding="utf-8")
markdown_path.write_text(markdown_doc, encoding="utf-8")
package = {
"slug": slug,
"title": selected_idea["title"],
"subtitle": selected_idea["subtitle"],
"description": selected_idea["description"],
"market": market,
"audience": audience,
"tone": tone,
"topic": topic,
"cover_url": f"/exports/{slug}/cover.png",
"html_url": f"/exports/{slug}/book.html",
"markdown_url": f"/exports/{slug}/book.md",
"report_url": f"/exports/{slug}/report.json",
"created_at": _now_iso(),
"featured": True,
"kind": "generated",
"length": f"{chapter_count} chapters",
"tags": list(selected_idea.get("seo_keywords", [])[:5]),
"category": "Generated Ebook",
"source_idea_index": selected_idea["index"],
"selected_idea": selected_idea,
"research": research,
"chapters": chapters,
"book_html": html_doc,
"book_markdown": markdown_doc,
"cover_base64": cover_b64,
}
report_path.write_text(json.dumps({"research": research, "package": package}, indent=2, ensure_ascii=False), encoding="utf-8")
record = {
"slug": slug,
"title": selected_idea["title"],
"subtitle": selected_idea["subtitle"],
"description": selected_idea["description"],
"category": "Generated Ebook",
"audience": audience,
"length": f"{chapter_count} chapters",
"tags": list(selected_idea.get("seo_keywords", [])[:5]),
"featured": True,
"kind": "generated",
"cover_url": f"/exports/{slug}/cover.png",
"html_url": f"/exports/{slug}/book.html",
"markdown_url": f"/exports/{slug}/book.md",
"report_url": f"/exports/{slug}/report.json",
"created_at": package["created_at"],
}
_persist_library_record(record)
package["library_record"] = record
return package
def _stripe_plan_price_id(plan: str) -> Optional[str]:
if plan == "annual":
return STRIPE_PRICE_ID_ANNUAL or None
return STRIPE_PRICE_ID_MONTHLY or None
def _stripe_configured() -> bool:
return bool(STRIPE_SECRET_KEY and STRIPE_PRICE_ID_MONTHLY)
def _build_checkout_session(request: Request, payload: CheckoutRequest) -> Dict[str, Any]:
if not STRIPE_SECRET_KEY:
raise HTTPException(status_code=501, detail="STRIPE_SECRET_KEY is not configured.")
if "@" not in payload.email or payload.email.count("@") != 1:
raise HTTPException(status_code=400, detail="A valid email address is required.")
price_id = _stripe_plan_price_id(payload.plan)
if not price_id:
raise HTTPException(status_code=501, detail=f"Missing Stripe price ID for plan '{payload.plan}'.")
origin = (payload.public_base_url or "").strip() or _base_url(request)
success_url = f"{origin}/?success=1&session_id={{CHECKOUT_SESSION_ID}}"
cancel_url = f"{origin}/?canceled=1"
form = {
"mode": "subscription",
"success_url": success_url,
"cancel_url": cancel_url,
"customer_email": payload.email,
"line_items[0][price]": price_id,
"line_items[0][quantity]": "1",
"metadata[plan]": payload.plan,
"metadata[email]": payload.email,
"metadata[name]": payload.name or "",
}
response = httpx.post(
"https://api.stripe.com/v1/checkout/sessions",
data=form,
auth=(STRIPE_SECRET_KEY, ""),
timeout=20.0,
)
if response.status_code >= 400:
raise HTTPException(status_code=response.status_code, detail=response.text)
session = response.json()
return {
"checkout_url": session.get("url"),
"session_id": session.get("id"),
"message": "Checkout session created.",
}
def _stripe_session_status(session_id: str) -> Dict[str, Any]:
if not STRIPE_SECRET_KEY:
raise HTTPException(status_code=501, detail="STRIPE_SECRET_KEY is not configured.")
response = httpx.get(
f"https://api.stripe.com/v1/checkout/sessions/{session_id}",
auth=(STRIPE_SECRET_KEY, ""),
timeout=20.0,
)
if response.status_code >= 400:
raise HTTPException(status_code=response.status_code, detail=response.text)
session = response.json()
return {
"session_id": session.get("id"),
"status": session.get("status"),
"payment_status": session.get("payment_status"),
"customer_email": session.get("customer_email"),
"mode": session.get("mode"),
"metadata": session.get("metadata", {}),
"subscription": session.get("subscription"),
}
def _parse_stripe_signature(header: str) -> tuple[str, List[str]]:
parts: Dict[str, List[str]] = {}
for chunk in header.split(","):
if "=" in chunk:
key, value = chunk.split("=", 1)
parts.setdefault(key.strip(), []).append(value.strip())
if "t" not in parts or "v1" not in parts:
raise ValueError("Invalid Stripe signature header.")
return parts["t"][0], parts["v1"]
def _verify_stripe_signature(secret: str, body: bytes, timestamp: str, signatures: List[str]) -> None:
expected = hmac.new(
secret.encode("utf-8"),
msg=f"{timestamp}.".encode("utf-8") + body,
digestmod=hashlib.sha256,
).hexdigest()
if not any(hmac.compare_digest(expected, signature) for signature in signatures):
raise ValueError("Invalid Stripe signature.")
def _upsert_member_from_session(session: Dict[str, Any]) -> None:
email = session.get("customer_email") or ""
if not email:
return
record = {
"email": email,
"name": session.get("metadata", {}).get("name") or "",
"plan": session.get("metadata", {}).get("plan") or "monthly",
"customer_id": session.get("customer") or "",
"subscription_id": session.get("subscription") or "",
"session_id": session.get("id") or "",
"status": session.get("payment_status") or session.get("status") or "active",
"updated_at": _now_iso(),
}
existing = _load_json(MEMBERS_PATH, [])
if not isinstance(existing, list):
existing = []
existing = [item for item in existing if item.get("email") != email]
existing.append(record)
_save_json(MEMBERS_PATH, existing)
_supabase_insert("ebook_members", record)
@app.get("/", response_class=HTMLResponse)
def home(request: Request) -> HTMLResponse:
status = _status_snapshot()
library_records = _all_library_records()
library_html = _render_library_cards(library_records)
status_json = json.dumps(status)
return HTMLResponse(
f"""<!doctype html>
<html lang="en-AU">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{_esc(APP_NAME)}</title>
<style>
:root {{
--bg: #f5f2ea;
--panel: #ffffff;
--ink: #13202a;
--muted: #57636d;
--line: #d9ddd8;
--accent: #0f766e;
--accent2: #b45309;
--accent3: #14532d;
--shadow: 0 16px 34px rgba(19, 32, 42, 0.08);
--radius: 10px;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: linear-gradient(180deg, #f5f2ea 0%, #fafafa 100%);
color: var(--ink);
}}
a {{ color: inherit; }}
main {{ max-width: 1240px; margin: 0 auto; padding: 22px; }}
.topbar {{
display: grid;
grid-template-columns: 1.3fr 0.7fr;
gap: 18px;
align-items: end;
margin-bottom: 18px;
}}
.eyebrow {{
color: var(--accent);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.12em;
}}
h1 {{
margin: 8px 0 10px;
font-size: clamp(34px, 5vw, 54px);
line-height: 1;
max-width: 12ch;
}}
.lede {{
margin: 0;
color: var(--muted);
line-height: 1.6;
max-width: 70ch;
}}
.status-grid {{
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}}
.status {{
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 14px;
min-height: 82px;
}}
.status strong {{
display: block;
font-size: 24px;
margin-bottom: 4px;
}}
.status span {{
color: var(--muted);
font-size: 13px;
}}
.workspace {{
display: grid;
grid-template-columns: 1.15fr 0.85fr;
gap: 16px;
margin-bottom: 16px;
}}
.panel {{
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 18px;
}}
.panel-head {{
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 14px;
flex-wrap: wrap;
}}
.panel h2 {{
margin: 0;
font-size: 22px;
}}
.form-grid {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}}
.field, select {{
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px 14px;
font: inherit;
background: #fff;
}}
.field-row {{
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
margin-top: 12px;
}}
.button-row, .action-row {{
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 14px;
}}
.btn {{
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0 16px;
border-radius: 8px;
border: 1px solid transparent;
cursor: pointer;
font: inherit;
font-weight: 700;
text-decoration: none;
}}
.btn.primary {{ background: var(--ink); color: #fff; }}
.btn.secondary {{ background: #fff; border-color: var(--line); }}
.btn.ghost {{ background: #f8fafc; border-color: var(--line); color: var(--accent); }}
.message {{
margin-top: 12px;
padding: 12px 14px;
border-radius: 8px;
border: 1px solid var(--line);
background: #f8fafc;
color: var(--muted);
min-height: 48px;
white-space: pre-wrap;
}}
.preview {{
display: grid;
gap: 12px;
}}
.cover-preview {{
display: grid;
grid-template-columns: 180px 1fr;
gap: 14px;
align-items: start;
}}
.cover-preview img {{
width: 100%;
aspect-ratio: 3/4;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--line);
background: #fff;
}}
.chips {{ display: flex; flex-wrap: wrap; gap: 8px; }}
.chip, .pill, .badge {{
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
font-weight: 700;
border: 1px solid var(--line);
background: #fff;
color: #41515b;
}}
.pill {{ background: #ecfeff; color: #0f766e; border-color: #c7eeea; }}
.badge.featured {{ background: #fff7ed; color: #9a3412; border-color: #fed7aa; }}
.split {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}}
.list, .meta-list {{
list-style: none;
margin: 0;
padding: 0;
}}
.list li {{
padding: 10px 0;
border-bottom: 1px solid var(--line);
}}
.ideas-grid, .library-grid {{
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}}
.idea-card, .book-card {{
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
overflow: hidden;
display: flex;
flex-direction: column;
}}
.idea-card.selected {{ outline: 2px solid var(--accent); }}
.idea-card-body, .book-card-body {{
padding: 14px;
display: grid;
gap: 10px;
}}
.idea-card h3, .book-card h3 {{
margin: 0;
font-size: 18px;
line-height: 1.2;
}}
.idea-card p, .book-card p {{
margin: 0;
color: var(--muted);
line-height: 1.5;
}}
.idea-meta, .book-meta {{
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}}
.tag-row, .button-row {{
display: flex;
flex-wrap: wrap;
gap: 8px;
}}
.tag {{
background: #f8fafc;
color: #42515b;
}}
.link-btn {{
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid var(--line);
text-decoration: none;
font-size: 13px;
font-weight: 700;
background: #fff;
}}
.section {{
margin-bottom: 12px;
padding-top: 2px;
}}
.section h3 {{
margin: 0 0 8px;
font-size: 16px;
color: var(--accent);
}}
.section p {{ margin: 0 0 10px; color: var(--muted); line-height: 1.6; }}
.chapter-preview {{
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: #fff;
}}
.chapter-preview h4 {{
margin: 0 0 8px;
font-size: 15px;
}}
.small {{
font-size: 13px;
color: var(--muted);
line-height: 1.5;
}}
.searchbar {{
width: min(100%, 320px);
border: 1px solid var(--line);
border-radius: 999px;
padding: 11px 14px;
background: #fff;
font: inherit;
}}
.footer {{
margin: 18px 0 8px;
color: var(--muted);
font-size: 13px;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}}
@media (max-width: 980px) {{
.topbar, .workspace, .split, .cover-preview, .form-grid, .ideas-grid, .library-grid {{
grid-template-columns: 1fr;
}}
}}
</style>
</head>
<body>
<main>
<header class="topbar">
<div>
<div class="eyebrow">{_esc(APP_NAME)}</div>
<h1>Ebook Research, Build, and Subscription Studio</h1>
<p class="lede">Run market research, generate SEO-led ebook ideas, build a polished ebook package, and publish a showcase library with Stripe membership signup.</p>
</div>
<div class="status-grid">
<div class="status"><strong id="status-openai">-</strong><span>OpenAI</span></div>
<div class="status"><strong id="status-supabase">-</strong><span>Supabase</span></div>
<div class="status"><strong id="status-stripe">-</strong><span>Stripe</span></div>
</div>
</header>
<section class="workspace">
<section class="panel">
<div class="panel-head">
<h2>Workspace</h2>
<div class="chips">
<span class="pill" id="library-count-pill">{len(library_records)} books</span>
<span class="pill" id="member-count-pill">{status["member_count"]} members</span>
</div>
</div>
<form id="builder-form">
<div class="form-grid">
<input class="field" id="topic" type="text" placeholder="Topic" value="Ebook publishing" autocomplete="off">
<input class="field" id="market" type="text" placeholder="Market" value="United States" autocomplete="off">
<input class="field" id="audience" type="text" placeholder="Audience" value="independent readers" autocomplete="off">
<select id="tone">
<option value="practical" selected>Practical</option>
<option value="premium">Premium</option>
<option value="editorial">Editorial</option>
<option value="technical">Technical</option>
<option value="conversational">Conversational</option>
</select>
</div>
<div class="field-row">
<select id="chapter_count">
<option value="5">5 chapters</option>
<option value="6" selected>6 chapters</option>
<option value="7">7 chapters</option>
<option value="8">8 chapters</option>
</select>
<select id="source_limit">
<option value="4">4 sources</option>
<option value="6" selected>6 sources</option>
<option value="8">8 sources</option>
<option value="10">10 sources</option>
</select>
<select id="idea_index">
<option value="0" selected>Idea 1</option>
<option value="1">Idea 2</option>
<option value="2">Idea 3</option>
<option value="3">Idea 4</option>
<option value="4">Idea 5</option>
</select>
</div>
<div class="button-row">
<button class="btn primary" type="submit" id="research-btn">Run research</button>
<button class="btn secondary" type="button" id="build-btn">Build ebook</button>
<button class="btn ghost" type="button" id="reset-btn">Reset</button>
</div>
</form>
<div class="message" id="workspace-message">Idle</div>
<div class="preview" id="preview-panel">
<div class="cover-preview">
<img id="preview-cover" alt="Preview cover" src="{_seed_cover_url(SEED_BOOKS[0])}">
<div>
<div class="chips" id="preview-badges">
<span class="pill">Preview</span>
<span class="badge">Ready</span>
</div>
<h3 id="preview-title">{_esc(SEED_BOOKS[0].title)}</h3>
<p class="small" id="preview-subtitle">{_esc(SEED_BOOKS[0].subtitle)}</p>
<p class="small" id="preview-description">{_esc(SEED_BOOKS[0].description)}</p>
<div class="action-row">
<a class="link-btn" id="download-html" href="#" target="_blank" rel="noreferrer">HTML</a>
<a class="link-btn" id="download-md" href="#" target="_blank" rel="noreferrer">Markdown</a>
<a class="link-btn" id="download-report" href="#" target="_blank" rel="noreferrer">Report</a>
</div>
</div>
</div>
<div class="chapter-preview" id="chapter-preview">
<h4>Chapters</h4>
<div class="small">No ebook built yet.</div>
</div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Research</h2>
<input id="idea-search" class="searchbar" type="search" placeholder="Filter ideas and library">
</div>
<div class="split">
<section>
<h3>Summary</h3>
<div class="section" id="research-summary"><p class="small">Run a research pass to fill this section.</p></div>
<h3>Demand</h3>
<ul class="list" id="demand-signals"></ul>
<h3>Pain points</h3>
<ul class="list" id="reader-pain-points"></ul>
</section>
<section>
<h3>Keyword clusters</h3>
<div id="keyword-clusters"></div>
<h3>Sources</h3>
<ul class="list" id="source-list"></ul>
</section>
</div>
</section>
</section>
<section class="panel" style="margin-bottom: 16px;">
<div class="panel-head">
<h2>Ideas</h2>
<div class="small">Select one idea, then build the ebook.</div>
</div>
<div class="ideas-grid" id="ideas-grid"></div>
</section>
<section class="panel" style="margin-bottom: 16px;">
<div class="panel-head">
<h2>Showcase</h2>
<div class="small">Search the library and open the built ebook files.</div>
</div>
<div class="library-grid" id="library-grid">{library_html}</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Membership</h2>
<div class="small">Stripe subscription checkout for readers and members.</div>
</div>
<form id="membership-form">
<div class="form-grid">
<input class="field" id="member-email" type="email" placeholder="Email address" autocomplete="email">
<input class="field" id="member-name" type="text" placeholder="Name" autocomplete="name">
</div>
<div class="field-row">
<select id="plan">
<option value="monthly" selected>Monthly membership</option>
<option value="annual">Annual membership</option>
</select>
<input class="field" id="public-base-url" type="text" placeholder="Public base URL" value="{_esc(PUBLIC_BASE_URL)}" autocomplete="off">
<button class="btn primary" type="submit">Start checkout</button>
</div>
</form>
<div class="message" id="membership-message">Checkout ready when Stripe keys are configured.</div>
</section>
<div class="footer">
<span>OpenAI: optional</span>
<span>Supabase: optional persistence</span>
<span>Stripe: subscriptions</span>
</div>
</main>
<script>
const initialStatus = {status_json};
let currentResearch = null;
let currentBook = null;
let selectedIdeaIndex = 0;
const escapeHtml = (value) => String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
const el = (id) => document.getElementById(id);
function updateStatusChips() {{
el("status-openai").textContent = initialStatus.openai_ready ? "ON" : "OFF";
el("status-supabase").textContent = initialStatus.supabase_ready ? "ON" : "OFF";
el("status-stripe").textContent = initialStatus.stripe_ready ? "ON" : "OFF";
el("library-count-pill").textContent = `${{initialStatus.library_count}} books`;
el("member-count-pill").textContent = `${{initialStatus.member_count}} members`;
}}
function renderResearch(report) {{
const summary = el("research-summary");
summary.innerHTML = `<p>${{escapeHtml(report.executive_summary || "")}}</p>`;
el("demand-signals").innerHTML = (report.demand_signals || []).map((item) => `<li>${{escapeHtml(item)}}</li>`).join("");
el("reader-pain-points").innerHTML = (report.reader_pain_points || []).map((item) => `<li>${{escapeHtml(item)}}</li>`).join("");
el("source-list").innerHTML = (report.sources || []).map((item) => `
<li>
<strong>${{escapeHtml(item.title || "")}}</strong><br>
<span class="small">${{escapeHtml(item.snippet || "")}}</span>
</li>`).join("");
el("keyword-clusters").innerHTML = (report.seo_clusters || []).map((cluster) => `
<div class="chapter-preview" style="margin-bottom:10px;">
<h4>${{escapeHtml(cluster.cluster || "")}}</h4>
<div class="tag-row">${{(cluster.keywords || []).map((keyword) => `<span class="tag">${{escapeHtml(keyword)}}</span>`).join("")}}</div>
<div class="small" style="margin-top:8px;">${{escapeHtml(cluster.intent || "")}} · ${{escapeHtml(cluster.angle || "")}}</div>
</div>`).join("");
renderIdeas(report.book_ideas || []);
}}
function renderIdeas(ideas) {{
const grid = el("ideas-grid");
if (!ideas.length) {{
grid.innerHTML = '<div class="small">No ideas returned.</div>';
return;
}}
grid.innerHTML = ideas.map((idea, index) => `
<article class="idea-card ${{index === selectedIdeaIndex ? 'selected' : ''}}" data-index="${{index}}">
<div class="idea-card-body">
<div class="card-line">
<span class="pill">Idea ${{index + 1}}</span>
<span class="badge">${{escapeHtml(idea.price_point || "$14.99")}}</span>
</div>
<h3>${{escapeHtml(idea.title || "")}}</h3>
<p>${{escapeHtml(idea.subtitle || "")}}</p>
<div class="idea-meta">${{escapeHtml(idea.positioning || "")}}</div>
<div class="tag-row">${{(idea.seo_keywords || []).map((keyword) => `<span class="tag">${{escapeHtml(keyword)}}</span>`).join("")}}</div>
<div class="button-row">
<button class="btn secondary" type="button" onclick="selectIdea(${{index}})">Select</button>
</div>
</div>
</article>
`).join("");
}}
function selectIdea(index) {{
selectedIdeaIndex = index;
if (currentResearch) {{
renderIdeas(currentResearch.book_ideas || []);
}}
if (currentResearch && currentResearch.book_ideas && currentResearch.book_ideas[index]) {{
previewIdea(currentResearch.book_ideas[index]);
}}
}}
function previewIdea(idea) {{
if (!idea) return;
el("preview-title").textContent = idea.title || "";
el("preview-subtitle").textContent = idea.subtitle || "";
el("preview-description").textContent = idea.description || "";
el("preview-cover").src = currentBook?.cover_url || el("preview-cover").src;
}}
function renderBook(book) {{
currentBook = book;
el("preview-title").textContent = book.title || "";
el("preview-subtitle").textContent = book.subtitle || "";
el("preview-description").textContent = book.description || "";
el("preview-cover").src = book.cover_url || el("preview-cover").src;
el("download-html").href = book.html_url || "#";
el("download-md").href = book.markdown_url || "#";
el("download-report").href = book.report_url || "#";
const chapters = (book.chapters || []).map((chapter) => `
<div class="chapter-preview" style="margin-bottom:10px;">
<h4>Chapter ${{chapter.number}}: ${{escapeHtml(chapter.title || "")}}</h4>
<div class="small">${{escapeHtml(chapter.summary || "")}}</div>
</div>`).join("");
el("chapter-preview").innerHTML = chapters || '<div class="small">No chapters yet.</div>';
el("workspace-message").textContent = `Built: ${{book.title}}`;
refreshLibrary();
}}
function renderLibrary(records) {{
const query = el("idea-search").value.trim().toLowerCase();
const filtered = records.filter((record) => {{
const haystack = [record.title, record.subtitle, record.category, (record.tags || []).join(" ")].join(" ").toLowerCase();
return !query || haystack.includes(query);
}});
el("library-grid").innerHTML = filtered.map((record) => `
<article class="book-card">
<img src="${{record.cover_url || ''}}" alt="${{escapeHtml(record.title || '')}} cover">
<div class="book-card-body">
<div class="card-line">
<span class="pill">${{escapeHtml(record.category || 'Ebook')}}</span>
${{record.featured ? '<span class="badge featured">Featured</span>' : ''}}
${{record.kind === 'generated' ? '<span class="badge">Generated</span>' : '<span class="badge">Showcase</span>'}}
</div>
<h3>${{escapeHtml(record.title || '')}}</h3>
<p>${{escapeHtml(record.subtitle || '')}}</p>
<div class="idea-meta">${{escapeHtml(record.description || '')}}</div>
<div class="book-meta">${{escapeHtml(record.audience || '')}} · ${{escapeHtml(record.length || '')}}</div>
<div class="button-row">
${{record.html_url ? `<a class="link-btn" href="${{record.html_url}}" target="_blank" rel="noreferrer">Read</a>` : ''}}
${{record.markdown_url ? `<a class="link-btn" href="${{record.markdown_url}}" target="_blank" rel="noreferrer">Markdown</a>` : ''}}
${{record.report_url ? `<a class="link-btn" href="${{record.report_url}}" target="_blank" rel="noreferrer">Report</a>` : ''}}
</div>
</div>
</article>
`).join("");
}}
async function fetchLibrary() {{
const response = await fetch("/api/library");
const data = await response.json();
renderLibrary(data.books || []);
initialStatus.library_count = (data.books || []).length;
el("library-count-pill").textContent = `${{initialStatus.library_count}} books`;
}}
async function runResearch() {{
const payload = {{
topic: el("topic").value.trim(),
market: el("market").value.trim(),
audience: el("audience").value.trim(),
tone: el("tone").value,
source_limit: Number(el("source_limit").value),
}};
el("workspace-message").textContent = "Running research...";
const response = await fetch("/api/research", {{
method: "POST",
headers: {{ "Content-Type": "application/json" }},
body: JSON.stringify(payload),
}});
const data = await response.json();
if (!response.ok) {{
throw new Error(data.detail || data.error || "Research failed");
}}
currentResearch = data;
selectedIdeaIndex = Number(el("idea_index").value || 0);
renderResearch(data);
previewIdea((data.book_ideas || [])[selectedIdeaIndex]);
el("workspace-message").textContent = "Research complete";
return data;
}}
async function buildBook() {{
if (!currentResearch) {{
await runResearch();
}}
const payload = {{
topic: el("topic").value.trim(),
market: el("market").value.trim(),
audience: el("audience").value.trim(),
tone: el("tone").value,
chapter_count: Number(el("chapter_count").value),
idea_index: selectedIdeaIndex,
research: currentResearch,
}};
el("workspace-message").textContent = "Building ebook...";
const response = await fetch("/api/build", {{
method: "POST",
headers: {{ "Content-Type": "application/json" }},
body: JSON.stringify(payload),
}});
const data = await response.json();
if (!response.ok) {{
throw new Error(data.detail || data.error || "Build failed");
}}
renderBook(data);
currentResearch = data.research;
renderResearch(currentResearch);
el("workspace-message").textContent = "Ebook built";
}}
async function submitMembership(event) {{
event.preventDefault();
const payload = {{
email: el("member-email").value.trim(),
name: el("member-name").value.trim(),
plan: el("plan").value,
public_base_url: el("public-base-url").value.trim(),
}};
const response = await fetch("/api/checkout/session", {{
method: "POST",
headers: {{ "Content-Type": "application/json" }},
body: JSON.stringify(payload),
}});
const data = await response.json();
if (!response.ok) {{
el("membership-message").textContent = data.detail || data.error || "Checkout failed.";
return;
}}
if (data.checkout_url) {{
window.location.href = data.checkout_url;
}} else {{
el("membership-message").textContent = data.message || "Checkout created.";
}}
}}
async function handleCheckoutReturn() {{
const params = new URLSearchParams(window.location.search);
const sessionId = params.get("session_id");
if (!sessionId) return;
const response = await fetch(`/api/checkout/session-status/${{sessionId}}`);
const data = await response.json();
if (response.ok) {{
el("membership-message").textContent = `Membership status: ${{data.payment_status || data.status || 'unknown'}} for ${{data.customer_email || 'unknown email'}}`;
}} else {{
el("membership-message").textContent = data.detail || data.error || "Could not verify checkout.";
}}
}}
async function refreshLibrary() {{
await fetchLibrary();
}}
function resetWorkspace() {{
el("topic").value = "Ebook publishing";
el("market").value = "United States";
el("audience").value = "independent readers";
el("tone").value = "practical";
el("chapter_count").value = "6";
el("source_limit").value = "6";
el("idea_index").value = "0";
el("idea-search").value = "";
selectedIdeaIndex = 0;
currentResearch = null;
currentBook = null;
el("workspace-message").textContent = "Reset";
renderIdeas([]);
el("research-summary").innerHTML = '<p class="small">Run a research pass to fill this section.</p>';
el("demand-signals").innerHTML = "";
el("reader-pain-points").innerHTML = "";
el("keyword-clusters").innerHTML = "";
el("source-list").innerHTML = "";
el("chapter-preview").innerHTML = '<div class="small">No ebook built yet.</div>';
el("download-html").href = "#";
el("download-md").href = "#";
el("download-report").href = "#";
el("preview-cover").src = "{_seed_cover_url(SEED_BOOKS[0])}";
}}
el("builder-form").addEventListener("submit", async (event) => {{
event.preventDefault();
try {{
await runResearch();
}} catch (error) {{
el("workspace-message").textContent = error.message;
}}
}});
el("build-btn").addEventListener("click", async () => {{
try {{
await buildBook();
}} catch (error) {{
el("workspace-message").textContent = error.message;
}}
}});
el("reset-btn").addEventListener("click", resetWorkspace);
el("membership-form").addEventListener("submit", submitMembership);
el("idea-search").addEventListener("input", () => {{
if (currentResearch) {{
renderIdeas(currentResearch.book_ideas || []);
}}
fetchLibrary();
}});
el("idea_index").addEventListener("change", (event) => {{
selectedIdeaIndex = Number(event.target.value || 0);
if (currentResearch) {{
previewIdea((currentResearch.book_ideas || [])[selectedIdeaIndex]);
renderIdeas(currentResearch.book_ideas || []);
}}
}});
updateStatusChips();
fetchLibrary();
handleCheckoutReturn();
</script>
</body>
</html>"""
)
@app.get("/api/health")
def api_health() -> Dict[str, Any]:
return {"status": "ok", **_status_snapshot()}
@app.get("/api/library")
def api_library() -> Dict[str, Any]:
return {"books": _all_library_records()}
@app.post("/api/research")
def api_research(payload: ResearchRequest) -> Dict[str, Any]:
report = build_research_report(
topic=payload.topic,
market=payload.market,
audience=payload.audience,
tone=payload.tone,
source_limit=payload.source_limit,
)
return report
@app.post("/api/build")
def api_build(payload: BuildRequest) -> Dict[str, Any]:
package = _build_book_package(
topic=payload.topic,
market=payload.market,
audience=payload.audience,
tone=payload.tone,
chapter_count=payload.chapter_count,
idea_index=payload.idea_index,
research=payload.research,
)
return {
"slug": package["slug"],
"title": package["title"],
"subtitle": package["subtitle"],
"description": package["description"],
"cover_url": package["cover_url"],
"html_url": package["html_url"],
"markdown_url": package["markdown_url"],
"report_url": package["report_url"],
"created_at": package["created_at"],
"selected_idea": package["selected_idea"],
"research": package["research"],
"chapters": package["chapters"],
"tags": package["tags"],
}
@app.get("/api/books/{slug}")
def api_book(slug: str) -> Dict[str, Any]:
records = _all_library_records()
for record in records:
if record["slug"] == slug:
return record
raise HTTPException(status_code=404, detail="Book not found.")
@app.post("/api/checkout/session")
def api_checkout_session(request: Request, payload: CheckoutRequest) -> Dict[str, Any]:
return _build_checkout_session(request, payload)
@app.get("/api/checkout/session-status/{session_id}")
def api_checkout_session_status(session_id: str) -> Dict[str, Any]:
return _stripe_session_status(session_id)
@app.post("/api/webhooks/stripe")
async def api_stripe_webhook(request: Request) -> JSONResponse:
raw_body = await request.body()
if STRIPE_WEBHOOK_SECRET:
signature = request.headers.get("Stripe-Signature", "")
try:
timestamp, signatures = _parse_stripe_signature(signature)
_verify_stripe_signature(STRIPE_WEBHOOK_SECRET, raw_body, timestamp, signatures)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
event = await request.json()
event_type = event.get("type")
if event_type == "checkout.session.completed":
session = event.get("data", {}).get("object", {})
_upsert_member_from_session(session)
return JSONResponse({"received": True})
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "7860")))