Spaces:
Runtime error
Runtime error
| 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") | |
| 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) | |
| 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("&", "&") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">") | |
| .replaceAll('"', """) | |
| .replaceAll("'", "'"); | |
| 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>""" | |
| ) | |
| def api_health() -> Dict[str, Any]: | |
| return {"status": "ok", **_status_snapshot()} | |
| def api_library() -> Dict[str, Any]: | |
| return {"books": _all_library_records()} | |
| 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 | |
| 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"], | |
| } | |
| 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.") | |
| def api_checkout_session(request: Request, payload: CheckoutRequest) -> Dict[str, Any]: | |
| return _build_checkout_session(request, payload) | |
| def api_checkout_session_status(session_id: str) -> Dict[str, Any]: | |
| return _stripe_session_status(session_id) | |
| 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"))) | |